mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-21 06:21:16 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55e1e885d5 | ||
|
|
777f14fad4 | ||
|
|
42bd103958 |
No files matched your search
@@ -466,13 +466,16 @@ jobs:
|
||||
# the bounds checks are compiled in, so a stripped exe still catches them.
|
||||
find "$d/build_flatpak/tests" -type f -perm -u+x -exec strip --strip-unneeded {} + 2>/dev/null || true
|
||||
# At runtime the tests read tests/ (TEST_DATA_DIR), scripts/, and under
|
||||
# resources/ the shipped profiles (PROFILES_DIR), the printers/ maps, and
|
||||
# the icon SVGs (NativeCommands icon names are checked against them).
|
||||
# resources/ the shipped profiles (PROFILES_DIR), the printers/ maps, the
|
||||
# icon SVGs (NativeCommands icon names are checked against them), and the
|
||||
# one font the text-import tests vectorize.
|
||||
find "$d" -mindepth 1 -maxdepth 1 -type d \
|
||||
! -name tests ! -name build_flatpak ! -name scripts ! -name resources -exec rm -rf {} +
|
||||
find "$d/resources" -mindepth 1 -maxdepth 1 ! -name profiles ! -name printers ! -name images -exec rm -rf {} +
|
||||
find "$d/resources" -mindepth 1 -maxdepth 1 \
|
||||
! -name profiles ! -name printers ! -name images ! -name fonts -exec rm -rf {} +
|
||||
# Only the SVGs are read; the png/ico/icns/gif assets are ~35MB of dead weight.
|
||||
find "$d/resources/images" -mindepth 1 -maxdepth 1 ! -name '*.svg' -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$d/resources/fonts" -mindepth 1 -maxdepth 1 ! -name HarmonyOS_Sans_SC_Regular.ttf -exec rm -rf {} +
|
||||
tar -cf flatpak-test-asset.tar flatpak_app "$d"
|
||||
- name: Upload flatpak test asset
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
@@ -123,7 +123,6 @@ option(SLIC3R_ASAN "Enable ASan on Clang and GCC" 0)
|
||||
|
||||
# Python stubgen module
|
||||
option(ORCA_BUILD_PYTHON_STUBGEN_MODULE "Build importable Python module for pybind11-stubgen" ON)
|
||||
option(ORCA_BUILD_PLUGIN_PERMISSION_PROBE "Build the headless Python plugin permission probe (developer tool)" OFF)
|
||||
set(ORCA_BUNDLED_UV_EXECUTABLE "" CACHE FILEPATH "Path to a uv executable to bundle for Python package installation. Leave empty to auto-download.")
|
||||
|
||||
# Auto-download uv if not provided by the user.
|
||||
|
||||
+3
-25
@@ -47,7 +47,7 @@ while getopts ":dpa:snt:xbc:i:j:Tuh" opt; do
|
||||
u )
|
||||
export BUILD_TARGET="universal"
|
||||
;;
|
||||
h ) echo "Usage: ./build_release_macos.sh [-d] [-- CMAKE_ARGS...]"
|
||||
h ) echo "Usage: ./build_release_macos.sh [-d]"
|
||||
echo " -d: Build deps only"
|
||||
echo " -a: Set ARCHITECTURE (arm64 or x86_64 or universal)"
|
||||
echo " -s: Build slicer only"
|
||||
@@ -60,33 +60,13 @@ while getopts ":dpa:snt:xbc:i:j:Tuh" opt; do
|
||||
echo " -i: Add a prefix to ignore during CMake dependency discovery (repeatable), defaults to /opt/local:/usr/local:/opt/homebrew"
|
||||
echo " -j: Set the number of parallel build jobs (CMAKE_BUILD_PARALLEL_LEVEL)"
|
||||
echo " -T: Build and run tests (set ORCA_TESTS_BUILD_ONLY=1 to build without running)"
|
||||
echo " -- CMAKE_ARGS...: pass everything after '--' to the cmake configure step of the deps (-d) and slicer (-s) builds,"
|
||||
echo " e.g. -DDESTDIR=<dir> for the deps, -DCMAKE_PREFIX_PATH=<dir>/usr/local -DSLIC3R_PCH=OFF for the slicer, --fresh."
|
||||
echo " They come last on the cmake command line. Use absolute paths: cmake runs in the build directory."
|
||||
exit 0
|
||||
;;
|
||||
: )
|
||||
echo "Option -${OPTARG} requires an argument, aborting."
|
||||
exit 1
|
||||
;;
|
||||
* )
|
||||
echo "Unknown option -${OPTARG}, aborting. CMake arguments must follow '--'."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# getopts stops at '--'; whatever follows is handed to cmake unchanged.
|
||||
if [[ ${OPTIND} -le $# ]] ; then
|
||||
separator_index=$((OPTIND-1))
|
||||
if [[ "${!separator_index}" != "--" ]] ; then
|
||||
echo "Unexpected argument '${!OPTIND}': cmake arguments must follow '--', aborting."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
shift $((OPTIND-1))
|
||||
EXTRA_CMAKE_ARGS=("$@")
|
||||
|
||||
# Set defaults
|
||||
|
||||
if [ -z "$ARCH" ]; then
|
||||
@@ -231,8 +211,7 @@ function build_deps() {
|
||||
-DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \
|
||||
-DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \
|
||||
"${CMAKE_CCACHE_ARGS[@]}" \
|
||||
${CMAKE_POLICY_COMPAT} \
|
||||
"${EXTRA_CMAKE_ARGS[@]}"
|
||||
${CMAKE_POLICY_COMPAT}
|
||||
fi
|
||||
cmake --build . --config "$BUILD_CONFIG" --target deps
|
||||
)
|
||||
@@ -335,8 +314,7 @@ function build_slicer() {
|
||||
-DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \
|
||||
-DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \
|
||||
"${CMAKE_CCACHE_ARGS[@]}" \
|
||||
${CMAKE_POLICY_COMPAT} \
|
||||
"${EXTRA_CMAKE_ARGS[@]}"
|
||||
${CMAKE_POLICY_COMPAT}
|
||||
fi
|
||||
cmake --build . --config "$BUILD_CONFIG" --target "$SLICER_BUILD_TARGET"
|
||||
)
|
||||
|
||||
@@ -366,7 +366,6 @@ src/slic3r/Utils/MoonrakerPrinterAgent.cpp
|
||||
src/slic3r/Utils/BBLNetworkPlugin.cpp
|
||||
src/slic3r/Utils/OrcaCloudServiceAgent.cpp
|
||||
src/slic3r/plugin/PluginAuditManager.cpp
|
||||
src/slic3r/plugin/PluginAuditManagerGui.cpp
|
||||
src/slic3r/plugin/PluginConfig.cpp
|
||||
src/slic3r/plugin/PluginResolver.cpp
|
||||
src/slic3r/plugin/CloudPluginService.cpp
|
||||
|
||||
@@ -23,7 +23,7 @@ if %FULL_MODE%==1 (
|
||||
call :prepareGettextList "%list_file%" "%filtered_list%" "%missing_list%"
|
||||
if "!has_sources!"=="1" (
|
||||
if not exist "%generated_i18n%" mkdir "%generated_i18n%"
|
||||
.\tools\xgettext.exe --keyword=L --keyword=_L --keyword=_u8L --keyword=PLUGIN_U8L --keyword=L_CONTEXT:1,2c --keyword=_L_CONTEXT:1,2c --keyword=_u8L_CONTEXT:1,2c --keyword=_L_PLURAL:1,2 --add-comments=TRN --from-code=UTF-8 --no-location --debug --boost --no-wrap -f "%filtered_list%" -o "%generated_pot%"
|
||||
.\tools\xgettext.exe --keyword=L --keyword=_L --keyword=_u8L --keyword=L_CONTEXT:1,2c --keyword=_L_CONTEXT:1,2c --keyword=_u8L_CONTEXT:1,2c --keyword=_L_PLURAL:1,2 --add-comments=TRN --from-code=UTF-8 --no-location --debug --boost --no-wrap -f "%filtered_list%" -o "%generated_pot%"
|
||||
if errorlevel 1 (
|
||||
set "script_exit_code=1"
|
||||
) else (
|
||||
|
||||
@@ -85,7 +85,7 @@ if $FULL_MODE; then
|
||||
generated_pot_file="${generated_i18n_dir}/OrcaSlicer.pot"
|
||||
|
||||
mkdir -p "$generated_i18n_dir"
|
||||
xgettext --keyword=L --keyword=_L --keyword=_u8L --keyword=PLUGIN_U8L --keyword=L_CONTEXT:1,2c --keyword=_L_CONTEXT:1,2c --keyword=_u8L_CONTEXT:1,2c --keyword=_L_PLURAL:1,2 --add-comments=TRN --from-code=UTF-8 --no-location --debug --boost --no-wrap -f "$filtered_list" -o "$generated_pot_file"
|
||||
xgettext --keyword=L --keyword=_L --keyword=_u8L --keyword=L_CONTEXT:1,2c --keyword=_L_CONTEXT:1,2c --keyword=_u8L_CONTEXT:1,2c --keyword=_L_PLURAL:1,2 --add-comments=TRN --from-code=UTF-8 --no-location --debug --boost --no-wrap -f "$filtered_list" -o "$generated_pot_file"
|
||||
python3 scripts/HintsToPot.py ./resources "$generated_i18n_dir"
|
||||
|
||||
if [ -f "$pot_file" ] && files_equal_ignoring_pot_date "$pot_file" "$generated_pot_file"; then
|
||||
|
||||
@@ -707,10 +707,6 @@ set(SLIC3R_GUI_SOURCES
|
||||
plugin/PluginManager.hpp
|
||||
plugin/PluginAuditManager.cpp
|
||||
plugin/PluginAuditManager.hpp
|
||||
plugin/PluginAuditManagerGui.cpp
|
||||
plugin/PluginAuditManagerInternal.hpp
|
||||
plugin/PluginModule.cpp
|
||||
plugin/PluginText.hpp
|
||||
plugin/PluginResolver.cpp
|
||||
plugin/PluginResolver.hpp
|
||||
plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.hpp
|
||||
@@ -1096,65 +1092,3 @@ if (ORCA_BUILD_PYTHON_STUBGEN_MODULE)
|
||||
PREFIX ""
|
||||
)
|
||||
endif()
|
||||
|
||||
# Headless plugin permission probe. A distinct executable so normal GUI loading and prompt
|
||||
# behavior stay untouched. It compiles the GUI-free plugin sources directly instead of linking
|
||||
# libslic3r_gui, so neither the GUI library nor wx is pulled into the probe. The permission
|
||||
# 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/probe/PluginPermissionProbe.cpp
|
||||
plugin/probe/PluginProbeCollector.hpp
|
||||
plugin/probe/NanoSvgImplementation.cpp
|
||||
plugin/PluginAuditManager.cpp
|
||||
plugin/PluginAuditManagerInternal.hpp
|
||||
plugin/PluginModule.cpp
|
||||
plugin/PluginText.hpp
|
||||
plugin/probe/PluginAuditManagerHeadless.cpp
|
||||
plugin/PluginFsUtils.cpp
|
||||
plugin/PluginLoader.cpp
|
||||
plugin/PythonInterpreter.cpp
|
||||
plugin/PythonPluginBridge.cpp
|
||||
plugin/probe/PluginHostProbe.cpp
|
||||
plugin/probe/PluginProbeConfig.cpp
|
||||
plugin/host/PluginHostGeometry.cpp
|
||||
plugin/host/PluginHostMesh.cpp
|
||||
plugin/host/PluginHostModel.cpp
|
||||
plugin/host/PluginHostSlicing.cpp
|
||||
plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.cpp
|
||||
plugin/pluginTypes/pages/PagesPluginCapability.cpp
|
||||
plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapability.cpp
|
||||
plugin/pluginTypes/script/ScriptPluginCapability.cpp
|
||||
plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp
|
||||
)
|
||||
|
||||
target_compile_definitions(orca-plugin-probe PRIVATE ORCA_PLUGIN_PROBE_BUILD)
|
||||
|
||||
target_include_directories(orca-plugin-probe PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}
|
||||
${CMAKE_CURRENT_BINARY_DIR}
|
||||
${CMAKE_SOURCE_DIR}/src
|
||||
Utils
|
||||
)
|
||||
|
||||
target_link_libraries(orca-plugin-probe PRIVATE
|
||||
libslic3r
|
||||
nanosvg
|
||||
pybind11::embed
|
||||
)
|
||||
|
||||
set_target_properties(orca-plugin-probe PROPERTIES
|
||||
OUTPUT_NAME orca-plugin-probe
|
||||
FOLDER "tools"
|
||||
)
|
||||
endif()
|
||||
@@ -1,5 +1,4 @@
|
||||
#include "PluginAuditManager.hpp"
|
||||
#include "PluginAuditManagerInternal.hpp"
|
||||
|
||||
#include "../Utils/OrcaCloudServiceAgent.hpp"
|
||||
#include "libslic3r/Utils.hpp"
|
||||
@@ -16,12 +15,25 @@
|
||||
#include <cctype>
|
||||
#include <cstddef>
|
||||
#include <cstdlib>
|
||||
#include <future>
|
||||
#include <mutex>
|
||||
#include <memory>
|
||||
#include <slic3r/GUI/BindDialog.hpp>
|
||||
#include <slic3r/GUI/GUI_App.hpp>
|
||||
#include <slic3r/plugin/PluginFsUtils.hpp>
|
||||
#include <slic3r/plugin/PluginManager.hpp>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
#include <wx/app.h>
|
||||
#include <wx/event.h>
|
||||
#include <wx/msgdlg.h>
|
||||
#include <wx/string.h>
|
||||
#include <wx/thread.h>
|
||||
#include "slic3r/GUI/I18N.hpp"
|
||||
#include "slic3r/plugin/PluginDescriptor.hpp"
|
||||
#include <Python.h>
|
||||
|
||||
namespace Slic3r {
|
||||
@@ -149,10 +161,8 @@ static bool is_fs_category(AuditEventCategory category)
|
||||
}
|
||||
}
|
||||
|
||||
namespace plugin_audit {
|
||||
|
||||
// The category's name in a saved event grant, e.g. "FsRead" in "FsRead:open".
|
||||
const char* category_name(AuditEventCategory category)
|
||||
static const char* category_name(AuditEventCategory category)
|
||||
{
|
||||
switch (category) {
|
||||
case AuditEventCategory::FsRead: return "FsRead";
|
||||
@@ -169,29 +179,6 @@ const char* category_name(AuditEventCategory category)
|
||||
return "None";
|
||||
}
|
||||
|
||||
int report_denied(PluginAuditManager& mgr,
|
||||
const std::string& event_name,
|
||||
const AuditDecision& decision,
|
||||
const std::string& target)
|
||||
{
|
||||
AuditViolation violation;
|
||||
violation.plugin_key = mgr.current_plugin();
|
||||
violation.event_name = event_name;
|
||||
violation.reason = decision.reason;
|
||||
mgr.report_violation(violation);
|
||||
|
||||
std::string message = "Plugin attempted audited operation \"" + event_name + "\" without permission";
|
||||
if (!decision.reason.empty())
|
||||
message += ": " + decision.reason;
|
||||
if (!target.empty())
|
||||
message += ": " + target;
|
||||
|
||||
PyErr_SetString(PyExc_PermissionError, message.c_str());
|
||||
return -1;
|
||||
}
|
||||
|
||||
} // namespace plugin_audit
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path safety
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -260,7 +247,6 @@ bool is_inside_allowed_root(const boost::filesystem::path& candidate, const boos
|
||||
|
||||
thread_local std::string PluginAuditManager::m_current_plugin_key = "";
|
||||
thread_local std::string PluginAuditManager::m_current_capability_name = "";
|
||||
thread_local std::string PluginAuditManager::m_current_audit_phase = "";
|
||||
thread_local std::vector<AllowedRoot> PluginAuditManager::m_scoped_allowed_roots;
|
||||
thread_local std::vector<PyObject*> PluginAuditManager::m_root_frames;
|
||||
thread_local int PluginAuditManager::m_releasing_frames = 0;
|
||||
@@ -330,76 +316,6 @@ std::string PluginAuditManager::current_capability() const { return m_current_ca
|
||||
|
||||
void PluginAuditManager::clear_current_capability() { m_current_capability_name.clear(); }
|
||||
|
||||
void PluginAuditManager::set_current_audit_phase(const std::string& phase) { m_current_audit_phase = phase; }
|
||||
|
||||
const std::string& PluginAuditManager::current_audit_phase() const { return m_current_audit_phase; }
|
||||
|
||||
void PluginAuditManager::clear_current_audit_phase() { m_current_audit_phase.clear(); }
|
||||
|
||||
void PluginAuditManager::set_observation_observer(AuditObserver observer)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_observer_mutex);
|
||||
m_observation_observer = std::move(observer);
|
||||
}
|
||||
|
||||
void PluginAuditManager::clear_observation_observer()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_observer_mutex);
|
||||
m_observation_observer = nullptr;
|
||||
}
|
||||
|
||||
bool PluginAuditManager::observation_mode() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_observer_mutex);
|
||||
return static_cast<bool>(m_observation_observer);
|
||||
}
|
||||
|
||||
// The audit hook holds the GIL here. Copy the observer out before invoking it so a callback that
|
||||
// reads the manager cannot deadlock on m_observer_mutex.
|
||||
bool PluginAuditManager::record_observation(const std::string& event_name,
|
||||
const std::string& category,
|
||||
const std::vector<std::string>& targets)
|
||||
{
|
||||
AuditObserver observer;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_observer_mutex);
|
||||
observer = m_observation_observer;
|
||||
}
|
||||
if (!observer)
|
||||
return false;
|
||||
|
||||
// The observer is called from a CPython C callback; a C++ exception must not unwind through
|
||||
// the audit hook. The probe's collector only allocates, so a throw here is a lost observation,
|
||||
// not a reason to abort the audited operation.
|
||||
try {
|
||||
AuditObservation observation;
|
||||
observation.plugin_key = m_current_plugin_key;
|
||||
observation.capability = m_current_capability_name;
|
||||
observation.phase = m_current_audit_phase;
|
||||
observation.event_name = event_name;
|
||||
observation.category = category;
|
||||
observation.targets = targets;
|
||||
// Probe mode never denies: the hook returns 0 after recording. A later failure of the
|
||||
// surrounding Python operation is reported by the probe's callback/load result, not here.
|
||||
observation.continued = true;
|
||||
observer(observation);
|
||||
} catch (...) {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
ScopedPluginAuditPhase::ScopedPluginAuditPhase(std::string phase)
|
||||
: m_phase(std::move(phase))
|
||||
, m_previous_phase(PluginAuditManager::instance().current_audit_phase())
|
||||
{
|
||||
PluginAuditManager::instance().set_current_audit_phase(m_phase);
|
||||
}
|
||||
|
||||
ScopedPluginAuditPhase::~ScopedPluginAuditPhase()
|
||||
{
|
||||
PluginAuditManager::instance().set_current_audit_phase(m_previous_phase);
|
||||
}
|
||||
|
||||
void PluginAuditManager::add_global_allowed_root(const boost::filesystem::path& root, bool allow_write)
|
||||
{
|
||||
if (root.empty())
|
||||
@@ -437,18 +353,16 @@ void PluginAuditManager::add_denied_filename(const std::string& filename)
|
||||
std::vector<std::string> PluginAuditManager::default_denied_filenames()
|
||||
{
|
||||
// AppConfig::config_path() picks .conf vs .ini on the USE_JSON_CONFIG ifdef and the app key
|
||||
// on its mode, and is a non-static member we cannot call without an instance. Denying all
|
||||
// on its mode, and is a non-static member we cannot call without an instance. Denying all
|
||||
// four names is cheaper and more robust than replicating that; the two unused names cost one
|
||||
// string comparison each. Keep the plugin install-state sidecar here too: it controls which
|
||||
// packages load on startup and must remain host-managed. Single-sourced so install_hook() and
|
||||
// the tests seed from the same list and cannot drift apart.
|
||||
// string comparison each. Single-sourced here so install_hook() and the tests seed from the
|
||||
// same list and cannot drift apart.
|
||||
return {
|
||||
SLIC3R_APP_KEY ".conf",
|
||||
GCODEVIEWER_APP_KEY ".conf",
|
||||
SLIC3R_APP_KEY ".ini",
|
||||
GCODEVIEWER_APP_KEY ".ini",
|
||||
secret_constants::USER_SECRET_FILENAME,
|
||||
".install_state.json",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -620,14 +534,73 @@ bool PluginAuditManager::request_filesystem_read_permissions(const std::string&
|
||||
if (plugin_key.empty() || paths.empty())
|
||||
return true;
|
||||
|
||||
// Probe mode records the declaration and proceeds. It must not consult the install state,
|
||||
// open the permission dialog, or persist a grant.
|
||||
if (observation_mode()) {
|
||||
record_observation("orca.request_permissions", "FsRead", paths);
|
||||
PluginDescriptor descriptor;
|
||||
if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty())
|
||||
return false;
|
||||
|
||||
PluginInstallState state;
|
||||
read_install_state(boost::filesystem::path(descriptor.plugin_root), state);
|
||||
|
||||
std::vector<std::string> missing;
|
||||
for (const std::string& path : paths) {
|
||||
if (std::find(state.permissions.fs_read.begin(), state.permissions.fs_read.end(), path) == state.permissions.fs_read.end())
|
||||
missing.push_back(path);
|
||||
}
|
||||
if (missing.empty())
|
||||
return true;
|
||||
|
||||
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
|
||||
return false;
|
||||
|
||||
auto show_dialog = [&descriptor, &missing]() {
|
||||
wxString requested_paths;
|
||||
for (const std::string& path : missing)
|
||||
requested_paths += wxString::FromUTF8(path.c_str()) + "\n";
|
||||
|
||||
wxMessageDialog dialog(
|
||||
nullptr,
|
||||
wxString::Format(_L("Plugin \"%s\" requests filesystem read access to:\n%s"),
|
||||
wxString::FromUTF8(descriptor.name.c_str()), requested_paths),
|
||||
_L("Plugin permissions"),
|
||||
wxYES_NO | wxICON_WARNING);
|
||||
return dialog.ShowModal() == wxID_YES;
|
||||
};
|
||||
|
||||
bool granted = false;
|
||||
if (wxIsMainThread()) {
|
||||
granted = show_dialog();
|
||||
} else {
|
||||
auto result = std::make_shared<std::promise<bool>>();
|
||||
auto future = result->get_future();
|
||||
GUI::wxGetApp().CallAfter([result, descriptor_name = descriptor.name, missing]() {
|
||||
wxString requested_paths;
|
||||
for (const std::string& path : missing)
|
||||
requested_paths += wxString::FromUTF8(path.c_str()) + "\n";
|
||||
|
||||
wxMessageDialog dialog(
|
||||
nullptr,
|
||||
wxString::Format(_L("Plugin \"%s\" requests filesystem read access to:\n%s"),
|
||||
wxString::FromUTF8(descriptor_name.c_str()), requested_paths),
|
||||
_L("Plugin permissions"),
|
||||
wxYES_NO | wxICON_WARNING);
|
||||
result->set_value(dialog.ShowModal() == wxID_YES);
|
||||
});
|
||||
granted = future.get();
|
||||
}
|
||||
|
||||
return plugin_audit::request_filesystem_read_permissions(plugin_key, paths);
|
||||
if (!granted)
|
||||
return false;
|
||||
|
||||
if (state.plugin_name.empty()) {
|
||||
state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local";
|
||||
state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version;
|
||||
state.plugin_name = descriptor.name;
|
||||
state.cloud_uuid = descriptor.cloud_uuid();
|
||||
state.enabled = true;
|
||||
}
|
||||
|
||||
state.permissions.fs_read.insert(state.permissions.fs_read.end(), missing.begin(), missing.end());
|
||||
return write_install_state(boost::filesystem::path(descriptor.plugin_root), state);
|
||||
}
|
||||
|
||||
void PluginAuditManager::report_violation(const AuditViolation& violation)
|
||||
@@ -889,18 +862,239 @@ std::vector<std::string> audit_targets(const std::string& event_name, AuditEvent
|
||||
return targets;
|
||||
}
|
||||
|
||||
// The saved list that grants an event: its category's target list, or the event-grant list for
|
||||
// an event with no target. Kept apart, so that no grant doubles as a target of the same name (an
|
||||
// os.fork grant is not an os.exec of a command named "os.fork").
|
||||
std::vector<std::string>* permission_list_for(AuditEventCategory category, bool by_event, PluginPermissions& permissions)
|
||||
{
|
||||
if (by_event)
|
||||
return &permissions.events;
|
||||
|
||||
switch (category) {
|
||||
case AuditEventCategory::FsRead: return &permissions.fs_read;
|
||||
case AuditEventCategory::FsReadWrite: return &permissions.fs_readwrite;
|
||||
case AuditEventCategory::Http: return &permissions.network_http;
|
||||
case AuditEventCategory::Socket: return &permissions.network_socket;
|
||||
case AuditEventCategory::ProcessCreate: return &permissions.process;
|
||||
case AuditEventCategory::Threading: return &permissions.threading;
|
||||
case AuditEventCategory::ProcessReplace: return &permissions.process;
|
||||
default: return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
// An event with no target is granted by its effective category and name, e.g. "FsRead:open"
|
||||
// for a read of a file descriptor, which does not grant a write of one ("FsReadWrite:open").
|
||||
std::string event_grant_key(AuditEventCategory category, const std::string& event_name)
|
||||
{
|
||||
return std::string(category_name(category)) + ":" + event_name;
|
||||
}
|
||||
|
||||
bool has_permission(const std::vector<std::string>& granted, const std::string& target)
|
||||
{
|
||||
return std::find(granted.begin(), granted.end(), target) != granted.end();
|
||||
}
|
||||
|
||||
bool persist_permission(const std::string& plugin_key,
|
||||
PluginInstallState& state,
|
||||
std::vector<std::string>& permission_list,
|
||||
const std::string& target)
|
||||
{
|
||||
PluginDescriptor descriptor;
|
||||
if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty())
|
||||
return false;
|
||||
|
||||
// A sandbox plugin may not have a sidecar yet. Seed the small amount of install metadata
|
||||
// needed by the writer so clicking Yes still creates the JSON file.
|
||||
if (state.plugin_name.empty()) {
|
||||
state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local";
|
||||
state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version;
|
||||
state.plugin_name = descriptor.name;
|
||||
state.cloud_uuid = descriptor.cloud_uuid();
|
||||
state.enabled = true;
|
||||
}
|
||||
|
||||
if (!has_permission(permission_list, target))
|
||||
permission_list.push_back(target);
|
||||
return write_install_state(boost::filesystem::path(descriptor.plugin_root), state);
|
||||
}
|
||||
|
||||
int report_denied(PluginAuditManager& mgr,
|
||||
const std::string& event_name,
|
||||
const AuditDecision& decision,
|
||||
const std::string& target = {})
|
||||
{
|
||||
AuditViolation violation;
|
||||
violation.plugin_key = mgr.current_plugin();
|
||||
violation.event_name = event_name;
|
||||
violation.reason = decision.reason;
|
||||
mgr.report_violation(violation);
|
||||
|
||||
std::string message = "Plugin attempted audited operation \"" + event_name + "\" without permission";
|
||||
if (!decision.reason.empty())
|
||||
message += ": " + decision.reason;
|
||||
if (!target.empty())
|
||||
message += ": " + target;
|
||||
|
||||
PyErr_SetString(PyExc_PermissionError, message.c_str());
|
||||
return -1;
|
||||
}
|
||||
|
||||
wxString audit_message(AuditEventCategory category, const wxString& plugin_name, const wxString& event_name,
|
||||
const wxString& target_list, size_t target_count)
|
||||
{
|
||||
if (target_list.IsEmpty())
|
||||
return wxString::Format(
|
||||
_L("Plugin \"%s\" is requesting permission for the Python audit event \"%s\".\n\n"
|
||||
"This operation does not expose a target the audit hook can display."),
|
||||
plugin_name, event_name);
|
||||
|
||||
switch (category) {
|
||||
case AuditEventCategory::FsRead:
|
||||
return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to read the following file:\n%s",
|
||||
"Plugin \"%s\" is requesting to read the following files:\n%s", target_count),
|
||||
plugin_name, target_list);
|
||||
case AuditEventCategory::FsReadWrite:
|
||||
return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to read/write the following file:\n%s",
|
||||
"Plugin \"%s\" is requesting to read/write the following files:\n%s", target_count),
|
||||
plugin_name, target_list);
|
||||
case AuditEventCategory::FsCreate:
|
||||
return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to create the following file:\n%s",
|
||||
"Plugin \"%s\" is requesting to create the following files:\n%s", target_count),
|
||||
plugin_name, target_list);
|
||||
case AuditEventCategory::FsDelete:
|
||||
return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to delete the following file:\n%s",
|
||||
"Plugin \"%s\" is requesting to delete the following files:\n%s", target_count),
|
||||
plugin_name, target_list);
|
||||
case AuditEventCategory::Http:
|
||||
return wxString::Format(_L("Plugin \"%s\" is requesting to make an HTTP request to:\n%s"), plugin_name, target_list);
|
||||
case AuditEventCategory::Socket:
|
||||
return wxString::Format(_L("Plugin \"%s\" is requesting to open a network connection to:\n%s"), plugin_name, target_list);
|
||||
case AuditEventCategory::ProcessCreate:
|
||||
return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to run the following command:\n%s",
|
||||
"Plugin \"%s\" is requesting to run the following commands:\n%s", target_count),
|
||||
plugin_name, target_list);
|
||||
case AuditEventCategory::Threading:
|
||||
return wxString::Format(_L("Plugin \"%s\" is requesting permission to create a thread."), plugin_name);
|
||||
case AuditEventCategory::ProcessReplace:
|
||||
return wxString::Format(_L("Plugin \"%s\" is requesting to replace the running application with:\n%s"), plugin_name, target_list);
|
||||
default:
|
||||
return wxString::Format(_L("Plugin \"%s\" is requesting permission for the Python audit event \"%s\"."), plugin_name, event_name);
|
||||
}
|
||||
}
|
||||
|
||||
// Builds and shows the modal permission prompt. Must run on the GUI thread.
|
||||
bool prompt_for_targets(AuditEventCategory category, const std::string& plugin_name, const std::string& event_name,
|
||||
const std::vector<std::string>& unresolved)
|
||||
{
|
||||
wxString target_list;
|
||||
for (const auto& target : unresolved)
|
||||
target_list += wxString::FromUTF8(target.c_str()) + "\n";
|
||||
|
||||
wxMessageDialog dialog(nullptr,
|
||||
audit_message(category, wxString::FromUTF8(plugin_name.c_str()),
|
||||
wxString::FromUTF8(event_name.c_str()), target_list, unresolved.size()),
|
||||
_L("Plugin permission request"), wxYES_NO | wxICON_WARNING);
|
||||
return dialog.ShowModal() == wxID_YES;
|
||||
}
|
||||
|
||||
// Records a grant in the plugin's sidecar so it is not asked again. Reads the install state
|
||||
// freshly because the async prompt outlives the caller's stack copy of it.
|
||||
void persist_grant(const std::string& plugin_key, AuditEventCategory category, bool by_event, const std::vector<std::string>& keys)
|
||||
{
|
||||
PluginInstallState state;
|
||||
if (!PluginManager::instance().get_install_state(plugin_key, state))
|
||||
return;
|
||||
|
||||
std::vector<std::string>* permission_list = permission_list_for(category, by_event, state.permissions);
|
||||
if (!permission_list)
|
||||
return;
|
||||
|
||||
for (const auto& key : keys)
|
||||
persist_permission(plugin_key, state, *permission_list, key);
|
||||
}
|
||||
|
||||
static PluginAuditManager::PermissionPrompt s_permission_prompt_for_testing;
|
||||
|
||||
// Asks the user to grant an audited event, returning true when they approve an inline prompt.
|
||||
// `keys` are what a Yes saves: the unresolved targets, or the event-grant key when the event has
|
||||
// no target (by_event), which the prompt does not show as a target.
|
||||
//
|
||||
// An audited event can fire on a thread the UI thread may itself be blocked waiting on: the
|
||||
// SlicingPipeline hook runs on the slicing worker thread (see PluginHooks.cpp), and
|
||||
// BackgroundSlicingProcess::stop()/stop_internal() park the UI thread until that worker stops.
|
||||
// Blocking the worker on a marshaled modal -- which is safe for the plugin-load worker that
|
||||
// request_filesystem_read_permissions runs on -- would therefore deadlock the application (the
|
||||
// invariant PluginHostUi.cpp documents for slicing-hook UI calls). Off the main thread the prompt
|
||||
// is therefore posted asynchronously and the current event denied (fail closed, like an unanswered
|
||||
// prompt); the grant is persisted once the user accepts, so a later attempt succeeds without
|
||||
// re-prompting.
|
||||
bool request_permission(AuditEventCategory category, const std::string& plugin_key, const std::string& plugin_name,
|
||||
const std::string& event_name, bool by_event, const std::vector<std::string>& keys)
|
||||
{
|
||||
const std::vector<std::string> shown = by_event ? std::vector<std::string>{} : keys;
|
||||
|
||||
if (s_permission_prompt_for_testing)
|
||||
return s_permission_prompt_for_testing(event_name, shown);
|
||||
|
||||
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
|
||||
return false;
|
||||
|
||||
if (wxIsMainThread())
|
||||
return prompt_for_targets(category, plugin_name, event_name, shown);
|
||||
|
||||
GUI::wxGetApp().CallAfter([category, plugin_key, plugin_name, event_name, by_event, keys, shown]() {
|
||||
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
|
||||
return;
|
||||
if (prompt_for_targets(category, plugin_name, event_name, shown))
|
||||
persist_grant(plugin_key, category, by_event, keys);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Decides an event that no allowed root or running operation covers: a saved grant allows it,
|
||||
// otherwise the user is asked and a Yes is saved.
|
||||
int decide_audited_event(PluginAuditManager& mgr,
|
||||
PluginInstallState& state,
|
||||
const std::string& plugin_key,
|
||||
const std::string& plugin_name,
|
||||
const std::string& event_name,
|
||||
AuditEventCategory category,
|
||||
const std::vector<std::string>& targets)
|
||||
{
|
||||
const bool by_event = targets.empty();
|
||||
const std::vector<std::string> keys = by_event ? std::vector<std::string>{event_grant_key(category, event_name)} : targets;
|
||||
std::vector<std::string>* permission_list = permission_list_for(category, by_event, state.permissions);
|
||||
|
||||
std::vector<std::string> unresolved = keys;
|
||||
if (permission_list) {
|
||||
unresolved.clear();
|
||||
for (const auto& key : keys)
|
||||
if (!has_permission(*permission_list, key))
|
||||
unresolved.push_back(key);
|
||||
if (unresolved.empty())
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Letting a root frame go runs finalizers inside a scope destructor or another event's hook,
|
||||
// where no dialog may open: what they do must already be granted.
|
||||
if (mgr.releasing_frames())
|
||||
return report_denied(mgr, event_name, {false, "audit permission required"});
|
||||
|
||||
if (!request_permission(category, plugin_key, plugin_name, event_name, by_event, unresolved))
|
||||
return report_denied(mgr, event_name, {false, "audit permission required"});
|
||||
|
||||
if (permission_list)
|
||||
for (const auto& key : unresolved)
|
||||
persist_permission(plugin_key, state, *permission_list, key);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Judges an audited event raised while a plugin context is active.
|
||||
int audit_plugin_event(PluginAuditManager& mgr, const std::string& event_name, AuditEventCategory event_type, PyObject* args)
|
||||
{
|
||||
const bool fs_category = is_fs_category(event_type);
|
||||
const std::vector<std::string> targets = audit_targets(event_name, event_type, args);
|
||||
|
||||
// Probe mode: report the classified event and let it through. Deliberately before the deny
|
||||
// registry and allowed-root checks, so the report covers operations that production would
|
||||
// block as well as those it would allow. No grant is read or written.
|
||||
if (mgr.record_observation(event_name, plugin_audit::category_name(event_type), targets))
|
||||
return 0;
|
||||
|
||||
// A denied path (secrets, certificates, config files -- see is_denied_path) is an
|
||||
// unconditional block: checked before the running-operation check below, so no granted
|
||||
// operation can launder access to one, and before the allowed-root shortcut further down,
|
||||
@@ -909,7 +1103,7 @@ int audit_plugin_event(PluginAuditManager& mgr, const std::string& event_name, A
|
||||
if (fs_category) {
|
||||
for (const auto& target : targets) {
|
||||
if (mgr.is_denied_path(boost::filesystem::path(target)))
|
||||
return plugin_audit::report_denied(mgr, event_name, {false, "denied path"}, target);
|
||||
return report_denied(mgr, event_name, {false, "denied path"}, target);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -934,7 +1128,15 @@ int audit_plugin_event(PluginAuditManager& mgr, const std::string& event_name, A
|
||||
return 0;
|
||||
}
|
||||
|
||||
const int result = plugin_audit::decide_event(mgr, event_name, event_type, targets);
|
||||
PluginInstallState state;
|
||||
const bool have_install_state = PluginManager::instance().get_install_state(mgr.current_plugin(), state);
|
||||
if (!have_install_state) {
|
||||
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " Failed to get install state for " << mgr.current_plugin();
|
||||
}
|
||||
|
||||
const std::string plugin_name = state.plugin_name.empty() ? mgr.current_plugin() : state.plugin_name;
|
||||
|
||||
const int result = decide_audited_event(mgr, state, mgr.current_plugin(), plugin_name, event_name, event_type, targets);
|
||||
|
||||
// A granted operation covers its nested events while it runs. A targetless grant names no
|
||||
// operation, so it covers nothing else.
|
||||
@@ -1021,6 +1223,11 @@ void PluginAuditManager::release_frames(std::vector<PyObject*>& frames)
|
||||
PyErr_SetRaisedException(error);
|
||||
}
|
||||
|
||||
void PluginAuditManager::set_permission_prompt_for_testing(PermissionPrompt prompt)
|
||||
{
|
||||
PluginAuditDetail::s_permission_prompt_for_testing = std::move(prompt);
|
||||
}
|
||||
|
||||
int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user_data)
|
||||
{
|
||||
auto* mgr = static_cast<PluginAuditManager*>(user_data);
|
||||
@@ -1076,9 +1283,8 @@ void PluginAuditManager::install_hook()
|
||||
// "cert" denied-path keyword seeded below.
|
||||
add_global_allowed_root(resources_dir(), /*allow_write=*/false);
|
||||
|
||||
// The user's app config and cloud credentials live directly inside data_dir(), and plugin
|
||||
// install state lives beneath it, so the root just granted would otherwise expose them to
|
||||
// plugins. Deny them by name.
|
||||
// The user's app config and cloud credentials live directly inside data_dir(), so the
|
||||
// root just granted would otherwise expose them to any plugin. Deny them by name.
|
||||
//
|
||||
// Seeded here rather than by each secret's owner because install_hook() runs during lazy
|
||||
// interpreter init and therefore provably precedes any plugin bytecode, whereas
|
||||
|
||||
@@ -24,19 +24,6 @@ struct AuditViolation {
|
||||
std::string reason;
|
||||
};
|
||||
|
||||
// One classified CPython audit event observed while the probe is in record-only mode. This is
|
||||
// evidence about the code path that ran, not a grant request: the hook allowed the operation to
|
||||
// continue and did not consult the permission dialog or the persisted install state.
|
||||
struct AuditObservation {
|
||||
std::string plugin_key;
|
||||
std::string capability;
|
||||
std::string phase;
|
||||
std::string event_name;
|
||||
std::string category;
|
||||
std::vector<std::string> targets;
|
||||
bool continued = true;
|
||||
};
|
||||
|
||||
// A filesystem root a plugin may access while an audit context is active. allow_write is
|
||||
// false for a root that only grants reads (e.g. the bundled, shared resources folder) --
|
||||
// a write-shaped event never matches such a root, even though a read-shaped one does.
|
||||
@@ -150,30 +137,6 @@ public:
|
||||
bool request_filesystem_read_permissions(const std::string& plugin_key,
|
||||
const std::vector<std::string>& paths);
|
||||
|
||||
// --- probe observation mode (thread-safe, never used by the GUI) ---
|
||||
// When an observer is installed, every classified event raised by plugin-authored code is
|
||||
// reported to it and then allowed through (the hook returns 0). The permission dialogs, saved
|
||||
// grants and .install_state.json are never consulted, so this is not a containment boundary.
|
||||
// The observer must not touch Python objects: it runs inside the audit hook with the GIL held.
|
||||
using AuditObserver = std::function<void(const AuditObservation&)>;
|
||||
void set_observation_observer(AuditObserver observer);
|
||||
void clear_observation_observer();
|
||||
bool observation_mode() const;
|
||||
|
||||
// Stop after classification/target extraction; true means an observer consumed the event.
|
||||
// Exposed for the probe and its tests, not for production callers.
|
||||
bool record_observation(const std::string& event_name,
|
||||
const std::string& category,
|
||||
const std::vector<std::string>& targets);
|
||||
|
||||
// --- probe phase attribution (thread_local) ---
|
||||
// Labels events for the probe's report. Production leaves it empty. A Python thread spawned by
|
||||
// plugin code inherits no phase, so its events are reported as unattributed rather than being
|
||||
// guessed at.
|
||||
void set_current_audit_phase(const std::string& phase);
|
||||
const std::string& current_audit_phase() const;
|
||||
void clear_current_audit_phase();
|
||||
|
||||
void report_violation(const AuditViolation& violation);
|
||||
bool audit_denial_pending() const;
|
||||
void clear_audit_denial();
|
||||
@@ -215,7 +178,6 @@ private:
|
||||
|
||||
static thread_local std::string m_current_plugin_key;
|
||||
static thread_local std::string m_current_capability_name;
|
||||
static thread_local std::string m_current_audit_phase;
|
||||
static thread_local std::vector<AllowedRoot> m_scoped_allowed_roots;
|
||||
static thread_local std::vector<PyObject*> m_root_frames; // strong references
|
||||
static thread_local int m_releasing_frames;
|
||||
@@ -228,11 +190,6 @@ private:
|
||||
std::vector<AllowedRoot> m_global_allowed_roots;
|
||||
std::vector<std::string> m_denied_filenames;
|
||||
std::vector<std::string> m_denied_path_keywords;
|
||||
|
||||
// Kept apart from m_mutex: observation_mode() is queried from the audit hook while the hook
|
||||
// already holds the GIL, and the setter runs before plugin code starts.
|
||||
mutable std::mutex m_observer_mutex;
|
||||
AuditObserver m_observation_observer;
|
||||
};
|
||||
|
||||
// RAII guard that sets the current plugin key and capability name, restoring the previous
|
||||
@@ -263,22 +220,6 @@ private:
|
||||
std::vector<PyObject*> m_previous_root_frames;
|
||||
};
|
||||
|
||||
// Sets the probe's phase label on the current thread and restores the previous one on scope exit.
|
||||
// Empty in production, where no phase is ever set.
|
||||
class ScopedPluginAuditPhase
|
||||
{
|
||||
public:
|
||||
explicit ScopedPluginAuditPhase(std::string phase);
|
||||
~ScopedPluginAuditPhase();
|
||||
|
||||
ScopedPluginAuditPhase(const ScopedPluginAuditPhase&) = delete;
|
||||
ScopedPluginAuditPhase& operator=(const ScopedPluginAuditPhase&) = delete;
|
||||
|
||||
private:
|
||||
std::string m_phase;
|
||||
std::string m_previous_phase;
|
||||
};
|
||||
|
||||
} // namespace Slic3r
|
||||
|
||||
#endif // slic3r_PluginAuditManager_hpp_
|
||||
@@ -1,332 +0,0 @@
|
||||
#include "PluginAuditManagerInternal.hpp"
|
||||
|
||||
#include "PluginDescriptor.hpp"
|
||||
#include "PluginFsUtils.hpp"
|
||||
#include "PluginManager.hpp"
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <boost/filesystem/path.hpp>
|
||||
#include <boost/log/trivial.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include <future>
|
||||
#include <memory>
|
||||
#include <slic3r/plugin/PluginAuditManager.hpp>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <slic3r/GUI/GUI_App.hpp>
|
||||
#include <slic3r/GUI/I18N.hpp>
|
||||
|
||||
#include <wx/app.h>
|
||||
#include <wx/msgdlg.h>
|
||||
#include <wx/string.h>
|
||||
#include <wx/thread.h>
|
||||
|
||||
namespace Slic3r {
|
||||
namespace {
|
||||
|
||||
// The saved list that grants an event: its category's target list, or the event-grant list for
|
||||
// an event with no target. Kept apart, so that no grant doubles as a target of the same name (an
|
||||
// os.fork grant is not an os.exec of a command named "os.fork").
|
||||
std::vector<std::string>* permission_list_for(AuditEventCategory category, bool by_event, PluginPermissions& permissions)
|
||||
{
|
||||
if (by_event)
|
||||
return &permissions.events;
|
||||
|
||||
switch (category) {
|
||||
case AuditEventCategory::FsRead: return &permissions.fs_read;
|
||||
case AuditEventCategory::FsReadWrite: return &permissions.fs_readwrite;
|
||||
case AuditEventCategory::Http: return &permissions.network_http;
|
||||
case AuditEventCategory::Socket: return &permissions.network_socket;
|
||||
case AuditEventCategory::ProcessCreate: return &permissions.process;
|
||||
case AuditEventCategory::Threading: return &permissions.threading;
|
||||
case AuditEventCategory::ProcessReplace: return &permissions.process;
|
||||
default: return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
// An event with no target is granted by its effective category and name, e.g. "FsRead:open"
|
||||
// for a read of a file descriptor, which does not grant a write of one ("FsReadWrite:open").
|
||||
std::string event_grant_key(AuditEventCategory category, const std::string& event_name)
|
||||
{
|
||||
return std::string(plugin_audit::category_name(category)) + ":" + event_name;
|
||||
}
|
||||
|
||||
bool has_permission(const std::vector<std::string>& granted, const std::string& target)
|
||||
{
|
||||
return std::find(granted.begin(), granted.end(), target) != granted.end();
|
||||
}
|
||||
|
||||
bool persist_permission(const std::string& plugin_key,
|
||||
PluginInstallState& state,
|
||||
std::vector<std::string>& permission_list,
|
||||
const std::string& target)
|
||||
{
|
||||
PluginDescriptor descriptor;
|
||||
if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty())
|
||||
return false;
|
||||
|
||||
// A sandbox plugin may not have a sidecar yet. Seed the small amount of install metadata
|
||||
// needed by the writer so clicking Yes still creates the JSON file.
|
||||
if (state.plugin_name.empty()) {
|
||||
state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local";
|
||||
state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version;
|
||||
state.plugin_name = descriptor.name;
|
||||
state.cloud_uuid = descriptor.cloud_uuid();
|
||||
state.enabled = true;
|
||||
}
|
||||
|
||||
if (!has_permission(permission_list, target))
|
||||
permission_list.push_back(target);
|
||||
return write_install_state(boost::filesystem::path(descriptor.plugin_root), state);
|
||||
}
|
||||
|
||||
wxString audit_message(AuditEventCategory category, const wxString& plugin_name, const wxString& event_name,
|
||||
const wxString& target_list, size_t target_count)
|
||||
{
|
||||
if (target_list.IsEmpty())
|
||||
return wxString::Format(
|
||||
_L("Plugin \"%s\" is requesting permission for the Python audit event \"%s\".\n\n"
|
||||
"This operation does not expose a target the audit hook can display."),
|
||||
plugin_name, event_name);
|
||||
|
||||
switch (category) {
|
||||
case AuditEventCategory::FsRead:
|
||||
return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to read the following file:\n%s",
|
||||
"Plugin \"%s\" is requesting to read the following files:\n%s", target_count),
|
||||
plugin_name, target_list);
|
||||
case AuditEventCategory::FsReadWrite:
|
||||
return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to read/write the following file:\n%s",
|
||||
"Plugin \"%s\" is requesting to read/write the following files:\n%s", target_count),
|
||||
plugin_name, target_list);
|
||||
case AuditEventCategory::FsCreate:
|
||||
return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to create the following file:\n%s",
|
||||
"Plugin \"%s\" is requesting to create the following files:\n%s", target_count),
|
||||
plugin_name, target_list);
|
||||
case AuditEventCategory::FsDelete:
|
||||
return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to delete the following file:\n%s",
|
||||
"Plugin \"%s\" is requesting to delete the following files:\n%s", target_count),
|
||||
plugin_name, target_list);
|
||||
case AuditEventCategory::Http:
|
||||
return wxString::Format(_L("Plugin \"%s\" is requesting to make an HTTP request to:\n%s"), plugin_name, target_list);
|
||||
case AuditEventCategory::Socket:
|
||||
return wxString::Format(_L("Plugin \"%s\" is requesting to open a network connection to:\n%s"), plugin_name, target_list);
|
||||
case AuditEventCategory::ProcessCreate:
|
||||
return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to run the following command:\n%s",
|
||||
"Plugin \"%s\" is requesting to run the following commands:\n%s", target_count),
|
||||
plugin_name, target_list);
|
||||
case AuditEventCategory::Threading:
|
||||
return wxString::Format(_L("Plugin \"%s\" is requesting permission to create a thread."), plugin_name);
|
||||
case AuditEventCategory::ProcessReplace:
|
||||
return wxString::Format(_L("Plugin \"%s\" is requesting to replace the running application with:\n%s"), plugin_name, target_list);
|
||||
default:
|
||||
return wxString::Format(_L("Plugin \"%s\" is requesting permission for the Python audit event \"%s\"."), plugin_name, event_name);
|
||||
}
|
||||
}
|
||||
|
||||
// Builds and shows the modal permission prompt. Must run on the GUI thread.
|
||||
bool prompt_for_targets(AuditEventCategory category, const std::string& plugin_name, const std::string& event_name,
|
||||
const std::vector<std::string>& unresolved)
|
||||
{
|
||||
wxString target_list;
|
||||
for (const auto& target : unresolved)
|
||||
target_list += wxString::FromUTF8(target.c_str()) + "\n";
|
||||
|
||||
wxMessageDialog dialog(nullptr,
|
||||
audit_message(category, wxString::FromUTF8(plugin_name.c_str()),
|
||||
wxString::FromUTF8(event_name.c_str()), target_list, unresolved.size()),
|
||||
_L("Plugin permission request"), wxYES_NO | wxICON_WARNING);
|
||||
return dialog.ShowModal() == wxID_YES;
|
||||
}
|
||||
|
||||
// Records a grant in the plugin's sidecar so it is not asked again. Reads the install state
|
||||
// freshly because the async prompt outlives the caller's stack copy of it.
|
||||
void persist_grant(const std::string& plugin_key, AuditEventCategory category, bool by_event, const std::vector<std::string>& keys)
|
||||
{
|
||||
PluginInstallState state;
|
||||
if (!PluginManager::instance().get_install_state(plugin_key, state))
|
||||
return;
|
||||
|
||||
std::vector<std::string>* permission_list = permission_list_for(category, by_event, state.permissions);
|
||||
if (!permission_list)
|
||||
return;
|
||||
|
||||
for (const auto& key : keys)
|
||||
persist_permission(plugin_key, state, *permission_list, key);
|
||||
}
|
||||
|
||||
PluginAuditManager::PermissionPrompt s_permission_prompt_for_testing;
|
||||
|
||||
// Asks the user to grant an audited event, returning true when they approve an inline prompt.
|
||||
// `keys` are what a Yes saves: the unresolved targets, or the event-grant key when the event has
|
||||
// no target (by_event), which the prompt does not show as a target.
|
||||
//
|
||||
// An audited event can fire on a thread the UI thread may itself be blocked waiting on: the
|
||||
// SlicingPipeline hook runs on the slicing worker thread (see PluginHooks.cpp), and
|
||||
// BackgroundSlicingProcess::stop()/stop_internal() park the UI thread until that worker stops.
|
||||
// Blocking the worker on a marshaled modal -- which is safe for the plugin-load worker that
|
||||
// request_filesystem_read_permissions runs on -- would therefore deadlock the application (the
|
||||
// invariant PluginHostUi.cpp documents for slicing-hook UI calls). Off the main thread the prompt
|
||||
// is therefore posted asynchronously and the current event denied (fail closed, like an unanswered
|
||||
// prompt); the grant is persisted once the user accepts, so a later attempt succeeds without
|
||||
// re-prompting.
|
||||
bool request_permission(AuditEventCategory category, const std::string& plugin_key, const std::string& plugin_name,
|
||||
const std::string& event_name, bool by_event, const std::vector<std::string>& keys)
|
||||
{
|
||||
const std::vector<std::string> shown = by_event ? std::vector<std::string>{} : keys;
|
||||
|
||||
if (s_permission_prompt_for_testing)
|
||||
return s_permission_prompt_for_testing(event_name, shown);
|
||||
|
||||
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
|
||||
return false;
|
||||
|
||||
if (wxIsMainThread())
|
||||
return prompt_for_targets(category, plugin_name, event_name, shown);
|
||||
|
||||
GUI::wxGetApp().CallAfter([category, plugin_key, plugin_name, event_name, by_event, keys, shown]() {
|
||||
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
|
||||
return;
|
||||
if (prompt_for_targets(category, plugin_name, event_name, shown))
|
||||
persist_grant(plugin_key, category, by_event, keys);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Decides an event that no allowed root or running operation covers: a saved grant allows it,
|
||||
// otherwise the user is asked and a Yes is saved.
|
||||
int decide_audited_event(PluginAuditManager& mgr,
|
||||
PluginInstallState& state,
|
||||
const std::string& plugin_key,
|
||||
const std::string& plugin_name,
|
||||
const std::string& event_name,
|
||||
AuditEventCategory category,
|
||||
const std::vector<std::string>& targets)
|
||||
{
|
||||
const bool by_event = targets.empty();
|
||||
const std::vector<std::string> keys = by_event ? std::vector<std::string>{event_grant_key(category, event_name)} : targets;
|
||||
std::vector<std::string>* permission_list = permission_list_for(category, by_event, state.permissions);
|
||||
|
||||
std::vector<std::string> unresolved = keys;
|
||||
if (permission_list) {
|
||||
unresolved.clear();
|
||||
for (const auto& key : keys)
|
||||
if (!has_permission(*permission_list, key))
|
||||
unresolved.push_back(key);
|
||||
if (unresolved.empty())
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Letting a root frame go runs finalizers inside a scope destructor or another event's hook,
|
||||
// where no dialog may open: what they do must already be granted.
|
||||
if (mgr.releasing_frames())
|
||||
return plugin_audit::report_denied(mgr, event_name, {false, "audit permission required"});
|
||||
|
||||
if (!request_permission(category, plugin_key, plugin_name, event_name, by_event, unresolved))
|
||||
return plugin_audit::report_denied(mgr, event_name, {false, "audit permission required"});
|
||||
|
||||
if (permission_list)
|
||||
for (const auto& key : unresolved)
|
||||
persist_permission(plugin_key, state, *permission_list, key);
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool plugin_audit::request_filesystem_read_permissions(const std::string& plugin_key,
|
||||
const std::vector<std::string>& paths)
|
||||
{
|
||||
PluginDescriptor descriptor;
|
||||
if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty())
|
||||
return false;
|
||||
|
||||
PluginInstallState state;
|
||||
read_install_state(boost::filesystem::path(descriptor.plugin_root), state);
|
||||
|
||||
std::vector<std::string> missing;
|
||||
for (const std::string& path : paths) {
|
||||
if (std::find(state.permissions.fs_read.begin(), state.permissions.fs_read.end(), path) == state.permissions.fs_read.end())
|
||||
missing.push_back(path);
|
||||
}
|
||||
if (missing.empty())
|
||||
return true;
|
||||
|
||||
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
|
||||
return false;
|
||||
|
||||
auto show_dialog = [&descriptor, &missing]() {
|
||||
wxString requested_paths;
|
||||
for (const std::string& path : missing)
|
||||
requested_paths += wxString::FromUTF8(path.c_str()) + "\n";
|
||||
|
||||
wxMessageDialog dialog(
|
||||
nullptr,
|
||||
wxString::Format(_L("Plugin \"%s\" requests filesystem read access to:\n%s"),
|
||||
wxString::FromUTF8(descriptor.name.c_str()), requested_paths),
|
||||
_L("Plugin permissions"),
|
||||
wxYES_NO | wxICON_WARNING);
|
||||
return dialog.ShowModal() == wxID_YES;
|
||||
};
|
||||
|
||||
bool granted = false;
|
||||
if (wxIsMainThread()) {
|
||||
granted = show_dialog();
|
||||
} else {
|
||||
auto result = std::make_shared<std::promise<bool>>();
|
||||
auto future = result->get_future();
|
||||
GUI::wxGetApp().CallAfter([result, descriptor_name = descriptor.name, missing]() {
|
||||
wxString requested_paths;
|
||||
for (const std::string& path : missing)
|
||||
requested_paths += wxString::FromUTF8(path.c_str()) + "\n";
|
||||
|
||||
wxMessageDialog dialog(
|
||||
nullptr,
|
||||
wxString::Format(_L("Plugin \"%s\" requests filesystem read access to:\n%s"),
|
||||
wxString::FromUTF8(descriptor_name.c_str()), requested_paths),
|
||||
_L("Plugin permissions"),
|
||||
wxYES_NO | wxICON_WARNING);
|
||||
result->set_value(dialog.ShowModal() == wxID_YES);
|
||||
});
|
||||
granted = future.get();
|
||||
}
|
||||
|
||||
if (!granted)
|
||||
return false;
|
||||
|
||||
if (state.plugin_name.empty()) {
|
||||
state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local";
|
||||
state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version;
|
||||
state.plugin_name = descriptor.name;
|
||||
state.cloud_uuid = descriptor.cloud_uuid();
|
||||
state.enabled = true;
|
||||
}
|
||||
|
||||
state.permissions.fs_read.insert(state.permissions.fs_read.end(), missing.begin(), missing.end());
|
||||
return write_install_state(boost::filesystem::path(descriptor.plugin_root), state);
|
||||
}
|
||||
|
||||
int plugin_audit::decide_event(PluginAuditManager& mgr,
|
||||
const std::string& event_name,
|
||||
AuditEventCategory category,
|
||||
const std::vector<std::string>& targets)
|
||||
{
|
||||
PluginInstallState state;
|
||||
const bool have_install_state = PluginManager::instance().get_install_state(mgr.current_plugin(), state);
|
||||
if (!have_install_state) {
|
||||
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " Failed to get install state for " << mgr.current_plugin();
|
||||
}
|
||||
|
||||
const std::string plugin_name = state.plugin_name.empty() ? mgr.current_plugin() : state.plugin_name;
|
||||
|
||||
return decide_audited_event(mgr, state, mgr.current_plugin(), plugin_name, event_name, category, targets);
|
||||
}
|
||||
|
||||
void PluginAuditManager::set_permission_prompt_for_testing(PermissionPrompt prompt)
|
||||
{
|
||||
s_permission_prompt_for_testing = std::move(prompt);
|
||||
}
|
||||
|
||||
} // namespace Slic3r
|
||||
@@ -1,38 +0,0 @@
|
||||
#ifndef slic3r_PluginAuditManagerInternal_hpp_
|
||||
#define slic3r_PluginAuditManagerInternal_hpp_
|
||||
|
||||
// Internal seam between the GUI-free audit core and the permission front end. The GUI library
|
||||
// supplies the dialogs, prompts and .install_state.json persistence; the headless probe supplies
|
||||
// a fail-closed implementation that never links wx. Production behavior lives in the GUI
|
||||
// implementation, which is the code that used to be part of PluginAuditManager.cpp.
|
||||
|
||||
#include "PluginAuditManager.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace Slic3r::plugin_audit {
|
||||
|
||||
const char* category_name(AuditEventCategory category);
|
||||
|
||||
int report_denied(PluginAuditManager& mgr,
|
||||
const std::string& event_name,
|
||||
const AuditDecision& decision,
|
||||
const std::string& target = {});
|
||||
|
||||
// Used only when the manager is not in observation mode. The GUI implementation consults the
|
||||
// install state, prompts and persists; the probe implementation fails closed.
|
||||
bool request_filesystem_read_permissions(const std::string& plugin_key,
|
||||
const std::vector<std::string>& paths);
|
||||
|
||||
// Decides an event that no allowed root or running operation covered. The GUI implementation
|
||||
// reads the install state and prompts; the probe implementation fails closed. In observation
|
||||
// mode the audit hook records and returns before reaching this seam.
|
||||
int decide_event(PluginAuditManager& mgr,
|
||||
const std::string& event_name,
|
||||
AuditEventCategory category,
|
||||
const std::vector<std::string>& targets);
|
||||
|
||||
} // namespace Slic3r::plugin_audit
|
||||
|
||||
#endif // slic3r_PluginAuditManagerInternal_hpp_
|
||||
@@ -14,7 +14,8 @@
|
||||
|
||||
#include "PluginAuditManager.hpp"
|
||||
#include "PythonInterpreter.hpp"
|
||||
#include "PluginText.hpp"
|
||||
#include "slic3r/GUI/I18N.hpp"
|
||||
#include "slic3r/GUI/format.hpp"
|
||||
|
||||
#include <exception>
|
||||
#include <miniz.h>
|
||||
@@ -91,7 +92,7 @@ bool resolve_allowed_plugin_root(const PluginDescriptor& descriptor,
|
||||
|
||||
const fs::path plugin_root = resolve_plugin_root_from_descriptor(descriptor);
|
||||
if (plugin_root.empty()) {
|
||||
error = PLUGIN_U8L("Plugin folder could not be determined.");
|
||||
error = _u8L("Plugin folder could not be determined.");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -102,7 +103,7 @@ bool resolve_allowed_plugin_root(const PluginDescriptor& descriptor,
|
||||
resolved_root = fs::absolute(plugin_root, ec);
|
||||
}
|
||||
if (ec || resolved_root.empty()) {
|
||||
error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to resolve plugin folder: %1%"), plugin_root.string());
|
||||
error = GUI::format(_u8L("Failed to resolve plugin folder: %1%"), plugin_root.string());
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -122,7 +123,7 @@ bool delete_plugin_root(const boost::filesystem::path& resolved_root, const std:
|
||||
const auto removed_count = fs::remove_all(resolved_root, ec);
|
||||
if (ec) {
|
||||
// TRN %1% is a folder path, %2% the system error message.
|
||||
error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to delete plugin folder %1%: %2%"), resolved_root.string(), ec.message());
|
||||
error = GUI::format(_u8L("Failed to delete plugin folder %1%: %2%"), resolved_root.string(), ec.message());
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1198,7 +1199,7 @@ boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::pat
|
||||
namespace fs = boost::filesystem;
|
||||
|
||||
if (!fs::exists(plugin_dir) || !fs::is_directory(plugin_dir)) {
|
||||
error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin directory does not exist: %1%"), plugin_dir.string());
|
||||
error = GUI::format(_u8L("Plugin directory does not exist: %1%"), plugin_dir.string());
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -1215,7 +1216,7 @@ boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::pat
|
||||
if (ext == ".py") {
|
||||
if (!py_entry.empty()) {
|
||||
// TRN %1% and %2% are file names.
|
||||
error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin directory contains multiple .py files: %1% and %2%"),
|
||||
error = GUI::format(_u8L("Plugin directory contains multiple .py files: %1% and %2%"),
|
||||
py_entry.filename().string(), it->path().filename().string());
|
||||
return {};
|
||||
}
|
||||
@@ -1223,7 +1224,7 @@ boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::pat
|
||||
} else if (ext == ".whl") {
|
||||
if (!whl_entry.empty()) {
|
||||
// TRN %1% and %2% are file names.
|
||||
error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin directory contains multiple .whl files: %1% and %2%"),
|
||||
error = GUI::format(_u8L("Plugin directory contains multiple .whl files: %1% and %2%"),
|
||||
whl_entry.filename().string(), it->path().filename().string());
|
||||
return {};
|
||||
}
|
||||
@@ -1232,7 +1233,7 @@ boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::pat
|
||||
}
|
||||
|
||||
if (!py_entry.empty() && !whl_entry.empty()) {
|
||||
error = PLUGIN_U8L("Plugin directory contains both .py and .whl entry files");
|
||||
error = _u8L("Plugin directory contains both .py and .whl entry files");
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -1242,7 +1243,7 @@ boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::pat
|
||||
if (!whl_entry.empty())
|
||||
return whl_entry;
|
||||
|
||||
error = PLUGIN_U8L("Plugin directory does not contain a .py or .whl entry file");
|
||||
error = _u8L("Plugin directory does not contain a .py or .whl entry file");
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -1259,34 +1260,34 @@ std::vector<std::string> collect_resource_texture_files(const std::string& dir,
|
||||
error.clear();
|
||||
|
||||
if (dir.empty()) {
|
||||
error = PLUGIN_U8L("Resource texture directory is not set");
|
||||
error = _u8L("Resource texture directory is not set");
|
||||
return files;
|
||||
}
|
||||
if (plugin_root.empty()) {
|
||||
error = PLUGIN_U8L("Plugin folder could not be determined.");
|
||||
error = _u8L("Plugin folder could not be determined.");
|
||||
return files;
|
||||
}
|
||||
|
||||
boost::system::error_code ec;
|
||||
const fs::path resolved_dir = fs::weakly_canonical(fs::path(dir), ec);
|
||||
if (ec) {
|
||||
error = PLUGIN_U8L("Resource texture directory could not be resolved");
|
||||
error = _u8L("Resource texture directory could not be resolved");
|
||||
return files;
|
||||
}
|
||||
|
||||
if (!fs::is_directory(resolved_dir, ec) || ec) {
|
||||
error = PLUGIN_U8L("Resource texture path is not a directory");
|
||||
error = _u8L("Resource texture path is not a directory");
|
||||
return files;
|
||||
}
|
||||
|
||||
const fs::path resolved_root = fs::weakly_canonical(fs::path(plugin_root), ec);
|
||||
if (ec) {
|
||||
error = PLUGIN_U8L("Plugin folder could not be resolved");
|
||||
error = _u8L("Plugin folder could not be resolved");
|
||||
return files;
|
||||
}
|
||||
|
||||
if (!is_inside_allowed_root(resolved_dir, resolved_root)) {
|
||||
error = PLUGIN_U8L("Resource texture directory is outside the plugin folder");
|
||||
error = _u8L("Resource texture directory is outside the plugin folder");
|
||||
return files;
|
||||
}
|
||||
|
||||
@@ -1311,7 +1312,7 @@ std::vector<std::string> collect_resource_texture_files(const std::string& dir,
|
||||
}
|
||||
|
||||
if (ec) {
|
||||
error = PLUGIN_U8L("Resource texture directory could not be read");
|
||||
error = _u8L("Resource texture directory could not be read");
|
||||
return {};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
#include "PythonPluginBridge.hpp"
|
||||
#include "pluginTypes/resourceTexture/ResourceTexturePluginCapability.hpp"
|
||||
#include "libslic3r/Utils.hpp"
|
||||
#include "PluginText.hpp"
|
||||
#include "slic3r/GUI/I18N.hpp"
|
||||
#include "slic3r/GUI/format.hpp"
|
||||
|
||||
#include <boost/algorithm/string.hpp>
|
||||
#include <boost/algorithm/string/case_conv.hpp>
|
||||
@@ -101,7 +102,7 @@ bool read_local_plugin_package_metadata(const boost::filesystem::path& source_pa
|
||||
|
||||
const std::string ext = plugin_package_extension(source_path);
|
||||
if (ext != ".py" && ext != ".whl") {
|
||||
error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin package must be a .py or .whl file, got: %1%"), ext);
|
||||
error = GUI::format(_u8L("Plugin package must be a .py or .whl file, got: %1%"), ext);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -178,14 +179,14 @@ bool add_wheel_dependencies_to_sys_path(const PluginDescriptor& descriptor,
|
||||
std::string extract_error;
|
||||
if (!extract_zip_to_directory(it->path(), dep_dir, extract_error)) {
|
||||
// TRN %1% is the dependency file, %2% the error detail.
|
||||
error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to extract plugin .whl dependency %1%: %2%"), it->path().string(), extract_error);
|
||||
error = GUI::format(_u8L("Failed to extract plugin .whl dependency %1%: %2%"), it->path().string(), extract_error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
std::string syspath_error;
|
||||
if (!interpreter.add_plugin_sys_path(dep_dir.string(), syspath_error)) {
|
||||
error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to add .whl dependency to sys.path: %1%"), syspath_error);
|
||||
error = GUI::format(_u8L("Failed to add .whl dependency to sys.path: %1%"), syspath_error);
|
||||
return false;
|
||||
}
|
||||
plugin_paths.push_back(dep_dir.string());
|
||||
@@ -247,12 +248,12 @@ bool load(const PluginDescriptor& descriptor,
|
||||
|
||||
PythonInterpreter& interpreter = PythonInterpreter::instance();
|
||||
if (!interpreter.is_initialized()) {
|
||||
error = PLUGIN_FORMAT(PLUGIN_U8L("Python interpreter not initialized: %1%"), interpreter.last_error());
|
||||
error = GUI::format(_u8L("Python interpreter not initialized: %1%"), interpreter.last_error());
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!descriptor.is_metadata_valid()) {
|
||||
error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin manifest is invalid: %1%"), descriptor.plugin_key);
|
||||
error = GUI::format(_u8L("Plugin manifest is invalid: %1%"), descriptor.plugin_key);
|
||||
if (descriptor.has_error())
|
||||
error += " - " + descriptor.normalized_error();
|
||||
return false;
|
||||
@@ -273,7 +274,7 @@ bool load(const PluginDescriptor& descriptor,
|
||||
if (!skip_deps) {
|
||||
std::string pkg_install_error;
|
||||
if (!install_packages(descriptor.dependencies, pkg_install_error)) {
|
||||
error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to install plugin dependencies: %1%"), pkg_install_error);
|
||||
error = GUI::format(_u8L("Failed to install plugin dependencies: %1%"), pkg_install_error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -292,7 +293,7 @@ bool load(const PluginDescriptor& descriptor,
|
||||
PyObject* module = import_plugin_module(descriptor, plugin.plugin_sys_paths, plugin.plugin_modules, load_error);
|
||||
if (module == nullptr) {
|
||||
bridge.cancel_plugin_capture(descriptor.entry_path);
|
||||
error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to load plugin module: %1%"), load_error);
|
||||
error = GUI::format(_u8L("Failed to load plugin module: %1%"), load_error);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -307,13 +308,13 @@ bool load(const PluginDescriptor& descriptor,
|
||||
auto capabilities_found = bridge.finalize_plugin_capture(descriptor.entry_path, descriptor.plugin_key, bridge_error);
|
||||
if (!bridge_error.empty()) {
|
||||
capabilities_found.clear();
|
||||
error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin registration failed: %1%"), bridge_error);
|
||||
error = GUI::format(_u8L("Plugin registration failed: %1%"), bridge_error);
|
||||
if (registration_failed != nullptr)
|
||||
*registration_failed = true;
|
||||
return false;
|
||||
}
|
||||
if (capabilities_found.empty()) {
|
||||
error = PLUGIN_U8L("Plugin module did not register any capabilities");
|
||||
error = _u8L("Plugin module did not register any capabilities");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -365,7 +366,7 @@ bool load(const PluginDescriptor& descriptor,
|
||||
|
||||
if (!seen_capabilities[type].insert(found.name).second) {
|
||||
// TRN %1% is the capability name, %2% the capability type (e.g. Script).
|
||||
materialization_error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin declares duplicate capability '%1%' for type %2%"),
|
||||
materialization_error = GUI::format(_u8L("Plugin declares duplicate capability '%1%' for type %2%"),
|
||||
found.name, PluginConfig::capability_type_label(type));
|
||||
break;
|
||||
}
|
||||
@@ -421,9 +422,9 @@ bool load(const PluginDescriptor& descriptor,
|
||||
capabilities.push_back(std::move(instance));
|
||||
}
|
||||
} catch (const std::exception& ex) {
|
||||
materialization_error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin capability materialization failed: %1%"), ex.what());
|
||||
materialization_error = GUI::format(_u8L("Plugin capability materialization failed: %1%"), ex.what());
|
||||
} catch (...) {
|
||||
materialization_error = PLUGIN_U8L("Plugin capability materialization failed");
|
||||
materialization_error = _u8L("Plugin capability materialization failed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,11 +466,11 @@ bool load(const PluginDescriptor& descriptor,
|
||||
} catch (const std::exception& ex) {
|
||||
teardown_capabilities(plugin.capabilities, lifecycle_count);
|
||||
// TRN on_load is the name of the plugin's startup function; keep it untranslated.
|
||||
error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin on_load failed: %1%"), ex.what());
|
||||
error = GUI::format(_u8L("Plugin on_load failed: %1%"), ex.what());
|
||||
return false;
|
||||
} catch (...) {
|
||||
teardown_capabilities(plugin.capabilities, lifecycle_count);
|
||||
error = PLUGIN_U8L("Plugin on_load failed");
|
||||
error = _u8L("Plugin on_load failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -650,7 +651,7 @@ bool install_plugin(const boost::filesystem::path& filepath,
|
||||
bool backup_created = false;
|
||||
|
||||
auto fail = [&error](const std::string& message) {
|
||||
error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin installation failed: %1%"), message);
|
||||
error = GUI::format(_u8L("Plugin installation failed: %1%"), message);
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -658,23 +659,23 @@ bool install_plugin(const boost::filesystem::path& filepath,
|
||||
const fs::path source_path = canonical_or_absolute(filepath);
|
||||
|
||||
if (!fs::exists(source_path, ec) || !fs::is_regular_file(source_path, ec))
|
||||
return fail(PLUGIN_FORMAT(PLUGIN_U8L("Plugin package is not a file: %1%"), filepath.string()));
|
||||
return fail(GUI::format(_u8L("Plugin package is not a file: %1%"), filepath.string()));
|
||||
|
||||
const std::string ext = plugin_package_extension(source_path);
|
||||
if (ext != ".py" && ext != ".whl")
|
||||
return fail(PLUGIN_FORMAT(PLUGIN_U8L("Plugin package must be a .py or .whl file, got: %1%"), ext));
|
||||
return fail(GUI::format(_u8L("Plugin package must be a .py or .whl file, got: %1%"), ext));
|
||||
// The cloud UUID is concatenated into the install path (final_dir = plugin_root / cloud_uuid).
|
||||
// It comes verbatim from the server's catalog JSON, so reject anything that could escape the
|
||||
// per-user plugin root (traversal, leading dots, separators) before touching the filesystem.
|
||||
if (is_cloud_install && !is_valid_plugin_id(cloud_uuid))
|
||||
return fail(PLUGIN_FORMAT(PLUGIN_U8L("Cloud plugin UUID is not a valid identifier: %1%"), cloud_uuid));
|
||||
return fail(GUI::format(_u8L("Cloud plugin UUID is not a valid identifier: %1%"), cloud_uuid));
|
||||
|
||||
const fs::path plugin_root = is_cloud_install && !cloud_user_id.empty() ? fs::path(get_cloud_plugin_dir(cloud_user_id)) :
|
||||
local_plugin_root();
|
||||
fs::create_directories(plugin_root, ec);
|
||||
if (ec)
|
||||
// TRN %1% is a folder path, %2% the system error message.
|
||||
return fail(PLUGIN_FORMAT(PLUGIN_U8L("Failed to create plugin directory %1%: %2%"), plugin_root.string(), ec.message()));
|
||||
return fail(GUI::format(_u8L("Failed to create plugin directory %1%: %2%"), plugin_root.string(), ec.message()));
|
||||
|
||||
std::string meta_error;
|
||||
if (is_cloud_install) {
|
||||
@@ -717,7 +718,7 @@ bool install_plugin(const boost::filesystem::path& filepath,
|
||||
fs::rename(final_dir, backup_dir, ec);
|
||||
if (ec)
|
||||
// TRN %1% is a folder path, %2% the system error message.
|
||||
return fail(PLUGIN_FORMAT(PLUGIN_U8L("Failed to back up existing plugin %1%: %2%"), final_dir.string(), ec.message()));
|
||||
return fail(GUI::format(_u8L("Failed to back up existing plugin %1%: %2%"), final_dir.string(), ec.message()));
|
||||
backup_created = true;
|
||||
}
|
||||
|
||||
@@ -728,7 +729,7 @@ bool install_plugin(const boost::filesystem::path& filepath,
|
||||
boost::system::error_code restore_ec;
|
||||
fs::rename(backup_dir, final_dir, restore_ec);
|
||||
}
|
||||
return fail(PLUGIN_FORMAT(PLUGIN_U8L("Failed to create plugin directory %1%: %2%"), final_dir.string(), ec.message()));
|
||||
return fail(GUI::format(_u8L("Failed to create plugin directory %1%: %2%"), final_dir.string(), ec.message()));
|
||||
}
|
||||
|
||||
fs::copy_file(source_path, dest_file, ec);
|
||||
@@ -739,7 +740,7 @@ bool install_plugin(const boost::filesystem::path& filepath,
|
||||
fs::rename(backup_dir, final_dir, restore_ec);
|
||||
}
|
||||
// TRN %1% is a file path, %2% the system error message.
|
||||
return fail(PLUGIN_FORMAT(PLUGIN_U8L("Failed to copy plugin file to %1%: %2%"), dest_file.string(), ec.message()));
|
||||
return fail(GUI::format(_u8L("Failed to copy plugin file to %1%: %2%"), dest_file.string(), ec.message()));
|
||||
}
|
||||
|
||||
// Update entry_path to the installed location.
|
||||
@@ -759,7 +760,7 @@ bool install_plugin(const boost::filesystem::path& filepath,
|
||||
fs::remove_all(final_dir, rollback_ec);
|
||||
if (backup_created)
|
||||
fs::rename(backup_dir, final_dir, rollback_ec);
|
||||
return fail(PLUGIN_FORMAT(PLUGIN_U8L("Failed to write plugin install state: %1%"), (final_dir / INSTALL_STATE_FILE).string()));
|
||||
return fail(GUI::format(_u8L("Failed to write plugin install state: %1%"), (final_dir / INSTALL_STATE_FILE).string()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,18 @@ const char* const LOAD_CANCELLED = "__plugin_load_cancelled__";
|
||||
|
||||
} // namespace
|
||||
|
||||
void Plugin::release_module()
|
||||
{
|
||||
if (module == nullptr && plugin_sys_paths.empty() && plugin_modules.empty())
|
||||
return;
|
||||
|
||||
PythonInterpreter::instance().unload_module(module, module_name, plugin_sys_paths, plugin_modules);
|
||||
module = nullptr;
|
||||
module_name.clear();
|
||||
plugin_sys_paths.clear();
|
||||
plugin_modules.clear();
|
||||
}
|
||||
|
||||
PluginManager& PluginManager::instance()
|
||||
{
|
||||
// Touch the interpreter first so its static outlives this one: ~Plugin needs it alive to
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
// Plugin module ownership is part of the loader's contract, not of the registry's GUI-facing
|
||||
// surface. Kept in its own translation unit so the headless probe can link the loader without
|
||||
// pulling PluginManager.cpp (and therefore wx/GUI) into the probe.
|
||||
|
||||
#include "PluginManager.hpp"
|
||||
#include "PythonInterpreter.hpp"
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
void Plugin::release_module()
|
||||
{
|
||||
if (module == nullptr && plugin_sys_paths.empty() && plugin_modules.empty())
|
||||
return;
|
||||
|
||||
PythonInterpreter::instance().unload_module(module, module_name, plugin_sys_paths, plugin_modules);
|
||||
module = nullptr;
|
||||
module_name.clear();
|
||||
plugin_sys_paths.clear();
|
||||
plugin_modules.clear();
|
||||
}
|
||||
|
||||
} // namespace Slic3r
|
||||
@@ -1,44 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
// User-facing plugin messages are translated in the GUI build. The headless probe links neither
|
||||
// libslic3r_gui nor wx, so it formats the same English text without wxGetTranslation. The GUI path
|
||||
// expands to exactly the calls the plugin sources used before this header existed.
|
||||
|
||||
#if defined(ORCA_PLUGIN_PROBE_BUILD)
|
||||
|
||||
#include "libslic3r/format.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
namespace Slic3r::plugin_text {
|
||||
|
||||
inline std::string translate_utf8(const char* text) { return text == nullptr ? std::string{} : std::string(text); }
|
||||
inline std::string translate_utf8(const std::string& text) { return text; }
|
||||
|
||||
template<typename... TArgs>
|
||||
inline std::string format(const char* fmt, TArgs&&... args)
|
||||
{
|
||||
return Slic3r::format(fmt, std::forward<TArgs>(args)...);
|
||||
}
|
||||
|
||||
template<typename... TArgs>
|
||||
inline std::string format(const std::string& fmt, TArgs&&... args)
|
||||
{
|
||||
return Slic3r::format(fmt, std::forward<TArgs>(args)...);
|
||||
}
|
||||
|
||||
} // namespace Slic3r::plugin_text
|
||||
|
||||
#define PLUGIN_U8L(text) ::Slic3r::plugin_text::translate_utf8(text)
|
||||
#define PLUGIN_FORMAT(...) ::Slic3r::plugin_text::format(__VA_ARGS__)
|
||||
|
||||
#else
|
||||
|
||||
#include "slic3r/GUI/I18N.hpp"
|
||||
#include "slic3r/GUI/format.hpp"
|
||||
|
||||
#define PLUGIN_U8L(text) _u8L(text)
|
||||
#define PLUGIN_FORMAT(...) GUI::format(__VA_ARGS__)
|
||||
|
||||
#endif
|
||||
@@ -30,7 +30,7 @@
|
||||
#include "pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp"
|
||||
#include <pybind11/pytypes.h>
|
||||
#include <pybind11/gil.h>
|
||||
#include "PluginText.hpp"
|
||||
#include "slic3r/GUI/I18N.hpp"
|
||||
|
||||
namespace py = pybind11;
|
||||
|
||||
@@ -193,7 +193,7 @@ std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(cons
|
||||
granted = PluginAuditManager::instance().request_filesystem_read_permissions(plugin_key, fs_read_permissions);
|
||||
}
|
||||
if (!granted) {
|
||||
error = PLUGIN_U8L("Plugin filesystem read permission request denied");
|
||||
error = _u8L("Plugin filesystem read permission request denied");
|
||||
BOOST_LOG_TRIVIAL(warning) << error << " for key " << plugin_key;
|
||||
discard_pending_for_key();
|
||||
clear_active_key();
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
// libslic3r references NanoSVG without carrying its header-only implementation. This probe links
|
||||
// libslic3r without the GUI BitmapCache.cpp that supplies the implementation in the application.
|
||||
#define NANOSVG_IMPLEMENTATION
|
||||
#include "nanosvg/nanosvg.h"
|
||||
@@ -1,32 +0,0 @@
|
||||
// Headless replacement for the GUI permission front end. The probe installs an observation
|
||||
// observer, so the audit hook records and returns before reaching this seam. If that observer is
|
||||
// ever absent, permission decisions fail closed instead of consulting GUI or install state.
|
||||
|
||||
#include "slic3r/plugin/PluginAuditManagerInternal.hpp"
|
||||
|
||||
#include <boost/log/trivial.hpp>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace Slic3r::plugin_audit {
|
||||
|
||||
bool request_filesystem_read_permissions(const std::string& plugin_key,
|
||||
const std::vector<std::string>& paths)
|
||||
{
|
||||
BOOST_LOG_TRIVIAL(warning) << "[PROBE] Refusing filesystem read permission request for " << plugin_key
|
||||
<< " (" << paths.size() << " path(s)): the headless probe does not evaluate grants";
|
||||
return false;
|
||||
}
|
||||
|
||||
int decide_event(PluginAuditManager& mgr,
|
||||
const std::string& event_name,
|
||||
AuditEventCategory category,
|
||||
const std::vector<std::string>& targets)
|
||||
{
|
||||
(void) category;
|
||||
(void) targets;
|
||||
return report_denied(mgr, event_name, {false, "permission decisions are unavailable in the headless probe"});
|
||||
}
|
||||
|
||||
} // namespace Slic3r::plugin_audit
|
||||
@@ -1,75 +0,0 @@
|
||||
// Probe-only `orca.host` registration. The GUI-free domains use the production bindings; the
|
||||
// domains that need a live GUI application, main window or preset bundle are exposed as explicit
|
||||
// unsupported submodules instead of being compiled (and linked) into the probe.
|
||||
//
|
||||
// A plugin that touches one of those domains fails its own callback with a clear message; the
|
||||
// failure is recorded by the probe's callback report rather than becoming a link dependency.
|
||||
|
||||
#include "slic3r/plugin/host/PluginHost.hpp"
|
||||
#include "slic3r/plugin/host/PluginHostBindings.hpp"
|
||||
|
||||
#include <pybind11/pybind11.h>
|
||||
#include <pybind11/pytypes.h>
|
||||
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
namespace py = pybind11;
|
||||
|
||||
namespace Slic3r {
|
||||
namespace {
|
||||
|
||||
void add_unavailable_domain(py::module_& host, const char* name, const char* description)
|
||||
{
|
||||
py::module_ domain = host.def_submodule(name, description);
|
||||
const std::string prefix = std::string("orca.host.") + name + ".";
|
||||
domain.attr("__getattr__") = py::cpp_function([prefix](const std::string& attribute) -> py::object {
|
||||
throw std::runtime_error(prefix + attribute +
|
||||
" is not available in the headless plugin permission probe because it needs the GUI application");
|
||||
});
|
||||
}
|
||||
|
||||
void add_unavailable_function(py::module_& host, const char* name)
|
||||
{
|
||||
// Production registers these in PluginHostApp::register_app(). Do not overwrite a slot that
|
||||
// another registrar already claimed (the model domain binds the Model class through
|
||||
// register_model); the probe only fills the GUI-backed accessors that are absent here.
|
||||
if (py::hasattr(host, name))
|
||||
return;
|
||||
|
||||
const std::string detail = std::string("orca.host.") + name +
|
||||
" is not available in the headless plugin permission probe because it needs the GUI application";
|
||||
host.def(name, [detail](py::args, py::kwargs) -> py::object { throw std::runtime_error(detail); }, detail.c_str());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void PluginHost::RegisterBindings(py::module_& module)
|
||||
{
|
||||
auto host = module.def_submodule("host", "Host application API");
|
||||
|
||||
// Value types first so the docstring signatures of later registrars resolve to the bound names.
|
||||
host_bindings::register_geometry(host);
|
||||
host_bindings::register_mesh(host);
|
||||
host_bindings::register_model(host);
|
||||
host_bindings::register_slicing(host);
|
||||
|
||||
// These domains are backed by the live GUI, its Plater/MainFrame and the preset bundle.
|
||||
add_unavailable_domain(host, "presets", "Preset bundle access (headless probe: unavailable)");
|
||||
add_unavailable_domain(host, "app", "Live application and Plater access (headless probe: unavailable)");
|
||||
add_unavailable_domain(host, "ui", "Native dialogs and HTML windows (headless probe: unavailable)");
|
||||
for (const char* name : {"plater", "model", "preset_bundle", "app_info", "gl_info", "selected_printer"})
|
||||
add_unavailable_function(host, name);
|
||||
|
||||
// Storage needs the PluginManager registry, which is not part of the headless probe.
|
||||
auto plugin_host = host.def_submodule("plugin", "Plugin host API (headless probe: storage unavailable)");
|
||||
plugin_host.def("storage", []() -> std::string {
|
||||
throw std::runtime_error("orca.host.plugin.storage() is not available in the headless plugin permission probe");
|
||||
}, "Return the installed folder of the current plugin.");
|
||||
plugin_host.attr("__getattr__") = py::cpp_function([](const std::string& attribute) -> py::object {
|
||||
throw std::runtime_error("orca.host.plugin." + attribute +
|
||||
" is not available in the headless plugin permission probe");
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace Slic3r
|
||||
@@ -1,800 +0,0 @@
|
||||
// Headless, record-only plugin permission probe.
|
||||
//
|
||||
// This is a developer/security-analysis tool, not a second production loader. It reuses the
|
||||
// embedded interpreter, the production plugin_loader sequence and the GUI-free `orca` bindings
|
||||
// (capability types plus the geometry/model/slicing host domains), so the observations come from
|
||||
// the same audit classification and target extraction as the GUI. It then lets every audited
|
||||
// operation continue instead of consulting permission grants or dialogs. Host domains that need
|
||||
// the live GUI (app, presets, ui) and plugin.storage are registered as explicit unsupported
|
||||
// errors; the config API reports an empty config and refuses writes.
|
||||
//
|
||||
// It is NOT a sandbox. Plugin code runs with the probe's filesystem, network and process
|
||||
// privileges. The temporary data directory only keeps OrcaSlicer's own state out of the user's
|
||||
// tree; it does not confine plugin-authored code. Use OS-level restrictions (container, user,
|
||||
// seccomp/AppArmor, firewall) when the package is not trusted.
|
||||
//
|
||||
// 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 "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"
|
||||
|
||||
#include <boost/algorithm/string/case_conv.hpp>
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <boost/filesystem/operations.hpp>
|
||||
#include <boost/filesystem/path.hpp>
|
||||
#include <boost/system/error_code.hpp>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <pybind11/embed.h>
|
||||
#include <pybind11/gil.h>
|
||||
#include <pybind11/pytypes.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdlib>
|
||||
#include <exception>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace fs = boost::filesystem;
|
||||
namespace py = pybind11;
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char* kLoadPhase = "load:import+registration+on_load";
|
||||
|
||||
struct Options
|
||||
{
|
||||
fs::path plugin_path;
|
||||
bool json = false;
|
||||
};
|
||||
|
||||
void print_usage(std::ostream& out)
|
||||
{
|
||||
out << "Usage: orca-plugin-probe [--json] <plugin.py|plugin.whl>\n"
|
||||
"\n"
|
||||
"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. 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"
|
||||
"\n"
|
||||
"WARNING: this is not a sandbox. Plugin Python code runs with this process's privileges.\n"
|
||||
"Results are observational and non-exhaustive. Use OS-level restrictions for untrusted\n"
|
||||
"packages.\n"
|
||||
"\n"
|
||||
"Exit status: 0 plugin loaded, 1 initialization/load failure, 2 usage error.\n";
|
||||
}
|
||||
|
||||
bool parse_options(int argc, char** argv, Options& options, std::string& error)
|
||||
{
|
||||
for (int index = 1; index < argc; ++index) {
|
||||
const std::string argument = argv[index];
|
||||
if (argument == "--help" || argument == "-h") {
|
||||
print_usage(std::cout);
|
||||
std::exit(0);
|
||||
}
|
||||
if (argument == "--json") {
|
||||
options.json = true;
|
||||
continue;
|
||||
}
|
||||
if (!argument.empty() && argument.front() == '-') {
|
||||
error = "Unknown option: " + argument;
|
||||
return false;
|
||||
}
|
||||
if (!options.plugin_path.empty()) {
|
||||
error = "Only one plugin package may be probed at a time.";
|
||||
return false;
|
||||
}
|
||||
options.plugin_path = fs::path(argument);
|
||||
}
|
||||
|
||||
if (options.plugin_path.empty()) {
|
||||
error = "No plugin package given.";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Keep every write OrcaSlicer makes during the run inside a throwaway tree, and keep relative
|
||||
// paths opened by plugin code out of the caller's working directory. This is hygiene, not
|
||||
// confinement: an absolute path supplied by the plugin is still opened normally.
|
||||
class ScopedProbeWorkspace
|
||||
{
|
||||
public:
|
||||
ScopedProbeWorkspace()
|
||||
: m_root(fs::temp_directory_path() / fs::unique_path("orca-plugin-probe-%%%%-%%%%-%%%%"))
|
||||
, m_previous_data_dir(data_dir())
|
||||
, m_previous_working_dir(fs::current_path())
|
||||
{
|
||||
fs::create_directories(m_root);
|
||||
set_data_dir(m_root.string());
|
||||
fs::current_path(m_root);
|
||||
m_moved = true;
|
||||
}
|
||||
|
||||
~ScopedProbeWorkspace()
|
||||
{
|
||||
boost::system::error_code ec;
|
||||
set_data_dir(m_previous_data_dir);
|
||||
if (m_moved)
|
||||
fs::current_path(m_previous_working_dir, ec);
|
||||
fs::remove_all(m_root, ec);
|
||||
}
|
||||
|
||||
const fs::path& root() const { return m_root; }
|
||||
|
||||
private:
|
||||
fs::path m_root;
|
||||
std::string m_previous_data_dir;
|
||||
fs::path m_previous_working_dir;
|
||||
bool m_moved = false;
|
||||
};
|
||||
|
||||
class ScopedObservationObserver
|
||||
{
|
||||
public:
|
||||
explicit ScopedObservationObserver(PluginProbeCollector& collector) : m_collector(collector)
|
||||
{
|
||||
PluginAuditManager::instance().set_observation_observer(
|
||||
[this](const AuditObservation& observation) { m_collector.record(observation); });
|
||||
}
|
||||
|
||||
~ScopedObservationObserver() { PluginAuditManager::instance().clear_observation_observer(); }
|
||||
|
||||
ScopedObservationObserver(const ScopedObservationObserver&) = delete;
|
||||
ScopedObservationObserver& operator=(const ScopedObservationObserver&) = delete;
|
||||
|
||||
private:
|
||||
PluginProbeCollector& m_collector;
|
||||
};
|
||||
|
||||
bool prepare_descriptor(const fs::path& plugin_path, PluginDescriptor& descriptor, std::string& error)
|
||||
{
|
||||
boost::system::error_code ec;
|
||||
const fs::path canonical = fs::weakly_canonical(plugin_path, ec);
|
||||
if (ec || !fs::exists(canonical) || !fs::is_regular_file(canonical)) {
|
||||
error = "Plugin package is not a readable file: " + plugin_path.string();
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string extension = canonical.extension().string();
|
||||
boost::algorithm::to_lower(extension);
|
||||
if (extension != ".py" && extension != ".whl") {
|
||||
error = "Plugin package must be a .py or .whl file, got: " + extension;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (extension == ".whl") {
|
||||
if (!read_wheel_plugin_metadata(canonical, descriptor, error))
|
||||
return false;
|
||||
} else {
|
||||
if (!read_python_plugin_metadata(canonical, descriptor, error))
|
||||
return false;
|
||||
}
|
||||
|
||||
descriptor.entry_path = canonical.string();
|
||||
descriptor.plugin_key = make_local_plugin_key(canonical.stem().string());
|
||||
descriptor.metadata_valid = true;
|
||||
// Leaving plugin_root empty keeps the probe independent of any .install_state.json next to
|
||||
// the package and avoids the production sidecar path.
|
||||
descriptor.plugin_root.clear();
|
||||
if (descriptor.name.empty())
|
||||
descriptor.name = descriptor.plugin_key;
|
||||
return true;
|
||||
}
|
||||
|
||||
void redirect_python_stdout()
|
||||
{
|
||||
py::gil_scoped_acquire gil;
|
||||
py::object sys = py::module_::import("sys");
|
||||
// Keep plugin prints from corrupting the C++ report on stdout. sys.stderr is the interpreter's
|
||||
// tee to the temporary log, so plugin output remains available there.
|
||||
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<std::string>();
|
||||
} 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<class Function>
|
||||
void invoke_callback(const std::shared_ptr<PluginCapabilityInterface>& capability,
|
||||
const std::string& phase,
|
||||
const std::string& callback,
|
||||
PluginProbeCollector& collector,
|
||||
Function&& function)
|
||||
{
|
||||
ScopedPluginAuditPhase phase_scope(phase);
|
||||
try {
|
||||
std::forward<Function>(function)();
|
||||
collector.record_callback({capability ? capability->name() : std::string{}, phase, callback, "invoked", {}});
|
||||
} catch (const std::exception& error) {
|
||||
collector.record_callback({capability ? capability->name() : std::string{}, phase, callback, "failed", error.what()});
|
||||
} catch (...) {
|
||||
collector.record_callback({capability ? capability->name() : std::string{}, phase, callback, "failed", "non-standard exception"});
|
||||
}
|
||||
}
|
||||
|
||||
struct ProbeCapability
|
||||
{
|
||||
std::string name;
|
||||
std::string type;
|
||||
bool enabled = true;
|
||||
};
|
||||
|
||||
std::vector<ProbeCapability> describe_capabilities(const Plugin& plugin)
|
||||
{
|
||||
std::vector<ProbeCapability> capabilities;
|
||||
capabilities.reserve(plugin.capabilities.size());
|
||||
for (const auto& capability : plugin.capabilities) {
|
||||
if (!capability)
|
||||
continue;
|
||||
capabilities.push_back({capability->name(), plugin_capability_type_to_string(capability->type()), capability->is_enabled()});
|
||||
}
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
void exercise_callbacks(const Plugin& plugin, PluginProbeCollector& collector)
|
||||
{
|
||||
// on_load() already ran inside plugin_loader::load. The callbacks below are the additional
|
||||
// entry points a loaded capability exposes to the host. Synthetic inputs are used; a callback
|
||||
// may behave differently with a real application state, which the report cannot capture.
|
||||
static constexpr LifecycleEvent lifecycle_events[] = {
|
||||
LifecycleEvent::NewProject, LifecycleEvent::ProjectOpened, LifecycleEvent::ProjectBeforeSave,
|
||||
LifecycleEvent::ProjectAfterSave, LifecycleEvent::ProjectClosed, LifecycleEvent::ProjectDirtyChanged,
|
||||
LifecycleEvent::SliceStarted, LifecycleEvent::SliceGeometryFinished, LifecycleEvent::GCodeExportStarted,
|
||||
LifecycleEvent::GCodeExportFinished, LifecycleEvent::SlicingJobComplete, LifecycleEvent::ObjectAdded,
|
||||
LifecycleEvent::ObjectDeleted, LifecycleEvent::ObjectTransformed, LifecycleEvent::ObjectChanged,
|
||||
LifecycleEvent::ObjectRenamed, LifecycleEvent::PlateCreated, LifecycleEvent::PlateDeleted,
|
||||
LifecycleEvent::PlateSelected, LifecycleEvent::PlateRenamed, LifecycleEvent::PresetSelected,
|
||||
LifecycleEvent::PresetSaved, LifecycleEvent::PrintStateChanged, LifecycleEvent::DeviceOnline,
|
||||
LifecycleEvent::DeviceOffline, LifecycleEvent::DeviceDiscovered, LifecycleEvent::DeviceSelected,
|
||||
LifecycleEvent::UploadStarted, LifecycleEvent::UploadFinished, LifecycleEvent::PrintJobStarted,
|
||||
LifecycleEvent::PrintJobFinished, LifecycleEvent::SendJobStarted, LifecycleEvent::SendJobFinished,
|
||||
};
|
||||
|
||||
for (const auto& capability : plugin.capabilities) {
|
||||
if (!capability)
|
||||
continue;
|
||||
|
||||
invoke_callback(capability, "callback:get_default_config", "get_default_config", collector,
|
||||
[&capability]() { (void) capability->get_default_config(); });
|
||||
|
||||
if (capability->config_ui_available()) {
|
||||
invoke_callback(capability, "callback:get_config_ui", "get_config_ui", collector,
|
||||
[&capability]() { (void) capability->get_config_ui(); });
|
||||
} else {
|
||||
collector.record_callback(
|
||||
{capability->name(), "callback:get_config_ui", "get_config_ui", "skipped", "has_config_ui() is false"});
|
||||
}
|
||||
|
||||
for (const LifecycleEvent event : lifecycle_events) {
|
||||
const std::string event_name = lifecycle_event_to_string(event);
|
||||
const std::string phase = "callback:on_lifecycle_event(" + event_name + ")";
|
||||
LifecycleEventContext context;
|
||||
context.source = "orca-plugin-probe";
|
||||
context.msg = "synthetic event generated by the permission probe";
|
||||
invoke_callback(capability, phase, "on_lifecycle_event(" + event_name + ")", collector,
|
||||
[&capability, event, &context]() { capability->on_lifecycle_event(event, context); });
|
||||
}
|
||||
|
||||
// Cancellation is a load-time path; calling it after a successful load would misrepresent
|
||||
// the capability's real lifecycle. Report it as skipped instead.
|
||||
collector.record_callback(
|
||||
{capability->name(), "callback:on_cancelled", "on_cancelled", "skipped", "not invoked after a successful load"});
|
||||
}
|
||||
}
|
||||
|
||||
nlohmann::json observation_json(const AuditObservation& observation)
|
||||
{
|
||||
return {
|
||||
{"plugin", observation.plugin_key},
|
||||
{"capability", observation.capability},
|
||||
{"phase", observation.phase.empty() ? "unattributed" : observation.phase},
|
||||
{"event", observation.event_name},
|
||||
{"category", observation.category},
|
||||
{"targets", observation.targets},
|
||||
{"continued", observation.continued},
|
||||
};
|
||||
}
|
||||
|
||||
nlohmann::json callback_json(const ProbeCallbackRecord& callback)
|
||||
{
|
||||
return {
|
||||
{"capability", callback.capability}, {"phase", callback.phase}, {"callback", callback.callback},
|
||||
{"status", callback.status}, {"detail", callback.detail},
|
||||
};
|
||||
}
|
||||
|
||||
nlohmann::json capability_json(const ProbeCapability& capability)
|
||||
{
|
||||
return {
|
||||
{"name", capability.name},
|
||||
{"type", capability.type},
|
||||
{"enabled", capability.enabled},
|
||||
};
|
||||
}
|
||||
|
||||
nlohmann::json build_report(const Options& options,
|
||||
const PluginProbeCollector& collector,
|
||||
const std::vector<ProbeCapability>& 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)
|
||||
{
|
||||
const std::vector<AuditObservation> 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;
|
||||
}
|
||||
|
||||
const std::vector<ProbeCallbackRecord> 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"},
|
||||
{"observed_by_this_run_only", true},
|
||||
{"plugin",
|
||||
{
|
||||
{"path", options.plugin_path.string()},
|
||||
{"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",
|
||||
{
|
||||
{"python_initialized", python_initialized},
|
||||
{"python_error", python_error},
|
||||
{"temporary_data_dir", data_dir},
|
||||
{"temporary_data_dir_removed_on_exit", true},
|
||||
{"working_directory", data_dir},
|
||||
{"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", summary},
|
||||
{"coverage",
|
||||
{
|
||||
{"exhaustive", false},
|
||||
{"sandbox", false},
|
||||
{"plugin_code_executed", true},
|
||||
{"notes",
|
||||
{
|
||||
"Only the code paths this probe exercised are represented. Audit events cover "
|
||||
"particular operations, not every Python call.",
|
||||
"The probe does not start the GUI. Host APIs that require a live application or "
|
||||
"UI (orca.host.app/presets/ui) and plugin.storage report an explicit unsupported "
|
||||
"error and are reported as failed callbacks.",
|
||||
"The probe links libslic3r for core geometry/print types, but not libslic3r_gui "
|
||||
"or wx.",
|
||||
"The temporary data directory isolates OrcaSlicer state, not plugin-authored file, "
|
||||
"network or process access. Use OS-level restrictions for untrusted packages.",
|
||||
"The loader may extract adjacent .whl dependencies into the package's directory; "
|
||||
"probe a copy of the package when that side effect is unwanted.",
|
||||
"Import, registration and on_load share one loader phase; the production loader "
|
||||
"does not expose a finer boundary without changing its behavior.",
|
||||
"Callbacks run with synthetic inputs, which can change plugin control flow "
|
||||
"compared with a real application session.",
|
||||
"continued=true means the audit hook did not block the operation. A surrounding "
|
||||
"callback or load step can still fail afterwards, as reported in callbacks/load.",
|
||||
"Events raised on a plugin-spawned thread carry no phase label and are reported "
|
||||
"as unattributed.",
|
||||
"Plugin code can spawn threads/processes, write anywhere this process can, or "
|
||||
"terminate the process. None of that is contained by the probe.",
|
||||
}},
|
||||
}},
|
||||
};
|
||||
}
|
||||
|
||||
void print_text_report(const nlohmann::json& report)
|
||||
{
|
||||
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<std::string>() << "\n";
|
||||
std::cout << "Readiness: " << report["readiness"]["status"].get<std::string>() << " (advisory; exit status follows the load result)\n";
|
||||
for (const auto& issue : report["readiness"]["issues"])
|
||||
std::cout << " " << issue["severity"].get<std::string>() << ": " << issue["detail"].get<std::string>() << "\n";
|
||||
std::cout << "Python initialized: " << (report["environment"]["python_initialized"].get<bool>() ? "yes" : "no") << "\n";
|
||||
if (!report["environment"]["python_error"].get<std::string>().empty())
|
||||
std::cout << "Python error: " << report["environment"]["python_error"].get<std::string>() << "\n";
|
||||
std::cout << "Load: " << (report["load"]["ok"].get<bool>() ? "ok" : "failed") << "\n";
|
||||
if (!report["load"]["error"].get<std::string>().empty())
|
||||
std::cout << "Load error: " << report["load"]["error"].get<std::string>() << "\n";
|
||||
|
||||
const auto& package = report["package"];
|
||||
std::cout << "\nPackage: valid=" << (package["valid"].get<bool>() ? "yes" : "no")
|
||||
<< " format=" << package["entry_format"].get<std::string>() << "\n";
|
||||
std::cout << " entry: " << package["entry_path"].get<std::string>() << "\n";
|
||||
std::cout << " name=" << or_none(package["metadata"]["name"].get<std::string>())
|
||||
<< " version=" << or_none(package["metadata"]["version"].get<std::string>())
|
||||
<< " author=" << or_none(package["metadata"]["author"].get<std::string>()) << "\n";
|
||||
if (!package["metadata"]["entry_package"].get<std::string>().empty())
|
||||
std::cout << " entry package: " << package["metadata"]["entry_package"].get<std::string>() << "\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<std::string>();
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
std::cout << "\n installed: no; the probe does not install the package or its dependencies\n";
|
||||
if (!package["error"].get<std::string>().empty())
|
||||
std::cout << " package error: " << package["error"].get<std::string>() << "\n";
|
||||
|
||||
const auto& runtime = report["runtime"];
|
||||
std::cout << "\nRuntime: python_version=" << or_none(runtime["python"]["version"].get<std::string>())
|
||||
<< " 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<std::string>();
|
||||
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<std::string>() << " [" << capability["type"].get<std::string>()
|
||||
<< "] enabled=" << (capability["enabled"].get<bool>() ? "yes" : "no") << "\n";
|
||||
|
||||
const auto& callback_results = report["callback_results"];
|
||||
std::cout << "\nCallbacks (" << callback_results["total"].get<std::size_t>() << ": " << callback_results["invoked"].get<std::size_t>()
|
||||
<< " invoked, " << callback_results["failed"].get<std::size_t>() << " failed, "
|
||||
<< callback_results["skipped"].get<std::size_t>() << " skipped)\n";
|
||||
for (const auto& callback : report["callbacks"]) {
|
||||
std::cout << " " << callback["status"].get<std::string>() << " " << callback["callback"].get<std::string>();
|
||||
if (!callback["capability"].get<std::string>().empty())
|
||||
std::cout << " capability=" << callback["capability"].get<std::string>();
|
||||
if (!callback["detail"].get<std::string>().empty())
|
||||
std::cout << " - " << callback["detail"].get<std::string>();
|
||||
std::cout << "\n";
|
||||
}
|
||||
|
||||
std::cout << "\nObserved audit events (" << report["audit_activity"]["observed_event_count"].get<std::size_t>() << " events, "
|
||||
<< report["audit_activity"]["category_count"].get<std::size_t>() << " categories):\n";
|
||||
for (const auto& observation : report["observations"]) {
|
||||
std::cout << " phase=" << observation["phase"].get<std::string>() << " event=" << observation["event"].get<std::string>()
|
||||
<< " category=" << observation["category"].get<std::string>();
|
||||
if (!observation["capability"].get<std::string>().empty())
|
||||
std::cout << " capability=" << observation["capability"].get<std::string>();
|
||||
if (!observation["targets"].empty()) {
|
||||
std::cout << " targets=";
|
||||
bool first = true;
|
||||
for (const auto& target : observation["targets"]) {
|
||||
if (!first)
|
||||
std::cout << ",";
|
||||
std::cout << target.get<std::string>();
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
std::cout << " continued=" << (observation["continued"].get<bool>() ? "yes" : "no") << "\n";
|
||||
}
|
||||
|
||||
std::cout << "\nObserved permission categories:\n";
|
||||
for (const auto& [category, detail] : report["summary"]["categories"].items()) {
|
||||
std::cout << " " << category << ":";
|
||||
for (const auto& event : detail["events"])
|
||||
std::cout << " " << event.get<std::string>();
|
||||
if (!detail["targets"].empty()) {
|
||||
std::cout << " -> ";
|
||||
bool first = true;
|
||||
for (const auto& target : detail["targets"]) {
|
||||
if (!first)
|
||||
std::cout << ", ";
|
||||
std::cout << target.get<std::string>();
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
std::cout << "\n";
|
||||
}
|
||||
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<int>() << " (driven by load; readiness is advisory)\n";
|
||||
|
||||
std::cout << "\nCoverage limits:\n";
|
||||
for (const auto& note : report["coverage"]["notes"])
|
||||
std::cout << " - " << note.get<std::string>() << "\n";
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
Options options;
|
||||
std::string option_error;
|
||||
if (!parse_options(argc, argv, options, option_error)) {
|
||||
std::cerr << "orca-plugin-probe: " << option_error << "\n\n";
|
||||
print_usage(std::cerr);
|
||||
return 2;
|
||||
}
|
||||
|
||||
boost::system::error_code ec;
|
||||
const fs::path canonical_plugin = fs::weakly_canonical(options.plugin_path, ec);
|
||||
if (!ec)
|
||||
options.plugin_path = canonical_plugin;
|
||||
|
||||
ScopedProbeWorkspace workspace;
|
||||
PluginProbeCollector collector;
|
||||
ScopedObservationObserver observer(collector);
|
||||
|
||||
PluginDescriptor descriptor;
|
||||
std::string descriptor_error;
|
||||
const bool descriptor_ready = prepare_descriptor(options.plugin_path, descriptor, descriptor_error);
|
||||
|
||||
PythonInterpreter& interpreter = PythonInterpreter::instance();
|
||||
bool python_initialized = false;
|
||||
std::string python_error;
|
||||
if (descriptor_ready) {
|
||||
python_initialized = interpreter.initialize();
|
||||
if (!python_initialized)
|
||||
python_error = interpreter.last_error();
|
||||
}
|
||||
|
||||
if (python_initialized) {
|
||||
try {
|
||||
redirect_python_stdout();
|
||||
} catch (const std::exception& error) {
|
||||
python_error = std::string("Could not redirect Python stdout: ") + error.what();
|
||||
}
|
||||
}
|
||||
|
||||
std::string python_version;
|
||||
if (python_initialized)
|
||||
python_version = query_python_version();
|
||||
|
||||
bool loaded = false;
|
||||
std::string load_error = descriptor_error;
|
||||
Plugin plugin;
|
||||
std::vector<ProbeCapability> capabilities;
|
||||
|
||||
if (python_initialized && descriptor_ready) {
|
||||
// The outer context covers import, registration and on_load in plugin_loader::load; the
|
||||
// per-callback scopes below cover the follow-up calls. The production loader is unchanged.
|
||||
ScopedPluginAuditContext audit_context(descriptor.plugin_key);
|
||||
{
|
||||
ScopedPluginAuditPhase load_phase(kLoadPhase);
|
||||
loaded = plugin_loader::load(descriptor, /*skip_deps=*/true, /*capabilities_to_enable=*/{}, nullptr, plugin, load_error);
|
||||
collector.record_callback({std::string{}, kLoadPhase, "plugin_loader::load", loaded ? "invoked" : "failed", load_error});
|
||||
collector.record_callback({std::string{}, "callback:on_load", "on_load", loaded ? "invoked" : "skipped",
|
||||
loaded ? "invoked by plugin_loader::load for each materialized capability" :
|
||||
"plugin_loader::load failed before completing on_load"});
|
||||
}
|
||||
|
||||
capabilities = describe_capabilities(plugin);
|
||||
|
||||
if (loaded)
|
||||
exercise_callbacks(plugin, collector);
|
||||
|
||||
if (loaded) {
|
||||
ScopedPluginAuditPhase unload_phase("callback:on_unload");
|
||||
plugin_loader::unload(plugin);
|
||||
collector.record_callback({std::string{}, "callback:on_unload", "on_unload", "invoked",
|
||||
"invoked by plugin_loader::unload; per-capability failures are logged by the loader"});
|
||||
}
|
||||
}
|
||||
|
||||
if (interpreter.is_initialized())
|
||||
interpreter.shutdown();
|
||||
|
||||
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,
|
||||
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";
|
||||
else
|
||||
print_text_report(report);
|
||||
|
||||
return loaded ? 0 : 1;
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
#ifndef slic3r_PluginProbeCollector_hpp_
|
||||
#define slic3r_PluginProbeCollector_hpp_
|
||||
|
||||
// Record-only collection for the headless plugin permission probe. This is deliberately a plain
|
||||
// in-memory store: it has no dialog, grant or persistence path, and it never decides whether an
|
||||
// audited operation may proceed. Keeping it independent of wx/Python also makes its grouping and
|
||||
// phase attribution testable without starting the interpreter.
|
||||
|
||||
#include "slic3r/plugin/PluginAuditManager.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
// What the probe attempted and what came back. `status` is one of "invoked", "failed" or
|
||||
// "skipped"; a skipped callback is reported so the absence of observations is not mistaken for a
|
||||
// clean result.
|
||||
struct ProbeCallbackRecord
|
||||
{
|
||||
std::string capability;
|
||||
std::string phase;
|
||||
std::string callback;
|
||||
std::string status;
|
||||
std::string detail;
|
||||
};
|
||||
|
||||
class PluginProbeCollector
|
||||
{
|
||||
public:
|
||||
void record(const AuditObservation& observation)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
m_observations.push_back(observation);
|
||||
}
|
||||
|
||||
void record_callback(ProbeCallbackRecord record)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
m_callbacks.push_back(std::move(record));
|
||||
}
|
||||
|
||||
std::vector<AuditObservation> observations() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
return m_observations;
|
||||
}
|
||||
|
||||
std::vector<ProbeCallbackRecord> callbacks() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
return m_callbacks;
|
||||
}
|
||||
|
||||
// Groups the run by the permission categories the audit hook already assigned, and lists the
|
||||
// distinct targets and event names seen in each. This is a view of this run only; it says
|
||||
// nothing about code paths the plugin did not exercise.
|
||||
nlohmann::json summary() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
|
||||
std::map<std::string, std::set<std::string>> events;
|
||||
std::map<std::string, std::set<std::string>> targets;
|
||||
for (const AuditObservation& observation : m_observations) {
|
||||
events[observation.category].insert(observation.event_name);
|
||||
targets[observation.category].insert(observation.targets.begin(), observation.targets.end());
|
||||
}
|
||||
|
||||
nlohmann::json categories = nlohmann::json::object();
|
||||
for (const auto& [category, category_events] : events) {
|
||||
categories[category] = {
|
||||
{"events", category_events},
|
||||
{"targets", targets[category]},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
{"observed_event_count", m_observations.size()},
|
||||
{"categories", categories},
|
||||
};
|
||||
}
|
||||
|
||||
private:
|
||||
mutable std::mutex m_mutex;
|
||||
std::vector<AuditObservation> m_observations;
|
||||
std::vector<ProbeCallbackRecord> m_callbacks;
|
||||
};
|
||||
|
||||
} // namespace Slic3r
|
||||
|
||||
#endif // slic3r_PluginProbeCollector_hpp_
|
||||
@@ -1,32 +0,0 @@
|
||||
// Minimal replacement for the config API PythonPluginBridge binds into `orca`.
|
||||
//
|
||||
// The production implementation (PluginConfig.cpp) resolves presets, reads the sidebar's effective
|
||||
// config and writes config.json through PluginManager, all of which pull in the GUI and the
|
||||
// registry. The probe has neither: a capability reports an empty config, an empty version and a
|
||||
// refused save. That keeps the bridge's Python surface intact without pretending to reproduce
|
||||
// application state the probe does not have.
|
||||
|
||||
#include "slic3r/plugin/PluginConfig.hpp"
|
||||
#include "slic3r/plugin/PythonPluginInterface.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
nlohmann::json capability_get_config(const PluginCapabilityInterface&)
|
||||
{
|
||||
return nlohmann::json::object();
|
||||
}
|
||||
|
||||
std::string capability_get_config_version(const PluginCapabilityInterface&) { return {}; }
|
||||
|
||||
bool capability_save_config(const PluginCapabilityInterface&, const nlohmann::json&) { return false; }
|
||||
|
||||
std::string PluginConfig::capability_type_label(PluginCapabilityType type)
|
||||
{
|
||||
return plugin_capability_type_display_name(type);
|
||||
}
|
||||
|
||||
} // namespace Slic3r
|
||||
@@ -1,6 +1,7 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/catch_message.hpp>
|
||||
#include "test_helpers.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
@@ -17,15 +18,48 @@
|
||||
using namespace Slic3r;
|
||||
using namespace Slic3r::Test;
|
||||
|
||||
// The fan is held off for the first close_fan_the_first_x_layers layers, so an explicit
|
||||
// fan-off command is emitted.
|
||||
// The fan stays off while printing the first close_fan_the_first_x_layers layers and runs afterwards.
|
||||
TEST_CASE("Fan is held off for the initial layers", "[Cooling]")
|
||||
{
|
||||
const int fan_off_layers = 5;
|
||||
const std::string gcode = slice({ cube(20) }, {
|
||||
{ "cooling", true },
|
||||
{ "close_fan_the_first_x_layers", 5 },
|
||||
{ "close_fan_the_first_x_layers", fan_off_layers },
|
||||
{ "full_fan_speed_layer", 0 },
|
||||
{ "fan_min_speed", 50 },
|
||||
{ "fan_max_speed", 100 },
|
||||
{ "fan_cooling_layer_time", 1000 }, // every layer is short enough to run the fan
|
||||
{ "layer_height", 0.2 },
|
||||
{ "initial_layer_print_height", 0.2 },
|
||||
});
|
||||
CHECK(gcode.find("M106 S0") != std::string::npos);
|
||||
|
||||
// Record, for each layer, whether any extrusion was printed with the part fan running.
|
||||
std::vector<bool> extruded_with_fan;
|
||||
int layer = -1;
|
||||
bool fan_on = false;
|
||||
std::istringstream input(gcode);
|
||||
std::string line;
|
||||
while (std::getline(input, line)) {
|
||||
if (line.rfind(";LAYER_CHANGE", 0) == 0) {
|
||||
++layer;
|
||||
extruded_with_fan.push_back(false);
|
||||
} else if (line.rfind("M107", 0) == 0) {
|
||||
fan_on = false;
|
||||
} else if (line.rfind("M106 ", 0) == 0 && line.find(" P") == std::string::npos) {
|
||||
const size_t s = line.find(" S");
|
||||
fan_on = s != std::string::npos && std::stod(line.substr(s + 2)) > 0.;
|
||||
} else if (layer >= 0 && fan_on && line.rfind("G1 ", 0) == 0 && line.find(" E") != std::string::npos &&
|
||||
(line.find(" X") != std::string::npos || line.find(" Y") != std::string::npos)) {
|
||||
extruded_with_fan[layer] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// A 20 mm cube at 0.2 mm layers has 100 layers.
|
||||
REQUIRE(extruded_with_fan.size() == 100);
|
||||
for (int i = 0; i < fan_off_layers; ++i) {
|
||||
CAPTURE(i);
|
||||
CHECK_FALSE(extruded_with_fan[i]);
|
||||
}
|
||||
CHECK(extruded_with_fan[fan_off_layers]);
|
||||
}
|
||||
|
||||
// The cooling pass resolves and strips its internal speed placeholders; none leak into
|
||||
|
||||
@@ -86,10 +86,9 @@ TEST_CASE("Pattern path length", "[Fill]") {
|
||||
Slic3r::Polylines paths = test(Slic3r::ExPolygon(test_set));
|
||||
REQUIRE(paths.size() == 1); // one continuous path
|
||||
|
||||
// TODO: determine what the "Expected length" should be for rectilinear fill of a 100x100 polygon.
|
||||
// This check only checks that it's above scale(3*100 + 2*50) + scaled_epsilon.
|
||||
// ok abs($paths->[0]->length - scale(3*100 + 2*50)) - scaled_epsilon, 'path has expected length';
|
||||
REQUIRE(std::abs(paths[0].length() - static_cast<double>(scale_(3*100 + 2*50))) - SCALED_EPSILON > 0); // path has expected length
|
||||
// Lines are 50 mm apart and the square is inset by half a spacing, so the lines on its edges drop out
|
||||
// and a single line crosses the middle, ending within half a spacing of the edges.
|
||||
CHECK_THAT(unscale<double>(paths[0].length()), Catch::Matchers::WithinAbs(100., filler->spacing / 2.));
|
||||
|
||||
test_set.clear();
|
||||
}
|
||||
@@ -148,10 +147,9 @@ TEST_CASE("Pattern path length", "[Fill]") {
|
||||
Slic3r::Polylines paths = test(Slic3r::ExPolygon(points));
|
||||
REQUIRE(paths.size() == 1); // one continuous path
|
||||
|
||||
// TODO: determine what the "Expected length" should be for rectilinear fill of a 100x100 polygon.
|
||||
// This check only checks that it's above scale(3*100 + 2*50) + scaled_epsilon.
|
||||
// ok abs($paths->[0]->length - scale(3*100 + 2*50)) - scaled_epsilon, 'path has expected length';
|
||||
REQUIRE(std::abs(paths[0].length() - static_cast<double>(scale_(3*100 + 2*50))) - SCALED_EPSILON > 0); // path has expected length
|
||||
// A solid fill covers the area with lines one spacing apart, so a missing segment shortens the path.
|
||||
const double expected_length = unscale<double>(unscale<double>(Slic3r::ExPolygon(points).area())) / filler->spacing;
|
||||
CHECK_THAT(unscale<double>(paths[0].length()), Catch::Matchers::WithinRel(expected_length, 0.01));
|
||||
}
|
||||
|
||||
SECTION("Rotated Square") {
|
||||
@@ -168,8 +166,9 @@ TEST_CASE("Pattern path length", "[Fill]") {
|
||||
fill_params.density = 1.0;
|
||||
filler->spacing = flow.spacing();
|
||||
|
||||
for (auto angle : { 0.0, 45.0}) {
|
||||
for (auto angle : { 0.0, PI / 4.0 }) {
|
||||
surface.expolygon.rotate(angle, Point(0,0));
|
||||
filler->bounding_box = get_extents(surface.expolygon.contour);
|
||||
Polylines paths = filler->fill_surface(&surface, fill_params);
|
||||
REQUIRE(paths.size() == 1);
|
||||
}
|
||||
@@ -222,7 +221,7 @@ TEST_CASE("Pattern path length", "[Fill]") {
|
||||
};
|
||||
Slic3r::ExPolygon expolygon(points);
|
||||
|
||||
REQUIRE(test_if_solid_surface_filled(expolygon, 0.5, 45.0, 0.99) == true);
|
||||
REQUIRE(test_if_solid_surface_filled(expolygon, 0.5, PI / 4.0, 0.99) == true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -123,38 +123,58 @@ SCENARIO("Changing the number of solid shell layers does not make all surfaces i
|
||||
config.set_deserialize_strict({
|
||||
{ "top_shell_layers", 2 },
|
||||
{ "bottom_shell_layers", 1 },
|
||||
{ "layer_height", 0.25 }, // get a known number of layers
|
||||
// Shell counts alone decide which layers are solid.
|
||||
{ "top_shell_thickness", 0 },
|
||||
{ "bottom_shell_thickness", 0 },
|
||||
{ "sparse_infill_density", "20%" },
|
||||
{ "layer_height", 0.25 }, // 80 layers: 0..79
|
||||
{ "initial_layer_print_height", 0.25 }
|
||||
});
|
||||
Slic3r::Print print;
|
||||
Slic3r::Model model;
|
||||
Slic3r::Test::init_print({cube(20)}, print, model, config);
|
||||
// Precondition: Ensure that the model has 2 solid top layers (79, 78)
|
||||
// and one solid bottom layer (0).
|
||||
auto test_is_solid_infill = [&print](size_t obj_id, size_t layer_id) {
|
||||
// True when the layer has fill surfaces and all of them are solid.
|
||||
auto is_solid_infill = [&print](size_t obj_id, size_t layer_id) {
|
||||
const Layer &layer = *(print.objects().at(obj_id)->get_layer((int)layer_id));
|
||||
// iterate over all of the regions in the layer
|
||||
for (const LayerRegion *region : layer.regions()) {
|
||||
// for each region, iterate over the fill surfaces
|
||||
bool has_fill = false;
|
||||
for (const LayerRegion *region : layer.regions())
|
||||
for (const Surface &surface : region->fill_surfaces.surfaces) {
|
||||
if (! surface.is_solid())
|
||||
return false;
|
||||
has_fill = true;
|
||||
}
|
||||
return has_fill;
|
||||
};
|
||||
// True when the layer has a sparse fill surface, so a layer with no fill at all does not count.
|
||||
auto has_sparse_infill = [&print](size_t obj_id, size_t layer_id) {
|
||||
const Layer &layer = *(print.objects().at(obj_id)->get_layer(static_cast<int>(layer_id)));
|
||||
for (const LayerRegion *region : layer.regions())
|
||||
for (const Surface &surface : region->fill_surfaces.surfaces)
|
||||
CHECK(surface.is_solid());
|
||||
}
|
||||
if (! surface.is_solid())
|
||||
return true;
|
||||
return false;
|
||||
};
|
||||
print.process();
|
||||
test_is_solid_infill(0, 0); // should be solid
|
||||
test_is_solid_infill(0, 79); // should be solid
|
||||
test_is_solid_infill(0, 78); // should be solid
|
||||
// Precondition: 2 solid top layers (79, 78) and one solid bottom layer (0).
|
||||
REQUIRE(is_solid_infill(0, 0));
|
||||
REQUIRE(is_solid_infill(0, 79));
|
||||
REQUIRE(is_solid_infill(0, 78));
|
||||
REQUIRE(has_sparse_infill(0, 77));
|
||||
WHEN("Model is re-sliced with top_shell_layers == 3") {
|
||||
config.set("top_shell_layers", 3);
|
||||
print.apply(model, config);
|
||||
print.process();
|
||||
THEN("Print object does not have 0 solid bottom layers.") {
|
||||
test_is_solid_infill(0, 0);
|
||||
CHECK(is_solid_infill(0, 0));
|
||||
}
|
||||
AND_THEN("Print object has 3 top solid layers") {
|
||||
test_is_solid_infill(0, 79);
|
||||
test_is_solid_infill(0, 78);
|
||||
test_is_solid_infill(0, 77);
|
||||
CHECK(is_solid_infill(0, 79));
|
||||
CHECK(is_solid_infill(0, 78));
|
||||
CHECK(is_solid_infill(0, 77));
|
||||
CHECK(has_sparse_infill(0, 76));
|
||||
}
|
||||
AND_THEN("Middle layers keep sparse infill") {
|
||||
CHECK(has_sparse_infill(0, 40));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -672,14 +692,18 @@ TEST_CASE("gcode_skip_config_block omits the resolved-settings comment block", "
|
||||
{
|
||||
const std::string gcode = slice({ cube(20) }, {
|
||||
{ "gcode_skip_config_block", true },
|
||||
{ "gcode_comments", true },
|
||||
});
|
||||
CHECK(gcode.find("; CONFIG_BLOCK_START") == std::string::npos);
|
||||
CHECK(gcode.find("; CONFIG_BLOCK_END") == std::string::npos);
|
||||
CHECK(gcode.find("; layer_height =") == std::string::npos);
|
||||
CHECK(gcode.find("; fill_density =") == std::string::npos);
|
||||
CHECK(gcode.find("; sparse_infill_density =") == std::string::npos);
|
||||
CHECK(gcode.find("; HEADER_BLOCK_START") != std::string::npos);
|
||||
CHECK(gcode.find("; EXECUTABLE_BLOCK_START") != std::string::npos);
|
||||
|
||||
const std::string with_block = slice({ cube(20) }, { { "gcode_skip_config_block", false } });
|
||||
CHECK(with_block.find("; CONFIG_BLOCK_START") != std::string::npos);
|
||||
CHECK(with_block.find("; layer_height =") != std::string::npos);
|
||||
CHECK(with_block.find("; sparse_infill_density =") != std::string::npos);
|
||||
}
|
||||
|
||||
// Some firmwares only scan the last N lines of the file for "estimated printing time", so it
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||
#include <catch2/matchers/catch_matchers.hpp>
|
||||
#include "libslic3r/TriangleMesh.hpp"
|
||||
#include "libslic3r/TriangleMeshSlicer.hpp"
|
||||
#include "libslic3r/Point.hpp"
|
||||
@@ -162,21 +164,29 @@ SCENARIO("Slice behavior", "[TriangleMesh]") {
|
||||
const std::vector<Vec3f> vertices {{0,0,0},{0,0,20},{0,5,0},{0,5,20},{50,0,0},{50,0,20},{15,5,0},{35,5,0},{15,20,0},{50,5,0},{35,20,0},{15,5,10},{50,5,20},{35,5,10},{35,20,10},{15,20,10}};
|
||||
const std::vector<Vec3i32> facets {{0,1,2},{2,1,3},{1,0,4},{5,1,4},{0,2,4},{4,2,6},{7,6,8},{4,6,7},{9,4,7},{7,8,10},{2,3,6},{11,3,12},{7,12,9},{13,12,7},{6,3,11},{11,12,13},{3,1,5},{12,3,5},{5,4,9},{12,5,9},{13,7,10},{14,13,10},{8,15,10},{10,15,14},{6,11,8},{8,11,15},{15,11,13},{14,15,13}};
|
||||
|
||||
auto cube = make_cube();
|
||||
TriangleMesh mesh(vertices, facets);
|
||||
// A 50x5x20 wall with a 20x15x10 block against it, so the block's top is at Z = 10.
|
||||
const double wall_area = 50. * 5.;
|
||||
const double wall_block_area = wall_area + 20. * 15.;
|
||||
const auto area_mm2 = [](const ExPolygons &slice) { return slice.at(0).area() * SCALING_FACTOR * SCALING_FACTOR; };
|
||||
WHEN(" a top tangent plane is sliced") {
|
||||
// At Z = 10 we have a top horizontal surface.
|
||||
std::vector<ExPolygons> slices = cube.slice({5.0, 10.0});
|
||||
std::vector<ExPolygons> slices = mesh.slice({5.0, 10.0});
|
||||
THEN( "its area is included") {
|
||||
REQUIRE(slices.at(0).at(0).area() > 0);
|
||||
REQUIRE(slices.at(1).at(0).area() > 0);
|
||||
REQUIRE(slices.at(0).size() == 1);
|
||||
REQUIRE(slices.at(1).size() == 1);
|
||||
CHECK_THAT(area_mm2(slices.at(0)), Catch::Matchers::WithinRel(wall_block_area, 1e-6));
|
||||
CHECK_THAT(area_mm2(slices.at(1)), Catch::Matchers::WithinRel(wall_block_area, 1e-6));
|
||||
}
|
||||
}
|
||||
WHEN(" a model that has been transformed is sliced") {
|
||||
cube.mirror_z();
|
||||
std::vector<ExPolygons> slices = cube.slice({-5.0, -10.0});
|
||||
THEN( "it is sliced properly (mirrored bottom plane area is included)") {
|
||||
REQUIRE(slices.at(0).at(0).area() > 0);
|
||||
REQUIRE(slices.at(1).at(0).area() > 0);
|
||||
mesh.mirror_z();
|
||||
std::vector<ExPolygons> slices = mesh.slice({-5.0, -10.0});
|
||||
THEN( "it is sliced like the original, and the mirrored block's bottom plane is not part of it") {
|
||||
REQUIRE(slices.at(0).size() == 1);
|
||||
REQUIRE(slices.at(1).size() == 1);
|
||||
CHECK_THAT(area_mm2(slices.at(0)), Catch::Matchers::WithinRel(wall_block_area, 1e-6));
|
||||
// A triangle owns its top edge but not its bottom edge, so a bottom tangent plane is left out.
|
||||
CHECK_THAT(area_mm2(slices.at(1)), Catch::Matchers::WithinRel(wall_area, 1e-6));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,9 +319,18 @@ TEST_CASE("FilamentGroup property checks", "[filament_group][property]") {
|
||||
// build (config_b/config_c cases evaluate the full per-layer nozzle-aware reorder for every
|
||||
// candidate grouping; this is a guard against hangs, not a micro-perf gate).
|
||||
REQUIRE(result.elapsed_ms < 40000.0);
|
||||
REQUIRE(result.flush_cost >= 0);
|
||||
|
||||
// Every case, feasible or not, must yield a usable grouping: each used filament is
|
||||
// assigned to an existing nozzle.
|
||||
auto used_filaments = collect_sorted_used_filaments(tc.context.model_info.layer_filaments);
|
||||
const auto &nozzle_list = tc.context.nozzle_info.nozzle_list;
|
||||
for (unsigned int fil : used_filaments) {
|
||||
INFO("Filament: " << fil);
|
||||
REQUIRE(fil < result.filament_map.size());
|
||||
CHECK(result.filament_map[fil] >= 0);
|
||||
CHECK(result.filament_map[fil] < static_cast<int>(nozzle_list.size()));
|
||||
}
|
||||
|
||||
if (is_constraint_feasible(tc.context, used_filaments)) {
|
||||
if (!result.constraints_ok) {
|
||||
for (auto& v : result.violations)
|
||||
|
||||
@@ -1055,18 +1055,19 @@ SCENARIO("Non-standard nozzle diameter survives .3mf save on a single-nozzle pri
|
||||
}
|
||||
}
|
||||
|
||||
// A legacy / foreign project (no multi-nozzle metadata) must load crash-safe through the BBS
|
||||
// importer and must not fabricate a filament_volume_map.
|
||||
SCENARIO("Legacy project loads crash-safe via load_bbs_3mf", "[3mf][MultiNozzle]") {
|
||||
GIVEN("a project without any multi-nozzle metadata") {
|
||||
std::string path = std::string(TEST_DATA_DIR) + "/test_3mf/Geräte/Büchse.3mf";
|
||||
Model model;
|
||||
DynamicPrintConfig config;
|
||||
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
|
||||
PlateDataPtrs plates;
|
||||
std::vector<Preset*> project_presets;
|
||||
bool is_bbl_3mf = false, is_orca_3mf = false;
|
||||
Semver file_version;
|
||||
// A project without multi-nozzle metadata must load through the BBS importer and must not
|
||||
// fabricate a filament_volume_map.
|
||||
SCENARIO("Projects without multi-nozzle metadata load without a per-filament volume map", "[3mf][MultiNozzle]") {
|
||||
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
|
||||
std::vector<Preset*> project_presets;
|
||||
bool is_bbl_3mf = false, is_orca_3mf = false;
|
||||
Semver file_version;
|
||||
|
||||
GIVEN("a legacy project with a bare model and no plate metadata") {
|
||||
std::string path = std::string(TEST_DATA_DIR) + "/test_3mf/Geräte/Büchse.3mf";
|
||||
Model model;
|
||||
DynamicPrintConfig config;
|
||||
PlateDataPtrs plates;
|
||||
|
||||
WHEN("loaded through the BBS importer") {
|
||||
bool loaded = false;
|
||||
@@ -1074,10 +1075,32 @@ SCENARIO("Legacy project loads crash-safe via load_bbs_3mf", "[3mf][MultiNozzle]
|
||||
&project_presets, &is_bbl_3mf, &is_orca_3mf,
|
||||
&file_version, nullptr,
|
||||
LoadStrategy::LoadModel | LoadStrategy::LoadConfig));
|
||||
THEN("it does not crash and invents no per-filament volume map") {
|
||||
for (PlateData* p : plates) {
|
||||
REQUIRE(p->config.option<ConfigOptionInts>("filament_volume_map") == nullptr);
|
||||
}
|
||||
THEN("the model loads") {
|
||||
REQUIRE(loaded);
|
||||
REQUIRE_FALSE(model.objects.empty());
|
||||
}
|
||||
release_PlateData_list(plates);
|
||||
}
|
||||
}
|
||||
|
||||
GIVEN("a project with plate metadata but no filament volume map") {
|
||||
ScopedTemporaryFile temp(".3mf");
|
||||
store_painted_cube(temp.string());
|
||||
ScopedTemporaryDir backup_dir("orca_volume_map_dst");
|
||||
Model model;
|
||||
model.set_backup_path(backup_dir.string());
|
||||
DynamicPrintConfig config;
|
||||
PlateDataPtrs plates;
|
||||
|
||||
WHEN("loaded through the BBS importer") {
|
||||
const bool loaded = load_bbs_3mf(temp.string().c_str(), &config, &ctxt, &model, &plates,
|
||||
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version,
|
||||
nullptr, LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
|
||||
THEN("every plate loads without a per-filament volume map") {
|
||||
REQUIRE(loaded);
|
||||
REQUIRE_FALSE(plates.empty());
|
||||
for (PlateData* p : plates)
|
||||
CHECK(p->config.option<ConfigOptionInts>("filament_volume_map") == nullptr);
|
||||
}
|
||||
release_PlateData_list(plates);
|
||||
}
|
||||
@@ -2048,8 +2071,8 @@ SCENARIO("Published 3MF round-trips the extended material metadata", "[3mf]") {
|
||||
REQUIRE(dst_model.model_info != nullptr);
|
||||
REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] == material_keys_json);
|
||||
|
||||
// The value must parse back with every extended field intact.
|
||||
nlohmann::json entries = nlohmann::json::parse(material_keys_json);
|
||||
// The loaded value must parse back with every extended field intact.
|
||||
nlohmann::json entries = nlohmann::json::parse(dst_model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG]);
|
||||
REQUIRE(entries.is_array());
|
||||
REQUIRE(entries.size() == 1);
|
||||
REQUIRE(entries[0]["full"].get<bool>() == true);
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <boost/filesystem/path.hpp>
|
||||
#include <fstream>
|
||||
#include <memory>
|
||||
#include <set>
|
||||
@@ -53,6 +54,7 @@
|
||||
#include <BRepAdaptor_Surface.hxx>
|
||||
#include <TopExp_Explorer.hxx>
|
||||
#include <TopoDS.hxx>
|
||||
#include <BRepAlgoAPI_Common.hxx>
|
||||
#include <BRepGProp.hxx>
|
||||
#include <GProp_GProps.hxx>
|
||||
#include <BRepPrimAPI_MakeBox.hxx>
|
||||
@@ -772,16 +774,10 @@ TEST_CASE("entity constraints: tangent/midpoint/symmetric/angle", "[CadDocument]
|
||||
|
||||
TEST_CASE("imported text glyphs all extrude without failing (charset sweep)", "[CadDocument]")
|
||||
{
|
||||
std::string font = resources_dir().empty()
|
||||
? std::string("resources/fonts/HarmonyOS_Sans_SC_Regular.ttf")
|
||||
: resources_dir() + "/fonts/HarmonyOS_Sans_SC_Regular.ttf";
|
||||
{
|
||||
std::ifstream probe(font);
|
||||
if (!probe.good()) {
|
||||
SUCCEED("bundled font not reachable in this environment; covered live on :10");
|
||||
return;
|
||||
}
|
||||
}
|
||||
// The bundled font, from the source tree: PROFILES_DIR is <repo>/resources/profiles.
|
||||
const std::string font =
|
||||
(boost::filesystem::path(PROFILES_DIR).parent_path() / "fonts" / "HarmonyOS_Sans_SC_Regular.ttf").string();
|
||||
REQUIRE(std::ifstream(font).good());
|
||||
|
||||
const std::string charset =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
@@ -2853,9 +2849,10 @@ TEST_CASE("datum coordinate system: non-perpendicular inputs produce orthonormal
|
||||
using Catch::Matchers::WithinAbs;
|
||||
|
||||
CadDocument doc;
|
||||
// Build a body so we have a face to reference for FaceAndDirection.
|
||||
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box");
|
||||
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "BoxExt");
|
||||
// A cylinder's end cap is bounded by one closed circular edge, which offers no in-plane
|
||||
// direction, so the frame has to fall back on coordsys_x_hint.
|
||||
int sk = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 5.0, "Circle");
|
||||
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Cyl");
|
||||
REQUIRE(doc.recompute());
|
||||
REQUIRE(doc.bodies.size() == 1);
|
||||
int n_faces = GeometryEngine::face_count(doc.bodies[0].shape);
|
||||
@@ -2865,34 +2862,33 @@ TEST_CASE("datum coordinate system: non-perpendicular inputs produce orthonormal
|
||||
if (fn.z() > 0.9) { top_face = i; break; }
|
||||
}
|
||||
REQUIRE(top_face >= 0);
|
||||
const Vec3d Z = GeometryEngine::face_normal_world(GeometryEngine::face_by_index(doc.bodies[0].shape, top_face));
|
||||
|
||||
int cs = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 0), "CS1");
|
||||
REQUIRE(cs >= 0);
|
||||
doc.features[cs].coordsys_type = CoordSysType::FaceAndDirection;
|
||||
doc.features[cs].coordsys_body = 0;
|
||||
doc.features[cs].coordsys_face = top_face;
|
||||
// Deliberately non-perpendicular X hint (NOT orthogonal to face normal ~+Z).
|
||||
// Deliberately non-perpendicular X hint (NOT orthogonal to the face normal +Z).
|
||||
doc.features[cs].coordsys_x_hint = Vec3d(3.0, -1.0, 0.5);
|
||||
|
||||
auto css = doc.resolve_datum_coordsys();
|
||||
REQUIRE(css.size() == 1);
|
||||
REQUIRE(css[0].error.empty());
|
||||
|
||||
Vec3d X = css[0].x, Y = css[0].y;
|
||||
// Orthonormality: each axis has unit length
|
||||
const Vec3d X = css[0].x, Y = css[0].y;
|
||||
// X is the hint projected into the face plane.
|
||||
const Vec3d expected_x = Vec3d(3.0, -1.0, 0.0).normalized();
|
||||
CHECK_THAT(X.x(), WithinAbs(expected_x.x(), 1e-9));
|
||||
CHECK_THAT(X.y(), WithinAbs(expected_x.y(), 1e-9));
|
||||
CHECK_THAT(X.z(), WithinAbs(expected_x.z(), 1e-9));
|
||||
// Orthonormal, with Z the face normal and the frame right-handed.
|
||||
CHECK_THAT(X.norm(), WithinAbs(1.0, 1e-9));
|
||||
CHECK_THAT(Y.norm(), WithinAbs(1.0, 1e-9));
|
||||
// Pairwise dot products are ~0
|
||||
CHECK_THAT(std::abs(X.dot(Y)), WithinAbs(0.0, 1e-9));
|
||||
// Z = X x Y (derived), also unit and perpendicular
|
||||
Vec3d Z = X.cross(Y);
|
||||
CHECK_THAT(Z.norm(), WithinAbs(1.0, 1e-9));
|
||||
CHECK_THAT(std::abs(X.dot(Z)), WithinAbs(0.0, 1e-9));
|
||||
CHECK_THAT(std::abs(Y.dot(Z)), WithinAbs(0.0, 1e-9));
|
||||
// Right-handedness: X x Y == Z
|
||||
CHECK_THAT(Z.x(), WithinAbs((X.cross(Y)).x(), 1e-9));
|
||||
CHECK_THAT(Z.y(), WithinAbs((X.cross(Y)).y(), 1e-9));
|
||||
CHECK_THAT(Z.z(), WithinAbs((X.cross(Y)).z(), 1e-9));
|
||||
CHECK_THAT(X.dot(Y), WithinAbs(0.0, 1e-9));
|
||||
CHECK_THAT(X.dot(Z), WithinAbs(0.0, 1e-9));
|
||||
CHECK_THAT(Y.dot(Z), WithinAbs(0.0, 1e-9));
|
||||
CHECK_THAT(X.cross(Y).dot(Z), WithinAbs(1.0, 1e-9));
|
||||
}
|
||||
|
||||
TEST_CASE("datum coordinate system: a face-only frame rotates with its body", "[CadDocument]")
|
||||
@@ -3917,17 +3913,23 @@ TEST_CASE("Use/Convert Entities: construction references never become solid", "[
|
||||
}
|
||||
REQUIRE(top_face >= 0);
|
||||
|
||||
const size_t body_count_before = doc.bodies.size();
|
||||
const double volume_before = doc.body_mass_properties(0).volume;
|
||||
|
||||
int target = doc.add_sketch_entities({}, SketchPlane::XY(), "Use");
|
||||
int appended = doc.project_edges_into_sketch(target, 0, {}, top_face);
|
||||
REQUIRE(appended == 4);
|
||||
doc.add_extrude(target, 5.0, false, BooleanMode::New, "UseExt");
|
||||
|
||||
REQUIRE(doc.recompute());
|
||||
REQUIRE(doc.error.empty());
|
||||
REQUIRE(doc.bodies.size() == body_count_before);
|
||||
REQUIRE_THAT(doc.body_mass_properties(0).volume, WithinRel(volume_before, 1e-9));
|
||||
SECTION("construction edges form no profile, so the extrude fails") {
|
||||
REQUIRE_FALSE(doc.recompute());
|
||||
REQUIRE_FALSE(doc.error.empty());
|
||||
}
|
||||
SECTION("the same edges as solid geometry extrude a 20x20x5 body") {
|
||||
for (auto& e : doc.features[target].entities)
|
||||
e.construction = false;
|
||||
REQUIRE(doc.recompute());
|
||||
REQUIRE(doc.error.empty());
|
||||
REQUIRE(doc.bodies.size() == 2);
|
||||
REQUIRE_THAT(doc.body_mass_properties(1).volume, WithinRel(20.0 * 20.0 * 5.0, 1e-6));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Use/Convert Entities: bad references return -1 and change nothing", "[CadDocument][project]")
|
||||
@@ -4252,7 +4254,7 @@ TEST_CASE("a previous-format recipe is refused, not silently misread", "[CadDocu
|
||||
REQUIRE(doc.features.empty()); // nothing half-read was left behind
|
||||
}
|
||||
|
||||
TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]")
|
||||
TEST_CASE("a v4 recipe keeps every field value", "[CadDocument]")
|
||||
{
|
||||
using Catch::Matchers::WithinRel;
|
||||
using Catch::Matchers::WithinAbs;
|
||||
@@ -4282,20 +4284,16 @@ TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]")
|
||||
}
|
||||
|
||||
CadDocument expected = make_golden_doc_v1();
|
||||
// Scoped tightly: this advice is ONLY valid for a count mismatch. It must not be in
|
||||
// scope for the field-value assertions below, where "regenerate the fixture" is the
|
||||
// one thing you must never do -- regenerating after a reorder bakes the corrupted
|
||||
// layout in as the new golden and permanently disarms this test.
|
||||
{
|
||||
INFO("Feature count changed - did you add/remove features in make_golden_doc_v1()?");
|
||||
INFO("If so: run libslic3r_tests \"[.regen]\" and re-run this test.");
|
||||
INFO("Feature count changed - make_golden_doc_v1() must keep building exactly the tree "
|
||||
"frozen in cad_recipe_v4.bin, so do not add or remove features there.");
|
||||
REQUIRE(features.size() == expected.features.size());
|
||||
}
|
||||
|
||||
// A failure BELOW this point means the on-disk serialization format changed: some field
|
||||
// in CadFeature::save/load was reordered, retyped, or removed. Fields may only ever be
|
||||
// APPENDED at the end of both lists. Do NOT regenerate the fixture to make this pass --
|
||||
// fix the field order instead. See scripts/CAD/run-kernel-tests.sh and the [.regen] case.
|
||||
// fix the field order instead.
|
||||
|
||||
// Field-by-field assertions against expected values.
|
||||
// Every field set to a distinctive non-default literal must be checked here.
|
||||
@@ -4550,34 +4548,48 @@ TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Layer 2: geometry check (optional — only if the document recomputes) ---
|
||||
// Build expected document and try to recompute it.
|
||||
// ponytail: the field-value layer above is the real tripwire;
|
||||
// this layer is a bonus sanity check on the full recompute path.
|
||||
// --- Layer 2: the production v4 load rebuilds the same geometry as the in-memory tree ---
|
||||
// The golden tree is serialization coverage and does not recompute as a whole: these
|
||||
// features fail on the geometry they get, so they are left off in both trees.
|
||||
const std::set<std::string> unbuildable{"Fillet_Lat35", "Chamfer_Top2", "Draft_F3", "Shell_T1375",
|
||||
"GoldenProject"};
|
||||
CadDocument exp_doc = make_golden_doc_v1();
|
||||
bool exp_ok = exp_doc.recompute();
|
||||
if (!exp_ok) {
|
||||
INFO("Expected document from make_golden_doc_v1() could not recompute "
|
||||
"(complex feature tree). Field-value checks above are sufficient.");
|
||||
// Fields appended after v4 are not in the file, so a v4 project reads them as defaults
|
||||
// (a v4 thread, for one, is not major-nominal). The reference has to match that.
|
||||
const CadFeature def;
|
||||
for (CadFeature& f : exp_doc.features) {
|
||||
f.thread_major_nominal = def.thread_major_nominal;
|
||||
f.pattern_inclusive = def.pattern_inclusive;
|
||||
f.dressup_edges = def.dressup_edges;
|
||||
f.text_string = def.text_string;
|
||||
f.text_font = def.text_font;
|
||||
f.text_height = def.text_height;
|
||||
f.revolve_axis_entity = def.revolve_axis_entity;
|
||||
}
|
||||
|
||||
CadDocument doc;
|
||||
bool ser_ok = doc.deserialize_recipe(blob);
|
||||
|
||||
if (exp_ok && ser_ok) {
|
||||
REQUIRE(doc.error.empty());
|
||||
REQUIRE(doc.bodies.size() == exp_doc.bodies.size());
|
||||
for (size_t i = 0; i < doc.bodies.size(); ++i) {
|
||||
double v = double(SketchEngine::tessellate(doc.bodies[i].shape).volume());
|
||||
double ev = double(SketchEngine::tessellate(exp_doc.bodies[i].shape).volume());
|
||||
REQUIRE_THAT(v, WithinRel(ev, 1e-6));
|
||||
doc.deserialize_recipe(blob);
|
||||
REQUIRE(doc.features.size() == exp_doc.features.size());
|
||||
for (size_t i = 0; i < exp_doc.features.size(); ++i)
|
||||
exp_doc.features[i].enabled = doc.features[i].enabled = unbuildable.count(exp_doc.features[i].name) == 0;
|
||||
const bool exp_built = exp_doc.recompute();
|
||||
INFO("reference error: " << exp_doc.error);
|
||||
REQUIRE(exp_built);
|
||||
const bool built = doc.recompute();
|
||||
INFO("loaded error: " << doc.error);
|
||||
REQUIRE(built);
|
||||
REQUIRE_FALSE(exp_doc.bodies.empty());
|
||||
REQUIRE(doc.bodies.size() == exp_doc.bodies.size());
|
||||
for (size_t b = 0; b < doc.bodies.size(); ++b) {
|
||||
INFO("body " << b);
|
||||
TriangleMesh mesh = SketchEngine::tessellate(doc.bodies[b].shape);
|
||||
TriangleMesh exp_mesh = SketchEngine::tessellate(exp_doc.bodies[b].shape);
|
||||
CHECK_THAT(double(mesh.volume()), WithinRel(double(exp_mesh.volume()), 1e-6));
|
||||
// Volume alone is blind to fields that only place a body, such as a transform.
|
||||
const BoundingBoxf3 bb = mesh.bounding_box(), exp_bb = exp_mesh.bounding_box();
|
||||
for (int k = 0; k < 3; ++k) {
|
||||
CHECK_THAT(bb.min[k], WithinAbs(exp_bb.min[k], 1e-6));
|
||||
CHECK_THAT(bb.max[k], WithinAbs(exp_bb.max[k], 1e-6));
|
||||
}
|
||||
} else {
|
||||
INFO("The golden recipe fixture was loaded and field-value checks passed.");
|
||||
INFO("Recompute on the deserialized or expected document failed — this is");
|
||||
INFO("expected for the extended golden fixture (many feature types coexist");
|
||||
INFO("purely for serialization coverage). The field-value tripwire above is");
|
||||
INFO("the primary format check.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5781,8 +5793,9 @@ TEST_CASE("fastened mate with angle", "[CadDocument][mate]")
|
||||
REQUIRE(doc.recompute());
|
||||
REQUIRE(doc.error.empty());
|
||||
|
||||
int sk2 = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl");
|
||||
doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "CylExt");
|
||||
// A 20x10 plate, so a quarter turn about the connector's Z swaps its X and Y extents.
|
||||
int sk2 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "Plate");
|
||||
doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "PlateExt");
|
||||
REQUIRE(doc.recompute());
|
||||
REQUIRE(doc.error.empty());
|
||||
|
||||
@@ -5801,6 +5814,13 @@ TEST_CASE("fastened mate with angle", "[CadDocument][mate]")
|
||||
gp_Pnt com = props.CentreOfMass();
|
||||
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
|
||||
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
|
||||
|
||||
Bnd_Box bb;
|
||||
BRepBndLib::Add(doc.bodies[1].shape, bb);
|
||||
double x0, y0, z0, x1, y1, z1;
|
||||
bb.Get(x0, y0, z0, x1, y1, z1);
|
||||
REQUIRE_THAT(x1 - x0, WithinAbs(10.0, 1e-3));
|
||||
REQUIRE_THAT(y1 - y0, WithinAbs(20.0, 1e-3));
|
||||
}
|
||||
|
||||
TEST_CASE("fastened mate with flip", "[CadDocument][mate]")
|
||||
@@ -5823,7 +5843,9 @@ TEST_CASE("fastened mate with flip", "[CadDocument][mate]")
|
||||
REQUIRE(doc.recompute());
|
||||
REQUIRE(doc.error.empty());
|
||||
|
||||
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Moving");
|
||||
// The connector sits on the cylinder's base, so the flip decides which side of the
|
||||
// fixed connector (z = 5) the 10 mm cylinder hangs: above it unflipped, below it flipped.
|
||||
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 0), "CS_Moving");
|
||||
doc.features[cs_moving].coordsys_body = 1;
|
||||
REQUIRE(doc.recompute());
|
||||
REQUIRE(doc.error.empty());
|
||||
@@ -5837,8 +5859,7 @@ TEST_CASE("fastened mate with flip", "[CadDocument][mate]")
|
||||
gp_Pnt com = props.CentreOfMass();
|
||||
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
|
||||
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
|
||||
// Flip opposes Z axes: for a symmetric cylinder this is invisible in centroid,
|
||||
// but the mate executed cleanly and the body moved to the target connector.
|
||||
REQUIRE_THAT(double(com.Z()), WithinAbs(5.0 - 10.0 / 2, 1e-4));
|
||||
}
|
||||
|
||||
TEST_CASE("planar mate: normal distance becomes mate_offset", "[CadDocument][mate]")
|
||||
@@ -6525,23 +6546,19 @@ TEST_CASE("fastened mate with flip on asymmetric body", "[CadDocument][mate]")
|
||||
REQUIRE(doc.recompute());
|
||||
REQUIRE(doc.error.empty());
|
||||
|
||||
// Fastened, NO flip
|
||||
doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, false, "MateNoFlip");
|
||||
REQUIRE(doc.recompute());
|
||||
REQUIRE(doc.error.empty());
|
||||
GProp_GProps nf_props;
|
||||
BRepGProp::VolumeProperties(doc.bodies[1].shape, nf_props);
|
||||
double nf_z = nf_props.CentreOfMass().Z();
|
||||
|
||||
doc.undo();
|
||||
|
||||
// Fastened, WITH flip
|
||||
doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, true, "MateFlip");
|
||||
REQUIRE(doc.recompute());
|
||||
REQUIRE(doc.error.empty());
|
||||
GProp_GProps f_props;
|
||||
BRepGProp::VolumeProperties(doc.bodies[1].shape, f_props);
|
||||
double f_z = f_props.CentreOfMass().Z();
|
||||
// The same assembly mated once without and once with flip, each in its own copy.
|
||||
auto mated_com_z = [&](bool flip) {
|
||||
CadDocument d = doc;
|
||||
d.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, flip, "Mate");
|
||||
REQUIRE(d.recompute());
|
||||
REQUIRE(d.error.empty());
|
||||
REQUIRE(d.mate_conflicts.empty());
|
||||
GProp_GProps props;
|
||||
BRepGProp::VolumeProperties(d.bodies[1].shape, props);
|
||||
return props.CentreOfMass().Z();
|
||||
};
|
||||
const double nf_z = mated_com_z(false);
|
||||
const double f_z = mated_com_z(true);
|
||||
|
||||
// Flip changes the centroid Z for an asymmetric body
|
||||
REQUIRE_THAT(f_z, !WithinAbs(nf_z, 1e-4));
|
||||
@@ -7324,13 +7341,22 @@ TEST_CASE("interference: sheet bodies are skipped", "[CadDocument][interference]
|
||||
CadDocument doc;
|
||||
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
|
||||
doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA");
|
||||
// A sheet passing straight through the solid: it has no volume, so no interference.
|
||||
int sk_s = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 40, 40, 0, "Sheet");
|
||||
// Four 10x5 walls standing inside the 20x20x10 solid: a sheet has no volume, so no interference.
|
||||
int sk_s = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Sheet");
|
||||
doc.add_surface_extrude(sk_s, 5.0, "SE");
|
||||
REQUIRE(doc.recompute());
|
||||
REQUIRE(doc.bodies.size() == 2);
|
||||
REQUIRE(CadDocument::is_sheet_shape(doc.bodies[1].shape));
|
||||
|
||||
// The raw overlap of the walls with the solid does measure a volume, so only the sheet
|
||||
// skip keeps it out of the report.
|
||||
BRepAlgoAPI_Common common(doc.bodies[0].shape, doc.bodies[1].shape);
|
||||
common.Build();
|
||||
REQUIRE(common.IsDone());
|
||||
GProp_GProps props;
|
||||
BRepGProp::VolumeProperties(common.Shape(), props);
|
||||
REQUIRE(std::abs(props.Mass()) > 1e-6);
|
||||
|
||||
REQUIRE(doc.check_interference().empty());
|
||||
}
|
||||
|
||||
@@ -7540,9 +7566,8 @@ TEST_CASE("A cut that removes no material is an error, not a silent success", "[
|
||||
CHECK(doc.error.find("removed no material") != std::string::npos);
|
||||
|
||||
// And the body is left as it was, not half-applied.
|
||||
CadDocument again = box();
|
||||
REQUIRE(again.recompute());
|
||||
CHECK_THAT(again.body_mass_properties(0).volume,
|
||||
REQUIRE(doc.bodies.size() == 1);
|
||||
CHECK_THAT(doc.body_mass_properties(0).volume,
|
||||
Catch::Matchers::WithinAbs(solid, 1e-6));
|
||||
}
|
||||
|
||||
|
||||
@@ -75,11 +75,10 @@ SCENARIO("Config accessor functions perform as expected.", "[Config]") {
|
||||
}
|
||||
}
|
||||
WHEN("A boolean option is set to a string value representing something other than 0 or 1") {
|
||||
THEN("A BadOptionTypeException exception is thrown.") {
|
||||
config.set("gcode_comments", true);
|
||||
THEN("A BadOptionTypeException exception is thrown and the value is unchanged.") {
|
||||
REQUIRE_THROWS_AS(config.set("gcode_comments", "Z"), BadOptionTypeException);
|
||||
}
|
||||
AND_THEN("Value is unchanged.") {
|
||||
REQUIRE(config.opt<ConfigOptionBool>("gcode_comments")->getBool() == false);
|
||||
REQUIRE(config.opt<ConfigOptionBool>("gcode_comments")->getBool() == true);
|
||||
}
|
||||
}
|
||||
WHEN("A boolean option is set to an int value") {
|
||||
@@ -117,12 +116,10 @@ SCENARIO("Config accessor functions perform as expected.", "[Config]") {
|
||||
}
|
||||
}
|
||||
WHEN("A numeric option is set to a non-numeric value.") {
|
||||
auto prev_value = config.opt<ConfigOptionFloat>("max_bridge_length")->getFloat();
|
||||
THEN("A BadOptionTypeException exception is thrown.") {
|
||||
config.set("max_bridge_length", 7.5);
|
||||
THEN("A BadOptionValueException exception is thrown and the value does not change.") {
|
||||
REQUIRE_THROWS_AS(config.set_deserialize_strict("max_bridge_length", "zzzz"), BadOptionValueException);
|
||||
}
|
||||
THEN("The value does not change.") {
|
||||
REQUIRE(config.opt<ConfigOptionFloat>("max_bridge_length")->getFloat() == prev_value);
|
||||
REQUIRE_THAT(config.opt<ConfigOptionFloat>("max_bridge_length")->getFloat(), Catch::Matchers::WithinAbs(7.5, 1e-9));
|
||||
}
|
||||
}
|
||||
WHEN("A string option is set through the string interface") {
|
||||
@@ -198,7 +195,7 @@ SCENARIO("Config accessor functions perform as expected.", "[Config]") {
|
||||
}
|
||||
}
|
||||
WHEN("An invalid option is requested during set.") {
|
||||
THEN("A BadOptionTypeException exception is thrown.") {
|
||||
THEN("A UnknownOptionException exception is thrown.") {
|
||||
REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", 1), UnknownOptionException);
|
||||
REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", 1.0), UnknownOptionException);
|
||||
REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", "1"), UnknownOptionException);
|
||||
|
||||
@@ -65,41 +65,25 @@ TEST_CASE("expand_mixed_filaments replaces mixed slots with their components", "
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("belt purge tower island count ignores virtual mixed slots", "[FilamentMixer][belt]")
|
||||
TEST_CASE("A mixed slot whose components are already used adds no filaments", "[FilamentMixer][belt]")
|
||||
{
|
||||
// Regression for the "extra purge tower" on a belt printer with a mixed
|
||||
// filament (MCTEST5). The belt purge prism is sized as
|
||||
// n_islands = used_filaments.size() - 1
|
||||
// and GUI::ensure_belt_purge_tower() collected those filaments straight off
|
||||
// the model objects' extruder assignments. A mixed slot is VIRTUAL -- no
|
||||
// nozzle carries it, and ToolOrdering::resolve_mixed_filaments() replaces it
|
||||
// with its components before any G-code is emitted -- so counting it as a
|
||||
// filament of its own provisions one island that can never be reached.
|
||||
//
|
||||
// MCTEST5: five cubes on extruders 1..5, where filament 5 is a 50/50 blend of
|
||||
// filaments 2 and 4. The G-code uses only T0..T3 and reports
|
||||
// "filament used [g] = 53.35, 141.11, 40.84, 107.23, 0.00" -- filament 5
|
||||
// consumes nothing, exactly as a virtual slot should.
|
||||
// The belt purge tower sizes its islands from the expanded set. A mixed slot is virtual: no
|
||||
// nozzle carries it and ToolOrdering::resolve_mixed_filaments() replaces it with its
|
||||
// components, so it must not count as a filament of its own.
|
||||
const std::vector<unsigned char> is_mixed = {0, 0, 0, 0, 1};
|
||||
const std::vector<std::string> comp_strs = {"", "", "", "", "2,4"};
|
||||
|
||||
// The set the sizer used to see: slots 0..4 (filaments 1..5).
|
||||
const std::vector<unsigned int> assigned = {0, 1, 2, 3, 4};
|
||||
const auto physical = expand_mixed_filaments(assigned, is_mixed, comp_strs);
|
||||
|
||||
// Slot 4 dissolves into 1 and 3, which are already present.
|
||||
REQUIRE(physical == std::vector<unsigned int>({0, 1, 2, 3}));
|
||||
|
||||
// Four physical filaments => three transitions => three islands, not four.
|
||||
REQUIRE(int(physical.size()) - 1 == 3);
|
||||
REQUIRE(int(assigned.size()) - 1 == 4); // what it produced before the fix
|
||||
|
||||
SECTION("A mixed slot whose components are otherwise unused still counts them") {
|
||||
// Only the mixed slot is assigned: it must still yield its two components,
|
||||
// i.e. one island, rather than collapsing to zero.
|
||||
// Only the mixed slot is assigned: it must still yield its two components rather than
|
||||
// collapsing to nothing.
|
||||
const auto only_mixed = expand_mixed_filaments({4}, is_mixed, comp_strs);
|
||||
REQUIRE(only_mixed == std::vector<unsigned int>({1, 3}));
|
||||
REQUIRE(int(only_mixed.size()) - 1 == 1);
|
||||
}
|
||||
|
||||
SECTION("No mixed filaments anywhere leaves the set untouched") {
|
||||
|
||||
@@ -1334,12 +1334,17 @@ TEST_CASE("Renamed printer/process names are normalized into compatible lists on
|
||||
CHECK(compatible_list(bundle.filaments, "My Filament", "compatible_printers") ==
|
||||
(std::vector<std::string>{ "New Printer", "Unknown Printer" }));
|
||||
|
||||
// Normalizing rewrites config in place without flagging the preset dirty.
|
||||
CHECK_FALSE(bundle.prints.find_preset("My Process", false, true)->is_dirty);
|
||||
// Normalization runs before selection, so selecting copies the rewritten list into the edited preset.
|
||||
bundle.prints.select_preset_by_name("My Process", true);
|
||||
REQUIRE(bundle.prints.get_selected_preset_name() == "My Process");
|
||||
CHECK(bundle.prints.get_edited_preset().config.option<ConfigOptionStrings>("compatible_printers")->values ==
|
||||
std::vector<std::string>{ "New Printer" });
|
||||
|
||||
// A system preset that already references the current name is left untouched (idempotent no-op).
|
||||
// Normalizing again is a no-op: the stored preset keeps the current name and still matches the
|
||||
// edited copy, so the selection is not reported modified.
|
||||
bundle.normalize_compatible_presets();
|
||||
CHECK(compatible_list(bundle.prints, "My Process", "compatible_printers") == std::vector<std::string>{ "New Printer" });
|
||||
CHECK_FALSE(bundle.prints.current_is_dirty());
|
||||
}
|
||||
|
||||
TEST_CASE("Renamed names are normalized into a SYSTEM preset's compatible lists", "[Preset][Rename]")
|
||||
@@ -1369,11 +1374,17 @@ TEST_CASE("Renamed names are normalized into a SYSTEM preset's compatible lists"
|
||||
CHECK(compatible_list(bundle.filaments, "System Filament", "compatible_prints") ==
|
||||
std::vector<std::string>{ "New Process" });
|
||||
|
||||
// The rewrite does not flag the system preset dirty, and is idempotent.
|
||||
CHECK_FALSE(bundle.filaments.find_preset("System Filament", false, true)->is_dirty);
|
||||
// Normalization runs before selection, so selecting copies the rewritten lists into the edited preset.
|
||||
bundle.filaments.select_preset_by_name("System Filament", true);
|
||||
REQUIRE(bundle.filaments.get_selected_preset_name() == "System Filament");
|
||||
CHECK(bundle.filaments.get_edited_preset().config.option<ConfigOptionStrings>("compatible_printers")->values ==
|
||||
std::vector<std::string>{ "New Printer" });
|
||||
|
||||
// Normalizing again is a no-op, so the selection is not reported modified.
|
||||
bundle.normalize_compatible_presets();
|
||||
CHECK(compatible_list(bundle.filaments, "System Filament", "compatible_printers") ==
|
||||
std::vector<std::string>{ "New Printer" });
|
||||
CHECK_FALSE(bundle.filaments.current_is_dirty());
|
||||
}
|
||||
|
||||
TEST_CASE("compatible_prints on SLA materials resolves against sla_prints, not prints", "[Preset][Rename]")
|
||||
@@ -5566,16 +5577,6 @@ TEST_CASE("Sizing down to the nozzle count plus mixes is what eats the mixed tai
|
||||
CHECK(bundle.num_mixed_filaments() == 0);
|
||||
CHECK(bundle.num_physical_filaments() == target);
|
||||
}
|
||||
|
||||
SECTION("applied as a floor, the mix is left alone") {
|
||||
if (target > bundle.filament_presets.size())
|
||||
bundle.set_num_filaments(target);
|
||||
|
||||
CHECK(bundle.filament_presets.size() == 6);
|
||||
CHECK(bundle.num_mixed_filaments() == 1);
|
||||
CHECK(bundle.is_mixed_filament(5));
|
||||
CHECK(bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values[5] == "1,2");
|
||||
}
|
||||
}
|
||||
|
||||
// After a published-3MF import relocated mixed-filament definitions, the freshly loaded
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
#include <catch2/matchers/catch_matchers.hpp>
|
||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||
#include "libslic3r/CAD/SketchImport.hpp"
|
||||
#include "libslic3r/Utils.hpp" // resources_dir
|
||||
#include "test_utils.hpp" // ScopedTemporaryFile
|
||||
|
||||
#include <boost/filesystem/path.hpp>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include "libslic3r/Point.hpp"
|
||||
@@ -78,18 +78,10 @@ TEST_CASE("transform_regions moves and scales independently", "[SketchImport]")
|
||||
|
||||
TEST_CASE("text_to_regions vectorizes glyphs with counters", "[SketchImport]")
|
||||
{
|
||||
// Locate the bundled font; resources_dir() may be unset under ctest, so
|
||||
// fall back to a cwd-relative path (tests run from the repo root).
|
||||
std::string font = resources_dir().empty()
|
||||
? std::string("resources/fonts/HarmonyOS_Sans_SC_Regular.ttf")
|
||||
: resources_dir() + "/fonts/HarmonyOS_Sans_SC_Regular.ttf";
|
||||
{
|
||||
std::ifstream probe(font);
|
||||
if (!probe.good()) {
|
||||
SUCCEED("bundled font not reachable in this environment; covered live on :10");
|
||||
return;
|
||||
}
|
||||
}
|
||||
// The bundled font, from the source tree: PROFILES_DIR is <repo>/resources/profiles.
|
||||
const std::string font =
|
||||
(boost::filesystem::path(PROFILES_DIR).parent_path() / "fonts" / "HarmonyOS_Sans_SC_Regular.ttf").string();
|
||||
REQUIRE(std::ifstream(font).good());
|
||||
|
||||
// Bad input is rejected without throwing.
|
||||
REQUIRE(text_to_regions("", 10.0, font).empty());
|
||||
|
||||
@@ -33,8 +33,9 @@ TEST_CASE("inference: cursor near a line endpoint snaps Coincident-able to it",
|
||||
TEST_CASE("inference: endpoint beats midpoint when both are in range", "[inference]")
|
||||
{
|
||||
std::vector<SketchEntity> ents = { line({0, 0}, {2, 0}) };
|
||||
// Query equidistant-ish but closer to the endpoint: endpoint tier wins regardless.
|
||||
auto s = infer_point_snap(ents, {1.9, 0.0}, 5.0);
|
||||
// Query 0.2 from the midpoint (1,0) and 0.8 from the endpoint (2,0): the endpoint tier
|
||||
// wins even though the midpoint is nearer.
|
||||
auto s = infer_point_snap(ents, {1.2, 0.0}, 5.0, /*include_origin=*/false);
|
||||
CHECK(s.kind == K::Endpoint);
|
||||
CHECK(s.role == SketchPointRole::P1);
|
||||
}
|
||||
|
||||
@@ -113,14 +113,11 @@ TEST_CASE("profile: offsetting a closed rectangle keeps it closed", "[SketchProf
|
||||
REQUIRE(closed_wires(out) == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("profile: offset outward grows the enclosed area by the right amount", "[SketchProfile]")
|
||||
TEST_CASE("profile: offsetting a closed rectangle outward keeps it one closed loop", "[SketchProfile]")
|
||||
{
|
||||
// A rectangle offset outward by d is (w+2d) x (h+2d) with the corners rounded at r=d,
|
||||
// so its area is w*h + 2d(w+h) + pi*d^2 whichever way the corners are healed... except
|
||||
// for a sharp-corner offset, which is exactly (w+2d)*(h+2d). Either healing is defensible;
|
||||
// a set of four disconnected segments is not, and that is what this measures.
|
||||
const double w = 40, h = 20, d = 5;
|
||||
const auto out = SketchEngine::offset_entities(rect(w, h), d);
|
||||
// Outward is -d for a CCW loop. The miter join reconnects the corners, so the result is one
|
||||
// closed wire, not four disconnected segments. Its area is checked by the sign test below.
|
||||
const auto out = SketchEngine::offset_entities(rect(40, 20), -5.0);
|
||||
const auto ws = SketchEngine::entities_to_wires(out, xy_plane());
|
||||
REQUIRE(ws.size() == 1);
|
||||
REQUIRE(ws[0].Closed());
|
||||
|
||||
@@ -60,9 +60,8 @@ std::vector<NozzleInfo> single_nozzle_per_extruder(int extruder_count)
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Multi-nozzle gate predicate mirrors BambuStudio", "[ToolOrdering][H2C]")
|
||||
TEST_CASE("Multi-nozzle options exist and default to one nozzle per extruder", "[ToolOrdering][H2C]")
|
||||
{
|
||||
// The multi-nozzle gate: std::any_of(extruder_max_nozzle_count > 1).
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
|
||||
auto *opt = config.option<ConfigOptionIntsNullable>("extruder_max_nozzle_count");
|
||||
@@ -72,15 +71,11 @@ TEST_CASE("Multi-nozzle gate predicate mirrors BambuStudio", "[ToolOrdering][H2C
|
||||
// 3mf projects round-trip the per-extruder nozzle inventory (GUI producers wire it later).
|
||||
REQUIRE(config.option<ConfigOptionStrings>("extruder_nozzle_stats") != nullptr);
|
||||
|
||||
auto has_multiple_nozzle = [](const std::vector<int> &values) {
|
||||
return std::any_of(values.begin(), values.end(), [](int v) { return v > 1; });
|
||||
};
|
||||
|
||||
// Default for every existing printer: 1 nozzle per extruder => gate is closed.
|
||||
REQUIRE_FALSE(has_multiple_nozzle(opt->values));
|
||||
|
||||
// Synthetic H2C-like machine: extruder 1 is a 6-nozzle cluster => gate opens.
|
||||
REQUIRE(has_multiple_nozzle(std::vector<int>{1, 6}));
|
||||
// Default for every existing printer: 1 nozzle per extruder, so the multi-nozzle gate
|
||||
// (any extruder_max_nozzle_count > 1) stays closed.
|
||||
REQUIRE_FALSE(opt->values.empty());
|
||||
for (int count : opt->values)
|
||||
CHECK(count == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("Single-nozzle grouping: every filament maps to its extruder nozzle", "[ToolOrdering][H2C]")
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
using namespace Slic3r;
|
||||
|
||||
using VD = Geometry::VoronoiDiagram;
|
||||
static bool has_missing_voronoi_vertices(const Polygons &polygons, const VD &vd);
|
||||
using BoostVD = boost::polygon::voronoi_diagram<double>;
|
||||
|
||||
// https://svn.boost.org/trac10/ticket/12067
|
||||
@@ -198,8 +199,12 @@ TEST_CASE("Voronoi missing edges - Alessandro gapfill 12707", "[Voronoi]")
|
||||
REQUIRE(intersecting_edges({ poly }).empty());
|
||||
|
||||
Lines lines = to_lines(poly);
|
||||
BoostVD vd;
|
||||
construct_voronoi(lines.begin(), lines.end(), &vd);
|
||||
VD vd;
|
||||
vd.construct_voronoi(lines.begin(), lines.end());
|
||||
// Orca's detector finds nothing wrong with this input, so no repair is needed.
|
||||
CHECK(VD::detect_known_issues(vd, lines.begin(), lines.end()) == VD::IssueType::NO_ISSUE_DETECTED);
|
||||
CHECK(vd.get_state() == VD::State::REPAIR_NOT_NEEDED);
|
||||
CHECK_FALSE(has_missing_voronoi_vertices({ poly }, vd));
|
||||
|
||||
#ifdef VORONOI_DEBUG_OUT
|
||||
dump_voronoi_to_svg(debug_out_path("voronoi-lines.svg").c_str(),
|
||||
@@ -305,9 +310,16 @@ TEST_CASE("Voronoi weirdness", "[Voronoi]")
|
||||
}
|
||||
#endif
|
||||
|
||||
BoostVD vd;
|
||||
VD vd;
|
||||
Lines lines = to_lines(poly);
|
||||
construct_voronoi(lines.begin(), lines.end(), &vd);
|
||||
vd.construct_voronoi(lines.begin(), lines.end());
|
||||
// Unrepaired, the diagram is missing a Voronoi vertex. construct_voronoi detects it and repairs it
|
||||
// by rotating the input.
|
||||
VD raw_vd;
|
||||
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
|
||||
CHECK(VD::detect_known_issues(raw_vd, lines.begin(), lines.end()) == VD::IssueType::MISSING_VORONOI_VERTEX);
|
||||
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
|
||||
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
|
||||
|
||||
#ifdef VORONOI_DEBUG_OUT
|
||||
dump_voronoi_to_svg(debug_out_path("voronoi-weirdness.svg").c_str(),
|
||||
@@ -347,9 +359,8 @@ TEST_CASE("Voronoi division by zero 12903", "[Voronoi]")
|
||||
|
||||
// https://svn.boost.org/trac10/ticket/12139
|
||||
// Funny sample from a dental industry?
|
||||
// Vojtech confirms this test fails and rightly so, because the input data contain self intersections.
|
||||
// This test is suppressed.
|
||||
TEST_CASE("Voronoi NaN coordinates 12139", "[Voronoi][.][!mayfail]")
|
||||
// The input data contain self intersections, so no valid Voronoi diagram can be built from it.
|
||||
TEST_CASE("Voronoi NaN coordinates 12139", "[Voronoi]")
|
||||
{
|
||||
Lines lines = {
|
||||
{ { 260500,1564400 }, { 261040,1562960 } },
|
||||
@@ -1327,18 +1338,13 @@ TEST_CASE("Voronoi NaN coordinates 12139", "[Voronoi][.][!mayfail]")
|
||||
}
|
||||
#endif
|
||||
|
||||
BoostVD vd;
|
||||
construct_voronoi(lines.begin(), lines.end(), &vd);
|
||||
|
||||
for (const auto& edge : vd.edges())
|
||||
if (edge.is_finite()) {
|
||||
auto v0 = edge.vertex0();
|
||||
auto v1 = edge.vertex1();
|
||||
REQUIRE((v0->x() == 0 || std::isnormal(v0->x())));
|
||||
REQUIRE((v0->y() == 0 || std::isnormal(v0->y())));
|
||||
REQUIRE((v1->x() == 0 || std::isnormal(v1->x())));
|
||||
REQUIRE((v1->y() == 0 || std::isnormal(v1->y())));
|
||||
}
|
||||
VD vd;
|
||||
vd.construct_voronoi(lines.begin(), lines.end());
|
||||
// The diagram built from this self-intersecting input has non-finite vertices, and rotating the
|
||||
// input does not repair it. It must be reported as invalid, which is what MedialAxis::build
|
||||
// checks before falling back to a morphological closing.
|
||||
CHECK(vd.get_state() == VD::State::REPAIR_UNSUCCESSFUL);
|
||||
CHECK_FALSE(vd.is_valid());
|
||||
|
||||
#ifdef VORONOI_DEBUG_OUT
|
||||
dump_voronoi_to_svg(debug_out_path("voronoi-NaNs.svg").c_str(),
|
||||
@@ -1935,7 +1941,7 @@ TEST_CASE("Voronoi skeleton", "[VoronoiSkeleton]")
|
||||
}
|
||||
|
||||
// Simple detection with complexity N^2 if there is any point in the input polygons that doesn't have Voronoi vertex.
|
||||
[[maybe_unused]] static bool has_missing_voronoi_vertices(const Polygons &polygons, const VD &vd)
|
||||
static bool has_missing_voronoi_vertices(const Polygons &polygons, const VD &vd)
|
||||
{
|
||||
auto are_equal = [](const VD::vertex_type v, const Point &p) { return (Vec2d(v.x(), v.y()) - p.cast<double>()).norm() <= SCALED_EPSILON; };
|
||||
|
||||
@@ -1957,7 +1963,7 @@ TEST_CASE("Voronoi skeleton", "[VoronoiSkeleton]")
|
||||
// into two parts. Even we add more points to the edge, and then for these points, the Voronoin vertex is also missing. An
|
||||
// infinity-edge passes through the missing Voronoi vertex. Therefore, this missing Voronoi vertex and edge can be reconstructed
|
||||
// using the intersection between the infinity-edge with the input polygon.
|
||||
// Rotation of the polygon solves this problem.
|
||||
// VoronoiDiagram::construct_voronoi repairs it by rotating the input.
|
||||
TEST_CASE("Voronoi missing vertex 1", "[VoronoiMissingVertex1]")
|
||||
{
|
||||
Polygon poly = {
|
||||
@@ -1981,7 +1987,14 @@ TEST_CASE("Voronoi missing vertex 1", "[VoronoiMissingVertex1]")
|
||||
dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex1-out.svg").c_str(), vd, Points(), lines);
|
||||
#endif
|
||||
|
||||
// REQUIRE(!has_missing_voronoi_vertices({poly}, vd));
|
||||
// Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
|
||||
// rotating the input.
|
||||
VD raw_vd;
|
||||
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
|
||||
CHECK(has_missing_voronoi_vertices({poly}, raw_vd));
|
||||
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
|
||||
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
|
||||
CHECK_FALSE(has_missing_voronoi_vertices({poly}, vd));
|
||||
}
|
||||
|
||||
// This case is composed of two square polygons (contour and hole), and again one of the edges is divided into two parts by a
|
||||
@@ -1989,7 +2002,7 @@ TEST_CASE("Voronoi missing vertex 1", "[VoronoiMissingVertex1]")
|
||||
// that for this case, through the missing Voronoi vertex is passing a finite edge between two internal Voronoin vertices.
|
||||
// Therefore, this missing Voronoi vertex and edge can be reconstructed using the intersection between the finite edge with the
|
||||
// input polygon.
|
||||
// Rotation of the polygons solves this problem.
|
||||
// VoronoiDiagram::construct_voronoi repairs it by rotating the input.
|
||||
TEST_CASE("Voronoi missing vertex 2", "[VoronoiMissingVertex2]")
|
||||
{
|
||||
Polygons poly = {
|
||||
@@ -2021,13 +2034,20 @@ TEST_CASE("Voronoi missing vertex 2", "[VoronoiMissingVertex2]")
|
||||
dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex2-out.svg").c_str(), vd, Points(), lines);
|
||||
#endif
|
||||
|
||||
// REQUIRE(!has_missing_voronoi_vertices(poly, vd));
|
||||
// Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
|
||||
// rotating the input.
|
||||
VD raw_vd;
|
||||
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
|
||||
CHECK(has_missing_voronoi_vertices(poly, raw_vd));
|
||||
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
|
||||
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
|
||||
CHECK_FALSE(has_missing_voronoi_vertices(poly, vd));
|
||||
}
|
||||
|
||||
// This case is composed of two polygons, and again one of the edges is divided into two parts by a point that lies on this edge,
|
||||
// and for this point is Voronoi vertex missing. A difference between the previous cases and this case through the missing
|
||||
// Voronoi vertex is passing finite edge between one inner Voronoi vertex and one outer Voronoi vertex.
|
||||
// Rotating the polygon also help solve this problem.
|
||||
// VoronoiDiagram::construct_voronoi repairs it by rotating the input.
|
||||
TEST_CASE("Voronoi missing vertex 3", "[VoronoiMissingVertex3]")
|
||||
{
|
||||
Polygons poly = {
|
||||
@@ -2055,20 +2075,27 @@ TEST_CASE("Voronoi missing vertex 3", "[VoronoiMissingVertex3]")
|
||||
// polygons_rotate(poly, PI/180);
|
||||
// polygons_rotate(poly, PI/6);
|
||||
|
||||
BoostVD vd;
|
||||
VD vd;
|
||||
Lines lines = to_lines(poly);
|
||||
construct_voronoi(lines.begin(), lines.end(), &vd);
|
||||
vd.construct_voronoi(lines.begin(), lines.end());
|
||||
#ifdef VORONOI_DEBUG_OUT
|
||||
dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex3-out.svg").c_str(), vd, Points(), lines);
|
||||
#endif
|
||||
|
||||
// REQUIRE(!has_missing_voronoi_vertices(poly, vd));
|
||||
// Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
|
||||
// rotating the input.
|
||||
VD raw_vd;
|
||||
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
|
||||
CHECK(has_missing_voronoi_vertices(poly, raw_vd));
|
||||
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
|
||||
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
|
||||
CHECK_FALSE(has_missing_voronoi_vertices(poly, vd));
|
||||
}
|
||||
|
||||
// In this case, the Voronoi vertex (146873, -146873) is included twice.
|
||||
// Also, near to those duplicate Voronoi vertices is another Voronoi vertex (146872, -146872).
|
||||
// Rotating the polygon will help solve this problem, but then there arise three very close Voronoi vertices.
|
||||
// Rotating of the input polygon will help solve this problem.
|
||||
// VoronoiDiagram::construct_voronoi repairs it by rotating the input.
|
||||
TEST_CASE("Duplicate Voronoi vertices", "[Voronoi]")
|
||||
{
|
||||
Polygon poly = {
|
||||
@@ -2095,7 +2122,7 @@ TEST_CASE("Duplicate Voronoi vertices", "[Voronoi]")
|
||||
dump_voronoi_to_svg(debug_out_path("voronoi-duplicate-vertices-out.svg").c_str(), vd, Points(), lines);
|
||||
#endif
|
||||
|
||||
[[maybe_unused]] auto has_duplicate_vertices = [](const VD &vd) -> bool {
|
||||
auto has_duplicate_vertices = [](const VD &vd) -> bool {
|
||||
std::vector<Vec2d> vertices;
|
||||
for (const auto &vertex : vd.vertices())
|
||||
vertices.emplace_back(Vec2d(vertex.x(), vertex.y()));
|
||||
@@ -2104,14 +2131,21 @@ TEST_CASE("Duplicate Voronoi vertices", "[Voronoi]")
|
||||
return std::unique(vertices.begin(), vertices.end()) != vertices.end();
|
||||
};
|
||||
|
||||
// REQUIRE(!has_duplicate_vertices(vd));
|
||||
// Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
|
||||
// rotating the input.
|
||||
VD raw_vd;
|
||||
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
|
||||
CHECK(has_duplicate_vertices(raw_vd));
|
||||
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
|
||||
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
|
||||
CHECK_FALSE(has_duplicate_vertices(vd));
|
||||
}
|
||||
|
||||
// In this case, there are three very close Voronoi vertices like in the previous test case after rotation. There is also one
|
||||
// missing Voronoi vertex. One infinity-edge (after clip [(146872, -70146871), (146872, -146871)]) passes through this missing
|
||||
// Voronoi vertex. This infinite edge [(146872, -70146871), (146872, -146871)] and edge [(146873, -146873), (0, 0)] are intersecting.
|
||||
// They intersect probably because the three points are very close to each other, with a combination of the missing Voronoi vertex.
|
||||
// Rotating of the input polygon will help solve this problem.
|
||||
// VoronoiDiagram::construct_voronoi repairs it by rotating the input.
|
||||
TEST_CASE("Intersecting Voronoi edges", "[Voronoi]")
|
||||
{
|
||||
Polygon poly = {
|
||||
@@ -2135,7 +2169,7 @@ TEST_CASE("Intersecting Voronoi edges", "[Voronoi]")
|
||||
dump_voronoi_to_svg(debug_out_path("voronoi-intersecting-edges-out.svg").c_str(), vd, Points(), lines);
|
||||
#endif
|
||||
|
||||
[[maybe_unused]] auto has_intersecting_edges = [](const Polygon &poly, const VD &vd) -> bool {
|
||||
auto has_intersecting_edges = [](const Polygon &poly, const VD &vd) -> bool {
|
||||
BoundingBox bbox = get_extents(poly);
|
||||
const double bbox_dim_max = double(std::max(bbox.size().x(), bbox.size().y()));
|
||||
|
||||
@@ -2166,5 +2200,12 @@ TEST_CASE("Intersecting Voronoi edges", "[Voronoi]")
|
||||
return false;
|
||||
};
|
||||
|
||||
// REQUIRE(!has_intersecting_edges(poly, vd));
|
||||
// Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
|
||||
// rotating the input.
|
||||
VD raw_vd;
|
||||
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
|
||||
CHECK(has_intersecting_edges(poly, raw_vd));
|
||||
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
|
||||
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
|
||||
CHECK_FALSE(has_intersecting_edges(poly, vd));
|
||||
}
|
||||
@@ -3,13 +3,11 @@
|
||||
#include <boost/system/error_code.hpp>
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <cstddef>
|
||||
#include <libslic3r/Utils.hpp>
|
||||
#include <libslic3r/libslic3r.h> // GCODEVIEWER_APP_KEY, SLIC3R_APP_KEY (via libslic3r_version.h)
|
||||
#include <slic3r/plugin/PluginAuditManager.hpp>
|
||||
#include <slic3r/plugin/PluginFsUtils.hpp>
|
||||
#include <slic3r/plugin/PluginManager.hpp>
|
||||
#include <slic3r/plugin/probe/PluginProbeCollector.hpp>
|
||||
#include <slic3r/plugin/PythonInterpreter.hpp>
|
||||
#include <slic3r/plugin/PythonPluginBridge.hpp>
|
||||
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||
@@ -34,7 +32,6 @@
|
||||
#include <pybind11/pytypes.h>
|
||||
#include <pybind11/stl.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <fstream>
|
||||
#include <functional>
|
||||
#include <ios>
|
||||
@@ -70,29 +67,9 @@ void seed_denied_keywords()
|
||||
mgr.add_denied_path_keyword(keyword);
|
||||
}
|
||||
|
||||
// Installs the record-only observer for a test and always clears it, so a failed CHECK cannot
|
||||
// leave a callback pointing at a destroyed collector for the next test in this process.
|
||||
struct ScopedTestObservationObserver
|
||||
{
|
||||
explicit ScopedTestObservationObserver(PluginProbeCollector& collector)
|
||||
: m_collector(collector)
|
||||
{
|
||||
PluginAuditManager::instance().set_observation_observer(
|
||||
[this](const AuditObservation& observation) { m_collector.record(observation); });
|
||||
}
|
||||
|
||||
~ScopedTestObservationObserver() { PluginAuditManager::instance().clear_observation_observer(); }
|
||||
|
||||
ScopedTestObservationObserver(const ScopedTestObservationObserver&) = delete;
|
||||
ScopedTestObservationObserver& operator=(const ScopedTestObservationObserver&) = delete;
|
||||
|
||||
private:
|
||||
PluginProbeCollector& m_collector;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Plugin audit denies host-managed filenames anywhere", "[audit]")
|
||||
TEST_CASE("Plugin audit denies app config and token filenames anywhere", "[audit]")
|
||||
{
|
||||
seed_denied_names();
|
||||
const PluginAuditManager& mgr = PluginAuditManager::instance();
|
||||
@@ -104,7 +81,6 @@ TEST_CASE("Plugin audit denies host-managed filenames anywhere", "[audit]")
|
||||
CHECK(mgr.is_denied_filename(fs::path(SLIC3R_APP_KEY ".ini")));
|
||||
CHECK(mgr.is_denied_filename(fs::path(GCODEVIEWER_APP_KEY ".ini")));
|
||||
CHECK(mgr.is_denied_filename(fs::path(secret_constants::USER_SECRET_FILENAME)));
|
||||
CHECK(mgr.is_denied_filename(fs::path(".install_state.json")));
|
||||
}
|
||||
|
||||
SECTION("companions holding the same secrets are denied by the prefix rule")
|
||||
@@ -160,7 +136,6 @@ TEST_CASE("Plugin audit deny beats allowed roots", "[audit]")
|
||||
|
||||
const fs::path conf = fs::path(data_dir()) / (SLIC3R_APP_KEY ".conf");
|
||||
const fs::path token = fs::path(data_dir()) / secret_constants::USER_SECRET_FILENAME;
|
||||
const fs::path install_state = fs::path(data_dir()) / "plugins" / "example" / ".install_state.json";
|
||||
|
||||
SECTION("a non-denied file inside the allowed root is writable (root really grants writes)")
|
||||
{
|
||||
@@ -201,17 +176,6 @@ TEST_CASE("Plugin audit deny beats allowed roots", "[audit]")
|
||||
CHECK_FALSE(decision.allowed);
|
||||
}
|
||||
|
||||
SECTION("plugin install state is blocked for reads and writes despite the allowed root")
|
||||
{
|
||||
AuditDecision read_decision = mgr.check_open(install_state.string(), "r");
|
||||
CHECK_FALSE(read_decision.allowed);
|
||||
CHECK(read_decision.reason == "denied filename");
|
||||
|
||||
AuditDecision write_decision = mgr.check_open(install_state.string(), "w");
|
||||
CHECK_FALSE(write_decision.allowed);
|
||||
CHECK(write_decision.reason == "denied filename");
|
||||
}
|
||||
|
||||
SECTION("a traversal path resolving to the config is blocked")
|
||||
{
|
||||
const fs::path traversal = fs::path(data_dir()) / "plugins" / ".." / (SLIC3R_APP_KEY ".conf");
|
||||
@@ -690,71 +654,6 @@ const char* const PLUGIN = "AuditPlugin";
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Probe phase scope restores the previous phase", "[audit]")
|
||||
{
|
||||
PluginAuditManager& mgr = PluginAuditManager::instance();
|
||||
mgr.set_current_audit_phase("outer");
|
||||
{
|
||||
ScopedPluginAuditPhase inner("inner");
|
||||
CHECK(mgr.current_audit_phase() == "inner");
|
||||
}
|
||||
CHECK(mgr.current_audit_phase() == "outer");
|
||||
mgr.clear_current_audit_phase();
|
||||
CHECK(mgr.current_audit_phase().empty());
|
||||
}
|
||||
|
||||
TEST_CASE("Record-only probe observations bypass prompts and retain phase attribution", "[audit][Python]")
|
||||
{
|
||||
AuditHarness harness;
|
||||
if (!harness.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
harness.add_plugin(PLUGIN);
|
||||
py::gil_scoped_acquire gil;
|
||||
PythonSide python(harness);
|
||||
|
||||
PluginProbeCollector collector;
|
||||
ScopedTestObservationObserver observer(collector);
|
||||
|
||||
// A declared read permission is recorded and allowed, but must not be written to the sidecar.
|
||||
{
|
||||
ScopedPluginAuditContext context(PLUGIN);
|
||||
CHECK(PluginAuditManager::instance().request_filesystem_read_permissions(PLUGIN, {"/tmp/probe-declared"}));
|
||||
}
|
||||
|
||||
{
|
||||
ScopedPluginAuditPhase phase("probe:callback");
|
||||
python.run_as(PLUGIN, "outcome = attempt(lambda: open(PRIVATE).close())");
|
||||
}
|
||||
|
||||
CHECK(python.str("outcome") == "ok");
|
||||
CHECK(harness.prompts.empty());
|
||||
CHECK(harness.saved_permissions(PLUGIN).fs_read.empty());
|
||||
|
||||
const std::vector<AuditObservation> observations = collector.observations();
|
||||
const auto open_event = std::find_if(
|
||||
observations.begin(), observations.end(), [](const AuditObservation& observation) {
|
||||
return observation.event_name == "open";
|
||||
});
|
||||
REQUIRE(open_event != observations.end());
|
||||
CHECK(open_event->plugin_key == PLUGIN);
|
||||
CHECK(open_event->phase == "probe:callback");
|
||||
CHECK(open_event->category == "FsRead");
|
||||
CHECK(open_event->continued);
|
||||
CHECK(std::find(open_event->targets.begin(), open_event->targets.end(), python.str("PRIVATE")) !=
|
||||
open_event->targets.end());
|
||||
|
||||
const auto declared = std::find_if(observations.begin(), observations.end(), [](const AuditObservation& observation) {
|
||||
return observation.event_name == "orca.request_permissions";
|
||||
});
|
||||
REQUIRE(declared != observations.end());
|
||||
CHECK(declared->category == "FsRead");
|
||||
CHECK(std::find(declared->targets.begin(), declared->targets.end(), "/tmp/probe-declared") != declared->targets.end());
|
||||
|
||||
const nlohmann::json summary = collector.summary();
|
||||
CHECK(summary["categories"].contains("FsRead"));
|
||||
CHECK(summary["observed_event_count"].get<std::size_t>() == observations.size());
|
||||
}
|
||||
|
||||
TEST_CASE("A saved request grant covers the request's own connection in a later session", "[audit][Python]")
|
||||
{
|
||||
AuditHarness harness;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/catch_message.hpp>
|
||||
#include "plugin_test_utils.hpp"
|
||||
#include <pybind11/pytypes.h>
|
||||
#include <pybind11/eval.h>
|
||||
@@ -17,6 +18,12 @@
|
||||
#include <pybind11/embed.h>
|
||||
#include <pybind11/pybind11.h>
|
||||
|
||||
#include <boost/filesystem/operations.hpp>
|
||||
#include <boost/filesystem/path.hpp>
|
||||
|
||||
#include <chrono>
|
||||
#include <fstream>
|
||||
#include <ios>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <pybind11/cast.h>
|
||||
@@ -105,6 +112,72 @@ PluginCapabilityId capability_id(PluginCapabilityType type, const char* name, co
|
||||
return {type, name, plugin_key};
|
||||
}
|
||||
|
||||
// restore_config_response looks the capability up in PluginManager, so the restore tests load a real
|
||||
// plugin rather than building a detached capability.
|
||||
const char* const RESTORE_PLUGIN_KEY = "Restore_Plugin";
|
||||
|
||||
const char* const RESTORE_PLUGIN_SOURCE = R"PY(# /// script
|
||||
# requires-python = ">=3.12"
|
||||
#
|
||||
# [tool.orcaslicer.plugin]
|
||||
# name = "Restore Plugin"
|
||||
# description = "Restore defaults fixture"
|
||||
# author = "OrcaSlicer"
|
||||
# version = "1.0"
|
||||
# type = "script"
|
||||
# ///
|
||||
import orca
|
||||
|
||||
class Target(orca.script.ScriptPluginCapabilityBase):
|
||||
def get_name(self): return "Target"
|
||||
def execute(self, ctx): return orca.ExecutionResult.success()
|
||||
def get_default_config(self): return {"speed": 1}
|
||||
|
||||
class Bystander(orca.script.ScriptPluginCapabilityBase):
|
||||
def get_name(self): return "Bystander"
|
||||
def execute(self, ctx): return orca.ExecutionResult.success()
|
||||
def get_default_config(self): return {"speed": 2}
|
||||
|
||||
# Loading seeds defaults through get_default_config and fails the plugin if it raises, so this one
|
||||
# answers the load and raises from then on.
|
||||
class Raising(orca.script.ScriptPluginCapabilityBase):
|
||||
calls = 0
|
||||
def get_name(self): return "Raising"
|
||||
def execute(self, ctx): return orca.ExecutionResult.success()
|
||||
def get_default_config(self):
|
||||
Raising.calls += 1
|
||||
if Raising.calls > 1:
|
||||
raise RuntimeError("boom")
|
||||
return {}
|
||||
|
||||
@orca.plugin
|
||||
class RestorePackage(orca.base):
|
||||
def register_capabilities(self):
|
||||
orca.register_capability(Target)
|
||||
orca.register_capability(Bystander)
|
||||
orca.register_capability(Raising)
|
||||
)PY";
|
||||
|
||||
// Writes the restore fixture into {data_dir}/orca_plugins and loads it, returning the load error
|
||||
// (empty on success). Call without holding the GIL: the load runs on a worker thread.
|
||||
std::string load_restore_plugin(const ScopedDataDir& data_dir_guard)
|
||||
{
|
||||
const boost::filesystem::path plugin_dir = data_dir_guard.plugins_dir() / RESTORE_PLUGIN_KEY;
|
||||
boost::filesystem::create_directories(plugin_dir);
|
||||
{
|
||||
std::ofstream out((plugin_dir / (std::string(RESTORE_PLUGIN_KEY) + ".py")).string(), std::ios::binary);
|
||||
out << RESTORE_PLUGIN_SOURCE;
|
||||
}
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
manager.load_plugin(RESTORE_PLUGIN_KEY, /*skip_deps=*/true);
|
||||
std::string error;
|
||||
if (!manager.wait_for_plugin_load(RESTORE_PLUGIN_KEY, std::chrono::seconds(120), error) && error.empty())
|
||||
error = "the plugin did not load";
|
||||
return error;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Capability config API is exposed on every Python capability", "[PluginConfig][Python]")
|
||||
@@ -357,26 +430,27 @@ TEST_CASE("Restoring defaults overwrites only the target capability", "[PluginCo
|
||||
ScopedPluginManager plugin_system; // declared first: destroyed last
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down
|
||||
|
||||
ScopedDataDir data_dir_guard("plugin-config-py-restore");
|
||||
host_config().load();
|
||||
const std::string load_error = load_restore_plugin(data_dir_guard);
|
||||
INFO("load error: " << load_error);
|
||||
REQUIRE(load_error.empty());
|
||||
|
||||
const std::string defaults_body = " def get_name(self): return 'cap'\n"
|
||||
" def get_default_config(self): return {'speed': 1}\n";
|
||||
py::object target = make_capability("RestoreTargetCap", defaults_body, "plugin_a", "cap_a");
|
||||
py::object bystander = make_capability("RestoreBystanderCap", defaults_body, "plugin_b", "cap_a");
|
||||
|
||||
const PluginCapabilityId target = capability_id(PluginCapabilityType::Script, "Target", RESTORE_PLUGIN_KEY);
|
||||
const PluginCapabilityId bystander = capability_id(PluginCapabilityType::Script, "Bystander", RESTORE_PLUGIN_KEY);
|
||||
const json edited = json{{"speed", 99}};
|
||||
REQUIRE(py_save_config(target, edited));
|
||||
REQUIRE(py_save_config(bystander, edited));
|
||||
REQUIRE(host_config().store_capability_config(target, edited));
|
||||
REQUIRE(host_config().store_capability_config(bystander, edited));
|
||||
|
||||
// What PluginsDialog::restore_capability_config does: ask the capability, store the answer.
|
||||
auto iface = as_interface(target);
|
||||
REQUIRE(host_config().store_capability_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"), iface->get_default_config()));
|
||||
const json response = PluginConfig::restore_config_response(target);
|
||||
|
||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"speed", 1}});
|
||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_b"))->config == json{{"speed", 99}});
|
||||
CHECK(response["ok"] == true);
|
||||
CHECK(response["config"] == json{{"speed", 1}});
|
||||
CHECK(host_config().get_config(target)->config == json{{"speed", 1}});
|
||||
CHECK(host_config().get_config(bystander)->config == edited);
|
||||
|
||||
PluginManager::instance().unload_plugin(RESTORE_PLUGIN_KEY);
|
||||
}
|
||||
|
||||
TEST_CASE("A raising get_default_config leaves the stored config untouched", "[PluginConfig][Python]")
|
||||
@@ -384,24 +458,23 @@ TEST_CASE("A raising get_default_config leaves the stored config untouched", "[P
|
||||
ScopedPluginManager plugin_system; // declared first: destroyed last
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down
|
||||
|
||||
ScopedDataDir data_dir_guard("plugin-config-py-restore-raise");
|
||||
host_config().load();
|
||||
const std::string load_error = load_restore_plugin(data_dir_guard);
|
||||
INFO("load error: " << load_error);
|
||||
REQUIRE(load_error.empty());
|
||||
|
||||
py::object cap = make_capability("RaisingDefaultsCap",
|
||||
" def get_name(self): return 'cap_a'\n"
|
||||
" def get_default_config(self): raise RuntimeError('boom')\n",
|
||||
"plugin_a", "cap_a");
|
||||
const PluginCapabilityId raising = capability_id(PluginCapabilityType::Script, "Raising", RESTORE_PLUGIN_KEY);
|
||||
REQUIRE(host_config().store_capability_config(raising, json{{"keep", "me"}}));
|
||||
|
||||
REQUIRE(py_save_config(cap, json{{"keep", "me"}}));
|
||||
const json response = PluginConfig::restore_config_response(raising);
|
||||
|
||||
auto iface = as_interface(cap);
|
||||
REQUIRE(iface);
|
||||
CHECK_THROWS_AS(iface->get_default_config(), py::error_already_set);
|
||||
CHECK(response["ok"] == false);
|
||||
CHECK_FALSE(response["error"].get<std::string>().empty());
|
||||
CHECK(host_config().get_config(raising)->config == json{{"keep", "me"}});
|
||||
|
||||
// The dialog stores nothing when the hook throws: a broken plugin must not wipe user settings.
|
||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"keep", "me"}});
|
||||
PluginManager::instance().unload_plugin(RESTORE_PLUGIN_KEY);
|
||||
}
|
||||
|
||||
TEST_CASE("A raising config UI hook surfaces as an exception the host can catch", "[PluginConfig][Python]")
|
||||
|
||||
@@ -76,12 +76,12 @@ TEST_CASE("plugin latest version uses the authoritative catalog field", "[Plugin
|
||||
{
|
||||
PluginDescriptor descriptor;
|
||||
descriptor.version = "1.3.0";
|
||||
descriptor.latest_version = "1.3.0";
|
||||
descriptor.latest_version = "1.4.0";
|
||||
PluginChangelog changelog;
|
||||
changelog.version = "1.2.0";
|
||||
descriptor.changelog.push_back(changelog);
|
||||
|
||||
CHECK(descriptor.latest_available_version() == "1.3.0");
|
||||
CHECK(descriptor.latest_available_version() == "1.4.0");
|
||||
}
|
||||
|
||||
TEST_CASE("plugin latest version falls back to the descriptor version", "[PluginDescriptor]")
|
||||
|
||||
@@ -615,12 +615,19 @@ TEST_CASE("orca.host: SurfaceCollection.set mutates geometry; lslices via make_s
|
||||
.attr("expolygon").attr("contour").attr("as_array")().cast<py::array>();
|
||||
CHECK(c.shape(0) == 4);
|
||||
|
||||
// lslices are derived: make_slices() re-derives them + refreshes the bbox cache.
|
||||
// lslices are derived: make_slices() re-derives them from the region slices and refreshes the
|
||||
// bbox cache.
|
||||
TestLayer layer;
|
||||
layer.add_region(nullptr);
|
||||
py::object ly = py::cast(static_cast<Slic3r::Layer*>(&layer), py::return_value_policy::reference);
|
||||
// (A hand-built layer has no regions, so make_slices() yields empty lslices — still null-safe.)
|
||||
ly.attr("regions")().cast<py::list>()[0].attr("slices").attr("set")(expolys, ST.attr("stInternalSolid"));
|
||||
REQUIRE(layer.lslices.empty());
|
||||
ly.attr("make_slices")();
|
||||
CHECK(layer.lslices_bboxes.size() == layer.lslices.size());
|
||||
REQUIRE(layer.lslices.size() == 1);
|
||||
CHECK_THAT(layer.lslices.front().area(), WithinRel((double) s * (double) s, 1e-9));
|
||||
REQUIRE(layer.lslices_bboxes.size() == 1);
|
||||
CHECK(layer.lslices_bboxes.front().min == Point(0, 0));
|
||||
CHECK(layer.lslices_bboxes.front().max == Point(s, s));
|
||||
}
|
||||
|
||||
TEST_CASE("orca.host ExPolygon in-place transforms + SurfaceCollection.append (sample ops)", "[slicing_pipeline]") {
|
||||
|
||||
Reference in new issue
Block a user