Compare commits

..
Author SHA1 Message Date
peachismomo 77ca086f67 chore: shift files 2026-10-21 13:34:16 +08:00
peachismomo 3dff91b9ff fix: clang-tidy 2026-10-20 18:48:25 +08:00
Ian Chua 8f10d48a76 Merge branch 'main' into feat/headless-plugin-probe 2026-10-20 18:43:50 +08:00
peachismomo 0aa1b5fb69 feat: Add headless Python plugin permission probe 2026-10-20 18:42:07 +08:00
54 changed files with 1976 additions and 868 deletions

No files matched your search

+1
View File
@@ -123,6 +123,7 @@ 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.
+2 -6
View File
@@ -59,13 +59,9 @@ else ()
set(_cross_compile_arg --host=${TOOLCHAIN_PREFIX})
endif ()
# 6.3.0 is the first release whose arm64 assembly avoids x18, which macOS
# reserves and zeroes on every return to user space; 6.2.1 crashes on Apple
# silicon whenever CGAL falls back to exact arithmetic.
ExternalProject_Add(dep_GMP
URL https://ftp.gnu.org/gnu/gmp/gmp-6.3.0.tar.xz
https://github.com/SoftFever/OrcaSlicer_deps/releases/download/gmp-6.3.0/gmp-6.3.0.tar.xz
URL_HASH SHA256=a3c2b80201b89e68616f4ad30bc66aee4927c3ce50e33929ca819d5c43538898
URL https://github.com/SoftFever/OrcaSlicer_deps/releases/download/gmp-6.2.1/gmp-6.2.1.tar.bz2
URL_HASH SHA256=eae9326beb4158c386e39a356818031bd28f3124cf915f8c5b1dc4c7a36b4d7c
DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/GMP
PATCH_COMMAND git apply ${GMP_DIRECTORY_FLAG} --verbose ${CMAKE_CURRENT_LIST_DIR}/0001-GMP_GCC15.patch
BUILD_IN_SOURCE ON
+1
View File
@@ -366,6 +366,7 @@ 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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "Flashforge",
"url": "",
"version": "02.04.00.12",
"version": "02.04.00.11",
"force_update": "0",
"description": "Flashforge configurations",
"machine_model_list": [
@@ -8,7 +8,6 @@
"compatible_printers": [
"Flashforge Adventurer 5M 0.4 Nozzle"
],
"exclude_object_tolerance": "0.2",
"filename_format": "{input_filename_base}_{filament_type[initial_tool]}_{print_time}.gcode",
"post_process": "",
"only_one_wall_top": "0",
@@ -8,7 +8,6 @@
"compatible_printers": [
"Flashforge Adventurer 5M Pro 0.4 Nozzle"
],
"exclude_object_tolerance": "0.2",
"filename_format": "{input_filename_base}_{filament_type[0]}_{print_time}.gcode",
"post_process": "",
"only_one_wall_top": "0",
@@ -8,7 +8,6 @@
"compatible_printers": [
"Flashforge Adventurer 5M 0.6 Nozzle"
],
"exclude_object_tolerance": "0.2",
"filename_format": "{input_filename_base}_{filament_type[initial_tool]}_{print_time}.gcode",
"post_process": "",
"only_one_wall_top": "0",
@@ -8,7 +8,6 @@
"compatible_printers": [
"Flashforge Adventurer 5M Pro 0.6 Nozzle"
],
"exclude_object_tolerance": "0.2",
"filename_format": "{input_filename_base}_{filament_type[initial_tool]}_{print_time}.gcode",
"post_process": "",
"only_one_wall_top": "0",
@@ -226,10 +226,10 @@ modules:
sha256: f353e7b99bd0cbfc27ac9082de51acf32a8bc0b3e21ff9661ecca6f205ec1d81
dest: external-packages/OpenVDB
# GMP 6.3.0
# GMP 6.2.1
- type: file
url: https://ftp.gnu.org/gnu/gmp/gmp-6.3.0.tar.xz
sha256: a3c2b80201b89e68616f4ad30bc66aee4927c3ce50e33929ca819d5c43538898
url: https://github.com/SoftFever/OrcaSlicer_deps/releases/download/gmp-6.2.1/gmp-6.2.1.tar.bz2
sha256: eae9326beb4158c386e39a356818031bd28f3124cf915f8c5b1dc4c7a36b4d7c
dest: external-packages/GMP
# MPFR 4.2.2
+1 -1
View File
@@ -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=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=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%"
if errorlevel 1 (
set "script_exit_code=1"
) else (
+1 -1
View File
@@ -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=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=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"
python3 scripts/HintsToPot.py ./resources "$generated_i18n_dir"
if [ -f "$pot_file" ] && files_equal_ignoring_pot_date "$pot_file" "$generated_pot_file"; then
+9 -25
View File
@@ -122,8 +122,8 @@ void update_arrange_params(ArrangeParams& params, const DynamicPrintConfig* prin
void update_selected_items_inflation(ArrangePolygons& selected, const DynamicPrintConfig* print_cfg, ArrangeParams& params) {
// do not inflate brim_width. Objects are allowed to have overlapped brim.
// A brim reaching the printable area's edge is fine, so measure against the full bed.
BoundingBox bedbb = Polygon(get_bed_shape(*print_cfg)).bounding_box();
Points bedpts = get_shrink_bedpts(print_cfg, params);
BoundingBox bedbb = Polygon(bedpts).bounding_box();
// set obj distance for auto seq_print
if (params.is_seq_print) {
bool all_objects_are_short = std::all_of(selected.begin(), selected.end(), [&](ArrangePolygon& ap) { return ap.height < params.nozzle_height; });
@@ -144,30 +144,14 @@ void update_selected_items_inflation(ArrangePolygons& selected, const DynamicPri
// 3. otherwise, use each object's own brim width
ap.inflation = params.min_obj_distance != 0 ? params.min_obj_distance / 2 :
plate_has_tree_support ? scaled(brim_max / 2) : scaled(ap.brim_width);
if (params.min_obj_distance != 0)
return; // an explicit object distance is the user's call, not ours to shrink
// An item cannot be larger than the printable area, inflation included. Inflating
// an almost bed filling item would leave it too big to ever be placed.
BoundingBox apbb = ap.poly.contour.bounding_box();
auto diffx = bedbb.size().x() - apbb.size().x();
auto diffy = bedbb.size().y() - apbb.size().y();
ap.inflation = std::max<coord_t>(0, std::min({ ap.inflation, diffx / 2, diffy / 2 }));
});
// Give the bed margin, which the arrange bed keeps from the printable area, back
// where the largest item needs it. The skirt keeps its room: pushing a skirt off
// the bed is worse than saying the item does not fit.
if (!selected.empty()) {
coord_t max_width = 0, max_height = 0;
for (const ArrangePolygon &ap : selected) {
BoundingBox apbb = ap.poly.contour.bounding_box();
max_width = std::max(max_width, apbb.size().x() + 2 * ap.inflation);
max_height = std::max(max_height, apbb.size().y() + 2 * ap.inflation);
BoundingBox apbb = ap.poly.contour.bounding_box();
auto diffx = bedbb.size().x() - apbb.size().x() - 5;
auto diffy = bedbb.size().y() - apbb.size().y() - 5;
if (diffx > 0 && diffy > 0) {
auto min_diff = std::min(diffx, diffy);
ap.inflation = std::min(min_diff / 2, ap.inflation);
}
auto margin = [](coord_t bed, coord_t item) { return std::max(0., unscale<double>(bed - item) / 2.); };
params.bed_shrink_x = std::min<double>(params.bed_shrink_x, std::max<double>(params.brim_skirt_distance, margin(bedbb.size().x(), max_width)));
params.bed_shrink_y = std::min<double>(params.bed_shrink_y, std::max<double>(params.brim_skirt_distance, margin(bedbb.size().y(), max_height)));
}
});
}
void update_unselected_items_inflation(ArrangePolygons& unselected, const DynamicPrintConfig* print_cfg, const ArrangeParams& params)
+1 -1
View File
@@ -2914,7 +2914,7 @@ void multiline_fill(Polylines& polylines, const FillParams& params, float spacin
const float delta = float(scale_(t));
// Arc tolerance of 1/500 of the offset, Clipper2's default.
for (const Polygon &ring : offset(polylines, delta, jtMiter, 0.002 * delta, etOpenRound))
for (const Polygon &ring : offset(polylines, delta, jtRound, 0.002 * delta, etOpenRound))
if (ring.size() >= 3)
all_polylines.emplace_back(ring.split_at_first_point());
}
+14 -14
View File
@@ -3979,22 +3979,22 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato
this->placeholder_parser().set("hold_chamber_temp_for_flat_print", new ConfigOptionBool(hold_chamber_temp_for_flat_print));
}
this->placeholder_parser().set_deferred("print_time_total_sec", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Print_Time_Total_Sec_Placeholder));
this->placeholder_parser().set_deferred("print_time_day", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Print_Time_Day_Placeholder));
this->placeholder_parser().set_deferred("print_time_hour", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Print_Time_Hour_Placeholder));
this->placeholder_parser().set_deferred("print_time_minute", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Print_Time_Minute_Placeholder));
this->placeholder_parser().set_deferred("print_time_sec", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Print_Time_Sec_Placeholder));
this->placeholder_parser().set_deferred("used_filament_length", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Used_Filament_Length_Placeholder));
this->placeholder_parser().set("print_time_total_sec", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Print_Time_Total_Sec_Placeholder)));
this->placeholder_parser().set("print_time_day", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Print_Time_Day_Placeholder)));
this->placeholder_parser().set("print_time_hour", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Print_Time_Hour_Placeholder)));
this->placeholder_parser().set("print_time_minute", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Print_Time_Minute_Placeholder)));
this->placeholder_parser().set("print_time_sec", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Print_Time_Sec_Placeholder)));
this->placeholder_parser().set("used_filament_length", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Used_Filament_Length_Placeholder)));
// The final print statistics are known only after the whole G-code is generated, so they are filled in during post-processing too.
// The running extruded_* totals are left alone; profiles use them as numbers.
this->placeholder_parser().set_deferred("used_filament", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Used_Filament_Length_Placeholder));
this->placeholder_parser().set_deferred("print_time", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Normal_Print_Time_Placeholder));
this->placeholder_parser().set_deferred("normal_print_time", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Normal_Print_Time_Placeholder));
this->placeholder_parser().set_deferred("silent_print_time", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Silent_Print_Time_Placeholder));
this->placeholder_parser().set_deferred("total_weight", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Total_Weight_Placeholder));
this->placeholder_parser().set_deferred("total_cost", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Total_Cost_Placeholder));
this->placeholder_parser().set_deferred("total_wipe_tower_cost", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Total_Wipe_Tower_Cost_Placeholder));
this->placeholder_parser().set_deferred("total_wipe_tower_filament", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Total_Wipe_Tower_Filament_Placeholder));
this->placeholder_parser().set("used_filament", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Used_Filament_Length_Placeholder)));
this->placeholder_parser().set("print_time", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Normal_Print_Time_Placeholder)));
this->placeholder_parser().set("normal_print_time", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Normal_Print_Time_Placeholder)));
this->placeholder_parser().set("silent_print_time", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Silent_Print_Time_Placeholder)));
this->placeholder_parser().set("total_weight", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Total_Weight_Placeholder)));
this->placeholder_parser().set("total_cost", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Total_Cost_Placeholder)));
this->placeholder_parser().set("total_wipe_tower_cost", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Total_Wipe_Tower_Cost_Placeholder)));
this->placeholder_parser().set("total_wipe_tower_filament", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Total_Wipe_Tower_Filament_Placeholder)));
// Sync variant-mapped params into placeholder_parser before processing start gcode
update_placeholder_parser_with_variant_params();
+3 -19
View File
@@ -213,29 +213,13 @@ int find_plane_at_point(const std::vector<LayOnFacePlane> &planes, const Transfo
return -1;
}
Transform3d lay_on_face_rotation(const Vec3d &normal, const Transform3d &instance_matrix_no_offset)
{
// Normal in instance coordinates: a normal transforms by the inverse transpose.
const Vec3d tnormal = (instance_matrix_no_offset.matrix().block(0, 0, 3, 3).inverse().transpose() * normal).normalized();
const Vec3d down = -Vec3d::UnitZ();
// A face already pointing straight up needs a half turn, and every horizontal axis does
// that equally well. setFromTwoVectors() resolves the choice through an SVD whose null
// space is the whole XY plane, so the axis follows rounding noise and the object lands at
// an arbitrary angle in the plate plane. Turn it over about X instead, which keeps the
// footprint the object already had.
if (tnormal.dot(down) < -1. + 1e-9)
return Transform3d(Eigen::AngleAxisd(PI, Vec3d::UnitX()));
return Transform3d(Eigen::Quaterniond().setFromTwoVectors(tnormal, down));
}
void lay_on_face(ModelObject &object, size_t instance_idx, const Vec3d &normal)
{
ModelInstance &instance = *object.instances[instance_idx];
const Geometry::Transformation &trafo = instance.get_transformation();
// Same rotation as Selection::flattening_rotate(), which the gizmo uses.
const Transform3d rotation = lay_on_face_rotation(normal, trafo.get_matrix_no_offset());
// Same rotation as Selection::flattening_rotate(): turn the transformed normal to point down.
const Vec3d tnormal = trafo.get_matrix().matrix().block(0, 0, 3, 3).inverse().transpose() * normal;
const Transform3d rotation = Transform3d(Eigen::Quaterniond().setFromTwoVectors(tnormal, -Vec3d::UnitZ()));
instance.set_transformation(Geometry::Transformation(trafo.get_offset_matrix() * rotation * trafo.get_matrix_no_offset()));
// Drop this instance only: ensure_on_bed() skips instances without auto_drop and measures the first instance.
object.translate_instance(instance_idx, -object.instance_bounding_box(instance_idx).min.z() * Vec3d::UnitZ());
-6
View File
@@ -46,10 +46,4 @@ int find_plane_at_point(const std::vector<LayOnFacePlane> &planes, const Transfo
// the gizmo applies, then drops the instance so its lowest point is at z = 0.
void lay_on_face(ModelObject &object, size_t instance_idx, const Vec3d &normal);
// The rotation that turns `normal` (object coordinates) to point down, given the instance's
// rotation, scale and mirror. A face that already points straight up is turned over about X:
// the axis setFromTwoVectors() picks there is arbitrary, and it lands the object at a random
// angle in the plate plane.
Transform3d lay_on_face_rotation(const Vec3d &normal, const Transform3d &instance_matrix_no_offset);
} // namespace Slic3r
+8 -81
View File
@@ -243,7 +243,7 @@ namespace client
struct expr
{
expr() {}
expr(const expr &rhs) : m_type(rhs.type()), it_range(rhs.it_range), m_deferred(rhs.m_deferred)
expr(const expr &rhs) : m_type(rhs.type()), it_range(rhs.it_range)
{ if (rhs.type() == TYPE_STRING) m_data.s = new std::string(*rhs.m_data.s); else m_data.set(rhs.m_data); }
expr(expr &&rhs) : expr(std::move(rhs), rhs.it_range.begin(), rhs.it_range.end()) {}
@@ -258,16 +258,13 @@ namespace client
explicit expr(std::string &&s) : m_type(TYPE_STRING) { m_data.s = new std::string(std::move(s)); }
explicit expr(const std::string &s, const Iterator &it_begin, const Iterator &it_end) :
m_type(TYPE_STRING), it_range(it_begin, it_end) { m_data.s = new std::string(s); }
explicit expr(expr &&rhs, const Iterator &it_begin, const Iterator &it_end) : m_type(rhs.type()), it_range{ it_begin, it_end }, m_deferred(rhs.m_deferred)
explicit expr(expr &&rhs, const Iterator &it_begin, const Iterator &it_end) : m_type(rhs.type()), it_range{ it_begin, it_end }
{
m_data.set(rhs.m_data);
rhs.m_type = TYPE_EMPTY;
rhs.m_deferred = Deferred::None;
rhs.m_type = TYPE_EMPTY;
}
expr &operator=(const expr &rhs)
{
// set_s() clears m_deferred, which is also rhs.m_deferred on a self-assignment.
const Deferred deferred = rhs.m_deferred;
if (rhs.type() == TYPE_STRING) {
this->set_s(rhs.s());
} else {
@@ -275,7 +272,6 @@ namespace client
m_data.set(rhs.m_data);
}
this->it_range = rhs.it_range;
m_deferred = deferred;
return *this;
}
@@ -285,10 +281,8 @@ namespace client
this->reset();
m_type = rhs.type();
this->it_range = rhs.it_range;
m_deferred = rhs.m_deferred;
m_data.set(rhs.m_data);
rhs.m_type = TYPE_EMPTY;
rhs.m_deferred = Deferred::None;
}
return *this;
}
@@ -297,8 +291,7 @@ namespace client
{
if (this->type() == TYPE_STRING)
delete m_data.s;
m_type = TYPE_EMPTY;
m_deferred = Deferred::None;
m_type = TYPE_EMPTY;
}
~expr() { reset(); }
@@ -311,10 +304,6 @@ namespace client
};
Type type() const { return m_type; }
bool numeric_type() const { return m_type == TYPE_INT || m_type == TYPE_DOUBLE; }
// A deferred string is either one deferred value or text that contains at least one.
enum class Deferred : unsigned char { None, Value, Text };
bool deferred() const { return m_deferred != Deferred::None; }
void set_deferred(Deferred deferred) { m_deferred = deferred; }
bool& b() { return m_data.b; }
bool b() const { return m_data.b; }
@@ -334,21 +323,18 @@ namespace client
std::string& s() { return *m_data.s; }
const std::string& s() const { return *m_data.s; }
void set_s(const std::string &s) {
m_deferred = Deferred::None;
if (this->type() == TYPE_STRING)
*m_data.s = s;
else
this->set_s_take_ownership(new std::string(s));
}
void set_s(std::string &&s) {
m_deferred = Deferred::None;
if (this->type() == TYPE_STRING)
*m_data.s = std::move(s);
else
this->set_s_take_ownership(new std::string(std::move(s)));
}
void set_s(const char *s) {
m_deferred = Deferred::None;
if (this->type() == TYPE_STRING)
*m_data.s = s;
else
@@ -492,21 +478,12 @@ namespace client
{
if (this->type() == TYPE_EMPTY) {
// Inside an if / else block to be skipped.
} else if (m_deferred == Deferred::Value && (rhs.type() != TYPE_STRING || rhs.m_deferred == Deferred::Value)) {
// A deferred value added to a number or to another deferred value is meant as a sum.
this->throw_if_deferred();
} else if (rhs.m_deferred == Deferred::Value && this->type() != TYPE_STRING) {
rhs.throw_if_deferred();
} else if (this->type() == TYPE_STRING) {
// Convert the right hand side to string and append.
*m_data.s += rhs.to_string();
if (this->deferred() || rhs.deferred())
m_deferred = Deferred::Text;
} else if (rhs.type() == TYPE_STRING) {
// Conver the left hand side to string, append rhs.
this->set_s(this->to_string() + rhs.s());
if (rhs.deferred())
m_deferred = Deferred::Text;
} else {
const char *err_msg = L("Cannot add non-numeric types.");
this->throw_if_not_numeric(err_msg);
@@ -625,8 +602,6 @@ namespace client
if (lhs.type() == TYPE_EMPTY)
// Inside an if / else block to be skipped
return;
lhs.throw_if_deferred();
rhs.throw_if_deferred();
bool value = false;
if (lhs.numeric_type() && rhs.numeric_type()) {
// Both types are numeric.
@@ -756,7 +731,6 @@ namespace client
if (lhs.type() == TYPE_EMPTY)
// Inside an if / else block to be skipped
return;
lhs.throw_if_deferred();
const std::string *subject = nullptr;
if (lhs.type() == TYPE_STRING) {
// One type is string, the other could be converted to string.
@@ -787,7 +761,6 @@ namespace client
if (subject.type() == TYPE_EMPTY)
// Inside an if / else block to be skipped
return;
subject.throw_if_deferred();
if (subject.type() != TYPE_STRING)
subject.throw_exception(L("regex_replace() first parameter must be a string."));
try {
@@ -812,8 +785,6 @@ namespace client
return;
}
if (! out.b()) {
match.throw_if_deferred();
pattern.throw_if_deferred();
if (match.type() != TYPE_STRING)
match.throw_exception(L("one_of(): First parameter (the string to match against) has to be a string value"));
if (pattern.type() != TYPE_STRING)
@@ -847,8 +818,6 @@ namespace client
if (lhs.type() == TYPE_EMPTY)
// Inside an if / else block to be skipped
return;
lhs.throw_if_deferred();
rhs.throw_if_deferred();
bool value = false;
if (lhs.type() == TYPE_BOOL && rhs.type() == TYPE_BOOL) {
value = (op == '|') ? (lhs.b() || rhs.b()) : (lhs.b() && rhs.b());
@@ -861,16 +830,8 @@ namespace client
static void logical_or (expr &lhs, expr &rhs) { logical_op(lhs, rhs, '|'); }
static void logical_and(expr &lhs, expr &rhs) { logical_op(lhs, rhs, '&'); }
static const char* deferred_message()
{
return L("This value is known only after the G-code is generated. It can be printed, but not used in a calculation or a comparison.");
}
// Reports the deferred message for a type error on a deferred value, since it holds a tag in place of the value.
void throw_exception(const char *message) const
{
if (this->deferred())
message = deferred_message();
boost::throw_exception(qi::expectation_failure<Iterator>(
this->it_range.begin(), this->it_range.end(), spirit::info(std::string("*") + message)));
}
@@ -881,18 +842,11 @@ namespace client
this->throw_exception(message);
}
void throw_if_deferred() const
{
if (this->deferred())
this->throw_exception(deferred_message());
}
private:
// This object will take ownership of the parameter string object "s".
void set_s_take_ownership(std::string* s) { assert(this->type() != TYPE_STRING); Data tmp; tmp.s = s; m_data.set(tmp); m_type = TYPE_STRING; }
Type m_type = TYPE_EMPTY;
Deferred m_deferred = Deferred::None;
union Data {
bool b;
@@ -941,8 +895,6 @@ namespace client
std::string error_message;
// Local variables declared in {if} branches that were not taken, see PlaceholderParser::check_inactive_branches.
mutable std::set<std::string> inactive_local_variables;
// Tags of PlaceholderParser::set_deferred(), matched by value so that a copy in a variable is deferred too.
const std::set<std::string> *deferred_tags = nullptr;
// Table to translate symbol tag to a human readable error message.
static std::map<std::string, std::string> tag_to_error_message;
@@ -1018,18 +970,6 @@ namespace client
} else
this->config_local.set_key_value(opt_key, opt.release());
}
void mark_deferred(expr &output) const {
if (output.type() != expr::TYPE_STRING)
return;
expr::Deferred deferred = expr::Deferred::None;
if (deferred_tags != nullptr && output.s().find('@') != std::string::npos)
for (const std::string &tag : *deferred_tags)
if (output.s().find(tag) != std::string::npos) {
deferred = output.s() == tag ? expr::Deferred::Value : expr::Deferred::Text;
break;
}
output.set_deferred(deferred);
}
static void legacy_variable_expansion(const MyContext *ctx, IteratorRange &opt_key, std::string &output)
{
@@ -1490,7 +1430,6 @@ namespace client
vector_element_to_expr(ctx, opt, output);
else
scalar_variable_to_expr(ctx, opt, output);
ctx->mark_deferred(output);
}
output.it_range = opt.it_range;
}
@@ -1577,7 +1516,7 @@ namespace client
if (ctx->skipping()) {
} else if (lhs.opt) {
if (lhs.opt->is_vector())
ctx->throw_exception(L("Cannot assign a scalar value to a vector variable."), rhs.it_range);
rhs.throw_exception(L("Cannot assign a scalar value to a vector variable."));
OptWithPos lhs_opt{ lhs.opt, lhs.it_range, true };
scalar_variable_assign_scalar(ctx, lhs_opt, rhs);
} else {
@@ -1603,7 +1542,7 @@ namespace client
if (ctx->skipping()) {
} else if (lhs.opt) {
if (lhs.opt->is_scalar())
ctx->throw_exception(L("Cannot assign a vector value to a scalar variable."), rhs_value.it_range);
rhs_value.throw_exception(L("Cannot assign a vector value to a scalar variable."));
OptWithPos lhs_opt{ lhs.opt, lhs.it_range, true };
vector_variable_assign_expr_with_count(ctx, lhs_opt, rhs_count, rhs_value);
} else {
@@ -1629,7 +1568,7 @@ namespace client
if (! ctx->skipping()) {
check_writable(ctx, lhs);
if (lhs.opt->is_scalar())
ctx->throw_exception(L("Cannot assign a vector value to a scalar variable."), rhs_value.it_range);
rhs_value.throw_exception(L("Cannot assign a vector value to a scalar variable."));
vector_variable_assign_expr_with_count(ctx, lhs, rhs_count, rhs_value);
}
}
@@ -1864,21 +1803,10 @@ namespace client
static void filament_change(const MyContext* ctx, expr& param)
{
if (param.type() == expr::TYPE_EMPTY)
// Inside an if / else block to be skipped
return;
MyContext *context = const_cast<MyContext *>(ctx);
param.throw_if_deferred();
context->current_extruder_id = param.as_i();
}
// The replacement may put a deferred tag into the result.
static void regex_replace(const MyContext *ctx, expr &subject, IteratorRange &pattern, expr &replacement)
{
expr::regex_replace(subject, pattern, replacement);
ctx->mark_deferred(subject);
}
static void throw_exception(const std::string &msg, const IteratorRange &it_range)
{
// An asterix is added to the start of the string to differentiate the boost::spirit::info::tag content
@@ -2481,7 +2409,7 @@ namespace client
| (kw["zdigits"] > '(' > conditional_expression(_r1) [_val = _1] > ',' > conditional_expression(_r1) > optional_parameter(_r1))
[ px::bind(&expr::digits<true>, _val, _2, _3) ]
| (kw["regex_replace"] > '(' > conditional_expression(_r1) [_val = _1] > ',' > regular_expression > ',' > conditional_expression(_r1) > ')')
[ px::bind(&MyContext::regex_replace, _r1, _val, _2, _3) ]
[ px::bind(&expr::regex_replace, _val, _2, _3) ]
| (kw["int"] > '(' > conditional_expression(_r1) > ')') [ px::bind(&FactorActions::to_int, _1, _val) ]
| (kw["round"] > '(' > conditional_expression(_r1) > ')') [ px::bind(&FactorActions::round, _1, _val) ]
| (kw["ceil"] > '(' > conditional_expression(_r1) > ')') [ px::bind(&FactorActions::ceil, _1, _val) ]
@@ -2686,7 +2614,6 @@ std::string PlaceholderParser::process(const std::string &templ, unsigned int cu
context.config = &this->config();
context.config_override = config_override;
context.config_outputs = config_outputs;
context.deferred_tags = &m_deferred_tags;
context.current_extruder_id = current_extruder_id;
context.context_data = context_data;
return process_macro(templ, context);
-4
View File
@@ -60,9 +60,6 @@ public:
void set(const std::string &key, double value) { this->set(key, new ConfigOptionFloat(value)); }
void set(const std::string &key, const std::vector<std::string> &values) { this->set(key, new ConfigOptionStrings(values)); }
void set(const std::string &key, ConfigOption *opt) { m_config.set_key_value(key, opt); }
// Orca: sets a value known only after the G-code is generated, as a tag that post-processing replaces.
// A template may print it or join it to text, and a calculation or a comparison on it throws.
void set_deferred(const std::string &key, const std::string &tag) { this->set(key, tag); m_deferred_tags.insert(tag); }
DynamicConfig& config_writable() { return m_config; }
const DynamicConfig& config() const { return m_config; }
const ConfigOption* option(const std::string &key) const { return m_config.option(key); }
@@ -91,7 +88,6 @@ private:
// config has a higher priority than external_config when looking up a symbol.
DynamicConfig m_config;
const DynamicConfig *m_external_config;
std::set<std::string> m_deferred_tags;
};
}
+1 -1
View File
@@ -6459,7 +6459,7 @@ void PrintConfigDef::init_fff_params()
def->label = L("Seam gap");
def->category = L("Quality");
def->tooltip = L("In order to reduce the visibility of the seam in a closed loop extrusion, the loop is interrupted and shortened by a specified amount.\n"
"This amount can be specified in millimeters or as a percentage of the nozzle diameter. The default value for this parameter is 10%.");
"This amount can be specified in millimeters or as a percentage of the current extruder diameter. The default value for this parameter is 10%.");
def->sidetext = L("mm or %");
def->min = 0;
def->mode = comAdvanced;
+66
View File
@@ -707,6 +707,10 @@ 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
@@ -1092,3 +1096,65 @@ 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()
+20 -4
View File
@@ -5632,10 +5632,14 @@ void GLCanvas3D::do_move(const std::string& snapshot_type, bool force_volume_mov
// Fixes sinking/flying instances (snaps object to buildplate)
for (const std::pair<int, int>& i : done) {
ModelObject* mo = m_model->objects[i.first];
if (!mo->auto_drop) {
continue;
}
const double shift_z = mo->get_instance_min_z(i.second);
//BBS: don't call translate if the z is zero
if (mo->auto_drop && (current_printer_technology() == ptSLA || shift_z > SINKING_Z_THRESHOLD) && (shift_z != 0.0f)) {
if ((current_printer_technology() == ptSLA || shift_z > SINKING_Z_THRESHOLD) && (shift_z != 0.0f)) {
const Vec3d shift(0.0, 0.0, -shift_z);
m_selection.translate(i.first, i.second, shift);
mo->translate_instance(i.second, shift);
@@ -5759,10 +5763,14 @@ void GLCanvas3D::do_rotate(const std::string& snapshot_type)
for (const std::pair<int, int> &i : done) {
ModelObject *mo = m_model->objects[i.first];
if (!mo->auto_drop) {
continue;
}
// BBS: don't call translate if the z is zero
const double shift_z = mo->get_instance_min_z(i.second);
// leave sinking instances as sinking
if (mo->auto_drop && (min_zs.find({i.first, i.second})->second >= SINKING_Z_THRESHOLD || shift_z > SINKING_Z_THRESHOLD) && (shift_z != 0.0f)) {
if ((min_zs.find({i.first, i.second})->second >= SINKING_Z_THRESHOLD || shift_z > SINKING_Z_THRESHOLD) && (shift_z != 0.0f)) {
const Vec3d shift(0.0, 0.0, -shift_z);
m_selection.translate(i.first, i.second, shift);
mo->translate_instance(i.second, shift);
@@ -5857,10 +5865,14 @@ void GLCanvas3D::do_scale(const std::string& snapshot_type)
for (const std::pair<int, int>& i : done) {
ModelObject* mo = m_model->objects[i.first];
if (!mo->auto_drop) {
continue;
}
//BBS: don't call translate if the z is zero
double shift_z = mo->get_instance_min_z(i.second);
// leave sinking instances as sinking
if (mo->auto_drop && (min_zs.empty() || min_zs.find({ i.first, i.second })->second >= SINKING_Z_THRESHOLD || shift_z > SINKING_Z_THRESHOLD) && (shift_z != 0.0f)) {
if ((min_zs.empty() || min_zs.find({ i.first, i.second })->second >= SINKING_Z_THRESHOLD || shift_z > SINKING_Z_THRESHOLD) && (shift_z != 0.0f)) {
Vec3d shift(0.0, 0.0, -shift_z);
m_selection.translate(i.first, i.second, shift);
mo->translate_instance(i.second, shift);
@@ -5973,10 +5985,14 @@ void GLCanvas3D::do_mirror(const std::string& snapshot_type)
for (const std::pair<int, int>& i : done) {
ModelObject* mo = m_model->objects[i.first];
if (!mo->auto_drop) {
continue;
}
//BBS: don't call translate if the z is zero
double shift_z = mo->get_instance_min_z(i.second);
// leave sinking instances as sinking
if (mo->auto_drop && (min_zs.empty() || min_zs.find({ i.first, i.second })->second >= SINKING_Z_THRESHOLD || shift_z > SINKING_Z_THRESHOLD) && (shift_z != 0.0f)) {
if ((min_zs.empty() || min_zs.find({ i.first, i.second })->second >= SINKING_Z_THRESHOLD || shift_z > SINKING_Z_THRESHOLD)&&(shift_z != 0.0f)) {
Vec3d shift(0.0, 0.0, -shift_z);
m_selection.translate(i.first, i.second, shift);
mo->translate_instance(i.second, shift);
+3 -22
View File
@@ -3002,8 +3002,7 @@ void GUI_App::init_plugin_gui_wiring()
};
// why: a newly loaded plugin only adds a selectable agent
// refresh the dropdown and leave the live agent alone, unless the selected preset was
// waiting for this agent: plugins load after the startup preset is applied
// refresh the dropdown and leave the live agent alone
auto refresh_printer_agent_dropdown_after_load = [](const std::string&)
{
if (!wxTheApp)
@@ -3015,11 +3014,8 @@ void GUI_App::init_plugin_gui_wiring()
app->CallAfter([app]
{
if (app->is_closing())
return;
app->switch_printer_agent_if_pending();
app->refresh_printer_agent_dropdown();
if (!app->is_closing())
app->refresh_printer_agent_dropdown();
});
};
@@ -4307,21 +4303,6 @@ void GUI_App::switch_printer_agent()
select_machine(effective_agent_id);
}
void GUI_App::switch_printer_agent_if_pending()
{
if (!m_agent || !preset_bundle)
return;
const std::string wanted_agent_id = resolve_printer_agent_id(preset_bundle->printers.get_edited_preset().config.opt_string("printer_agent"));
const auto live_agent = m_agent->get_printer_agent();
if (live_agent && live_agent->get_agent_info().id == wanted_agent_id)
return;
if (!NetworkAgentFactory::get_printer_agent_info(wanted_agent_id))
return;
switch_printer_agent();
}
void GUI_App::select_machine(const std::string& agent_id)
{
// Skip for BBL agent for now - uses its own device discovery/selection
-3
View File
@@ -412,9 +412,6 @@ public:
// Reconcile the live printer agent with the stored preset selection.
void switch_printer_agent();
// Install the selected preset's agent if it is registered but not live, e.g. when its plugin
// finishes loading after the startup preset was applied. Otherwise leave the live agent alone.
void switch_printer_agent_if_pending();
std::string resolve_printer_agent_id(const std::string& stored_id) const;
// ORCA TODO: in the future, bbl presets should specify "bbl" printer agent id
+2 -6
View File
@@ -1618,9 +1618,7 @@ void ObjectList::list_manipulation(const wxPoint& mouse_pos, bool evt_context_me
get_selected_item_indexes(obj_idx, vol_idx, item);
if (obj_idx != -1) {
Plater::TakeSnapshot snapshot(plater, _u8L("Shift objects to bed"));
// Shift on request also when auto drop is off, which ensure_on_bed() skips.
ModelObject* mo = (*m_objects)[obj_idx];
mo->translate_instances(-mo->min_z() * Vec3d::UnitZ());
(*m_objects)[obj_idx]->ensure_on_bed();
cnv->reload_scene(true, true);
update_info_items(obj_idx);
notify_instance_updated(obj_idx);
@@ -6736,9 +6734,7 @@ void ObjectList::OnEditingStarted(wxDataViewEvent &event)
Plater::TakeSnapshot snapshot(plater, _u8L("Shift objects to bed"));
int obj_idx, vol_idx;
get_selected_item_indexes(obj_idx, vol_idx, item);
// Shift on request also when auto drop is off, which ensure_on_bed() skips.
ModelObject* mo = (*m_objects)[obj_idx];
mo->translate_instances(-mo->min_z() * Vec3d::UnitZ());
(*m_objects)[obj_idx]->ensure_on_bed();
cnv->reload_scene(true, true);
update_info_items(obj_idx);
notify_instance_updated(obj_idx);
+3 -15
View File
@@ -771,17 +771,6 @@ void ArrangeJob::finalize(bool canceled, std::exception_ptr &eptr) {
if (canceled || eptr)
return;
// Nothing could be placed: leave the plates and the instances exactly as they were.
// Rebuilding the plates would only re-home the instances by their bounds, which moves
// a too large item to whichever plate it happens to reach over.
const bool nothing_arranged = !m_selected.empty() &&
std::all_of(m_selected.begin(), m_selected.end(), [](const ArrangePolygon &ap) { return !ap.is_arranged(); });
if (nothing_arranged) {
m_plater->get_notification_manager()->close_notification_of_type(NotificationType::ArrangeOngoing);
m_plater->m_arrange_running.store(false);
return;
}
// Unprintable items go to the last virtual bed
int beds = 0;
@@ -881,13 +870,12 @@ void ArrangeJob::finalize(bool canceled, std::exception_ptr &eptr) {
}
m_plater->get_notification_manager()->close_notification_of_type(NotificationType::ArrangeOngoing);
//BBS: reload all objects due to arrange. Plates are never recycled here: arranging
// must not remove a plate the user created, even when the arrange left it empty.
//BBS: reload all objects due to arrange
if (only_on_partplate) {
plate_list.rebuild_plates_after_arrangement(false, true, current_plate_index);
plate_list.rebuild_plates_after_arrangement(!only_on_partplate, true, current_plate_index);
}
else {
plate_list.rebuild_plates_after_arrangement(false, true);
plate_list.rebuild_plates_after_arrangement(!only_on_partplate, true);
}
// unlock the plates we just locked
+1 -2
View File
@@ -248,6 +248,7 @@ void FillBedJob::process(Ctl &ctl)
if (m_object_idx == -1 || m_selected.empty()) return;
update_arrange_params(params, m_plater->config(), m_selected);
m_bedpts = get_shrink_bedpts(m_plater->config(), params);
auto &partplate_list = m_plater->get_partplate_list();
auto &print = wxGetApp().plater()->get_partplate_list().get_current_fff_print();
@@ -259,8 +260,6 @@ void FillBedJob::process(Ctl &ctl)
update_selected_items_inflation(m_selected, m_plater->config(), params);
update_unselected_items_inflation(m_unselected, m_plater->config(), params);
// After the inflation, which may give back part of the bed margin.
m_bedpts = get_shrink_bedpts(m_plater->config(), params);
bool do_stop = false;
params.stopcondition = [&ctl, &do_stop]() {
+11 -6
View File
@@ -7048,14 +7048,19 @@ void PartPlateList::postprocess_arrange_polygon(arrangement::ArrangePolygon& arr
{
BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(": bed_idx %1%, selected %2%, translation(x) %3%, (y) %4%") % arrange_polygon.bed_idx % selected % unscale<double>(arrange_polygon.translation(X)) % unscale<double>(arrange_polygon.translation(Y));
// UNARRANGED means the item could not be placed, so it has no bed to map onto and
// must be left where the user had it. Moving it to a virtual bed here would shift
// it off the plate, because no such plate is created for it afterwards.
if (arrange_polygon.bed_idx == arrangement::UNARRANGED)
return;
if ((selected) || (arrange_polygon.bed_idx != PartPlateList::MAX_PLATES_COUNT))
{
if (arrange_polygon.bed_idx == -1)
{
// outarea for large object
arrange_polygon.bed_idx = m_plate_list.size();
BoundingBox apbox = get_extents(arrange_polygon.transformed_poly()); // the item may have been rotated
auto apbox_size = apbox.size();
arrange_polygon.translation(X) = 0.5 * apbox_size[0];
arrange_polygon.translation(Y) = scaled<double>(static_cast<double>(m_plate_depth)) - 0.5 * apbox_size[1];
}
arrange_polygon.row = arrange_polygon.bed_idx / m_plate_cols;
arrange_polygon.col = arrange_polygon.bed_idx % m_plate_cols;
arrange_polygon.translation(X) += scaled<double>(plate_stride_x() * arrange_polygon.col);
+3 -19
View File
@@ -2865,8 +2865,7 @@ Sidebar::Sidebar(Plater *parent)
// add printer title
scrolled_sizer->Add(p->m_panel_printer_title, 0, wxEXPAND | wxALL, 0);
// ORCA Bind with LEFT_DOWN, since closing a dialog over the title bar can send it a stray LEFT_UP
auto toggle_printer_section = ([this] (wxMouseEvent & e) {
p->m_panel_printer_title->Bind(wxEVT_LEFT_UP, [this] (auto & e) {
if (!p || !p->combo_printer || !p->m_text_printer_settings || !p->m_panel_printer_content || !m_scrolled_sizer)
return;
// ORCA Show printer name on title when its folded to inform user without expanding it again
@@ -2877,8 +2876,6 @@ Sidebar::Sidebar(Plater *parent)
p->m_panel_printer_separator->Show(isShown);
m_scrolled_sizer->Layout();
});
p->m_panel_printer_title->Bind(wxEVT_LEFT_DOWN , toggle_printer_section);
p->m_panel_printer_title->Bind(wxEVT_LEFT_DCLICK, toggle_printer_section);
// ORCA add bottom border for seperation wile sections folded
p->m_panel_printer_separator = new wxPanel(p->scrolled, wxID_ANY, wxDefaultPosition, wxSize(-1, FromDIP(2))); // ORCA staticline class not works without string
p->m_panel_printer_separator->SetBackgroundColour("#FFFFFF");
@@ -3251,8 +3248,7 @@ Sidebar::Sidebar(Plater *parent)
p->m_panel_filament_title = new StaticBox(p->scrolled, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL | wxBORDER_NONE);
p->m_panel_filament_title->SetBackgroundColor(title_bg);
p->m_panel_filament_title->SetBackgroundColor2(0xF1F1F1);
// ORCA Bind with LEFT_DOWN, since closing a dialog over the title bar can send it a stray LEFT_UP
auto toggle_filament_section = ([this](wxMouseEvent &e) {
p->m_panel_filament_title->Bind(wxEVT_LEFT_UP, [this](wxMouseEvent &e) {
if (!p || !p->m_filament_area_wrapper || !m_scrolled_sizer || !p->m_bpButton_set_filament || !p->m_purge_mode_btn || !p->m_flushing_volume_btn || !p->m_bpButton_add_filament || !ams_btn)
return;
// ORCA exclude area of del button from titlebar collapse/expand feature to fix undesired collapse when user spams del filament button
@@ -3271,8 +3267,6 @@ Sidebar::Sidebar(Plater *parent)
CallAfter([this]{update_filaments_counter(true);}); // call after all UI processing done
});
p->m_panel_filament_title->Bind(wxEVT_LEFT_DOWN , toggle_filament_section);
p->m_panel_filament_title->Bind(wxEVT_LEFT_DCLICK, toggle_filament_section);
wxBoxSizer* bSizer39;
bSizer39 = new wxBoxSizer( wxHORIZONTAL );
@@ -11040,17 +11034,7 @@ void Plater::priv::split_volume()
void Plater::priv::scale_selection_to_fit_print_volume()
{
#if ENABLE_ENHANCED_PRINT_VOLUME_FIT
const BuildVolume volume = this->bed.build_volume();
// Every plate shares the bed size, but each sits at its own origin: fit onto the
// plate the selection is on instead of always onto the first one.
PartPlate* plate = this->partplate_list.get_curr_plate();
const Vec3d bed_center = plate != nullptr ? plate->get_center_origin() :
Vec3d(volume.bed_center().x(), volume.bed_center().y(), 0.);
// On a multi-nozzle printer each nozzle reaches only part of the bed, so a model fitted
// to the bed can still exceed a nozzle's printable range. Fit into the volume every
// nozzle shares, which is also the area Arrange places items in.
const BoundingBoxf3 print_volume = plate != nullptr ? plate->get_build_volume(true) : volume.bounding_volume();
this->view3D->get_canvas3d()->get_selection().scale_to_fit_print_volume(volume, Vec2d(bed_center.x(), bed_center.y()), print_volume);
this->view3D->get_canvas3d()->get_selection().scale_to_fit_print_volume(this->bed.build_volume());
#else
this->view3D->get_canvas3d()->get_selection().scale_to_fit_print_volume(*config);
#endif // ENABLE_ENHANCED_PRINT_VOLUME_FIT
+33 -16
View File
@@ -17,7 +17,6 @@
#include "libslic3r/Color.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/Geometry.hpp"
#include "libslic3r/LayOnFace.hpp"
#include "slic3r/GUI/GLModel.hpp"
#include <string>
#include <vector>
@@ -580,12 +579,29 @@ void Selection::drop()
wxGetApp().plater()->take_snapshot(L("Move object"));
// do_move() writes every volume back to the model and refreshes the view.
setup_cache();
TransformationType transformation_type;
transformation_type.set_relative();
translate(Vec3d(0, 0, -min_z), transformation_type);
wxGetApp().plater()->get_view3D_canvas3D()->do_move("");
this->move_to_center(Vec3d(0, 0, -min_z));
for (unsigned int i : m_list) {
GLVolume& volume = *(*m_volumes)[i];
// Skip the wipe tower: its synthetic id (>= 1000) is not an index into m_model->objects.
if (volume.object_idx() >= 1000)
continue;
ModelObject* model_object = m_model->objects[volume.object_idx()];
if (model_object != nullptr) {
if (m_mode == Volume) {
ModelVolume* cur_mv = model_object->volumes[volume.volume_idx()];
cur_mv->set_transformation(volume.get_volume_transformation());
} else if (m_mode == Instance) {
model_object->instances[volume.instance_idx()]->set_transformation(volume.get_instance_transformation());
}
model_object->invalidate_bounding_box();
this->notify_instance_update(volume.object_idx(), volume.instance_idx());
}
}
wxGetApp().plater()->get_view3D_canvas3D()->post_event(SimpleEvent(EVT_GLCANVAS_INSTANCE_MOVED));
}
void Selection::center_plate(const int plate_idx) {
@@ -1456,9 +1472,11 @@ void Selection::flattening_rotate(const Vec3d& normal)
for (unsigned int i : m_list) {
GLVolume& v = *(*m_volumes)[i];
// Additional rotation to align the face normal with the down vector in the world coordinate space.
// Normal transformed from the object coordinate space to the world coordinate space.
const Geometry::Transformation& old_inst_trafo = v.get_instance_transformation();
const Transform3d rotation_matrix = lay_on_face_rotation(normal, old_inst_trafo.get_matrix_no_offset());
const Vec3d tnormal = old_inst_trafo.get_matrix().matrix().block(0, 0, 3, 3).inverse().transpose() * normal;
// Additional rotation to align tnormal with the down vector in the world coordinate space.
const Transform3d rotation_matrix = Transform3d(Eigen::Quaterniond().setFromTwoVectors(tnormal, -Vec3d::UnitZ()));
v.set_instance_transformation(old_inst_trafo.get_offset_matrix() * rotation_matrix * old_inst_trafo.get_matrix_no_offset());
}
@@ -1478,7 +1496,7 @@ void Selection::scale(const Vec3d& scale, TransformationType transformation_type
}
#if ENABLE_ENHANCED_PRINT_VOLUME_FIT
void Selection::scale_to_fit_print_volume(const BuildVolume& volume, const Vec2d& bed_center, const BoundingBoxf3& print_volume)
void Selection::scale_to_fit_print_volume(const BuildVolume& volume)
{
auto fit = [this](double s, Vec3d offset) {
if (s <= 0.0 || s == 1.0)
@@ -1508,7 +1526,8 @@ void Selection::scale_to_fit_print_volume(const BuildVolume& volume, const Vec2d
//wxGetApp().obj_manipul()->set_dirty();
};
auto fit_rectangle = [this, fit, &print_volume]() {
auto fit_rectangle = [this, fit](const BuildVolume& volume) {
const BoundingBoxf3 print_volume = volume.bounding_volume();
const Vec3d print_volume_size = print_volume.size();
// adds 1/100th of a mm on all sides to avoid false out of print volume detections due to floating-point roundings
@@ -1522,7 +1541,7 @@ void Selection::scale_to_fit_print_volume(const BuildVolume& volume, const Vec2d
fit(std::min(sx, std::min(sy, sz)), print_volume.center() - get_bounding_box().center());
};
auto fit_circle = [this, fit, &bed_center](const BuildVolume& volume) {
auto fit_circle = [this, fit](const BuildVolume& volume) {
const Geometry::Circled& print_circle = volume.circle();
double print_circle_radius = unscale<double>(print_circle.radius);
@@ -1553,9 +1572,7 @@ void Selection::scale_to_fit_print_volume(const BuildVolume& volume, const Vec2d
const double s = std::min(print_circle_radius / circle_radius, volume.printable_height() / max_z);
const Vec3d sel_center = get_bounding_box().center();
const Vec3d offset = s * (Vec3d(unscale<double>(circle.center.x()), unscale<double>(circle.center.y()), 0.5 * max_z) - sel_center);
// The active plate's bed is the same circle, moved by its centre.
const Vec3d plate_shift = Vec3d(bed_center.x() - volume.bed_center().x(), bed_center.y() - volume.bed_center().y(), 0.);
const Vec3d print_center = Vec3d(unscale<double>(print_circle.center.x()), unscale<double>(print_circle.center.y()), 0.5 * volume.printable_height()) + plate_shift;
const Vec3d print_center = { unscale<double>(print_circle.center.x()), unscale<double>(print_circle.center.y()), 0.5 * volume.printable_height() };
fit(s, print_center - (sel_center + offset));
};
@@ -1564,7 +1581,7 @@ void Selection::scale_to_fit_print_volume(const BuildVolume& volume, const Vec2d
switch (volume.type())
{
case BuildVolume_Type::Rectangle: { fit_rectangle(); break; }
case BuildVolume_Type::Rectangle: { fit_rectangle(volume); break; }
case BuildVolume_Type::Circle: { fit_circle(volume); break; }
default: { break; }
}
+1 -4
View File
@@ -354,10 +354,7 @@ public:
void flattening_rotate(const Vec3d& normal);
void scale(const Vec3d& scale, TransformationType transformation_type);
#if ENABLE_ENHANCED_PRINT_VOLUME_FIT
// bed_center is the centre of the plate the selection is on: every plate shares the
// bed size, but each sits at its own origin. print_volume is the box the selection must
// fit in, which is the area every nozzle reaches rather than the whole bed.
void scale_to_fit_print_volume(const BuildVolume& volume, const Vec2d& bed_center, const BoundingBoxf3& print_volume);
void scale_to_fit_print_volume(const BuildVolume& volume);
#else
void scale_to_fit_print_volume(const DynamicPrintConfig& config);
#endif // ENABLE_ENHANCED_PRINT_VOLUME_FIT
+1 -1
View File
@@ -310,7 +310,7 @@ Label::Label(wxWindow *parent, wxFont const &font, wxString const &text, long st
SetBackgroundColour(StaticBox::GetParentBackgroundColor(parent));
SetForegroundColour("#262E30");
if (style & LB_PROPAGATE_MOUSE_EVENT) {
for (auto evt : { wxEVT_LEFT_UP, wxEVT_LEFT_DOWN, wxEVT_LEFT_DCLICK}) // ORCA also propagete LEFT_DCLICK event that used on sidebar titlebars
for (auto evt : { wxEVT_LEFT_UP, wxEVT_LEFT_DOWN })
Bind(evt, [this] (auto & e) { GetParent()->GetEventHandler()->ProcessEventLocally(e); });
};
if (style & LB_AUTO_WRAP) {
+6 -25
View File
@@ -73,14 +73,7 @@ bool QidiPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode
// 3. Fetch slot info and build AmsTrayData directly
std::vector<AmsTrayData> trays;
int box_count = 0;
bool slots_ok = false;
try {
slots_ok = fetch_slot_info(device_info.base_url, device_info.api_key, dict, series_id, trays, box_count, error);
} catch (const nlohmann::json::exception& e) {
// A slot value of an unexpected type fails the sync instead of escaping to the UI
error = e.what();
}
if (!slots_ok) {
if (!fetch_slot_info(device_info.base_url, device_info.api_key, dict, series_id, trays, box_count, error)) {
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::fetch_filament_info: Failed to fetch slot info: " << error;
return false;
}
@@ -98,7 +91,7 @@ bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url,
int& box_count,
std::string& error)
{
std::string url = join_url(base_url, "/printer/objects/query?save_variables=variables&multi_color_controller=box");
std::string url = join_url(base_url, "/printer/objects/query?save_variables=variables");
for (int i = 0; i < 16; ++i) {
url += "&box_stepper%20slot" + std::to_string(i) + "=runout_button";
}
@@ -171,12 +164,9 @@ bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url,
AmsTrayData tray;
tray.slot_index = i;
// Read slot variables. Newer firmware stores a hex color list and a material id instead of dictionary
// indexes, and only in the slots it has rewritten, so each slot is checked for both forms.
const auto color_value = variables.value("color_slot" + std::to_string(i), nlohmann::json(1));
const auto filament_value = variables.value("filament_slot" + std::to_string(i), nlohmann::json(1));
const int color_index = color_value.is_number() ? color_value.get<int>() : 0;
const int filament_type = filament_value.is_number() ? filament_value.get<int>() : 0;
// Read slot variables
const int color_index = variables.value("color_slot" + std::to_string(i), 1);
const int filament_type = variables.value("filament_slot" + std::to_string(i), 1);
const int vendor_type = variables.value("vendor_slot" + std::to_string(i), 0);
// Check filament presence via runout sensor
@@ -197,13 +187,6 @@ bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url,
if (filament_it != dict.filaments.end()) {
filament_name = filament_it->second;
}
if (filament_value.is_string()) {
const nlohmann::json::json_pointer decoded_type("/multi_color_controller/box/box" + std::to_string(i / 4) +
"_filament_info_" + std::to_string(i % 4) + "_filament_type");
if (status.contains(decoded_type) && status[decoded_type].is_string()) {
filament_name = status[decoded_type].get<std::string>();
}
}
tray.tray_type = normalize_filament_type(filament_name);
// Try Qidi-specific setting ID first; fall back to visible preset by type
@@ -219,9 +202,7 @@ bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url,
// Look up color from dictionary
auto color_it = dict.colors.find(color_index);
if (color_value.is_array() && !color_value.empty() && color_value[0].is_string()) {
tray.tray_color = color_value[0].get<std::string>();
} else if (color_it != dict.colors.end()) {
if (color_it != dict.colors.end()) {
tray.tray_color = color_it->second;
} else {
tray.tray_color = "FFFFFFFF";
+3 -7
View File
@@ -350,13 +350,9 @@ bool SimplyPrint::do_temp_upload(const boost::filesystem::path& file_path,
if (should_open_in_external_browser()) {
wxLaunchDefaultBrowser(url);
} else {
// The tab switch queues a reload of the printer's home page, so switch synchronously
// first and queue the import page after it.
GUI::wxGetApp().CallAfter([url = wxString::FromUTF8(url)] {
const auto mainframe = GUI::wxGetApp().mainframe;
mainframe->select_tab(TAB_ID_MONITOR);
mainframe->load_printer_url(url);
});
const auto mainframe = GUI::wxGetApp().mainframe;
mainframe->request_select_tab(TAB_ID_MONITOR);
mainframe->load_printer_url(url);
}
return true;
+112 -365
View File
@@ -1,4 +1,5 @@
#include "PluginAuditManager.hpp"
#include "PluginAuditManagerInternal.hpp"
#include "../Utils/OrcaCloudServiceAgent.hpp"
#include "libslic3r/Utils.hpp"
@@ -15,25 +16,12 @@
#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 {
@@ -161,8 +149,10 @@ 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".
static const char* category_name(AuditEventCategory category)
const char* category_name(AuditEventCategory category)
{
switch (category) {
case AuditEventCategory::FsRead: return "FsRead";
@@ -179,6 +169,29 @@ static 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
// ---------------------------------------------------------------------------
@@ -247,6 +260,7 @@ 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;
@@ -316,6 +330,76 @@ 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())
@@ -500,12 +584,6 @@ AuditDecision PluginAuditManager::check_path_access(const boost::filesystem::pat
candidate = absolute_candidate;
}
// Importing Python modules reads their source and may inspect package directories. These
// reads are normal interpreter work, not plugin data access; keep the exemption read-only
// and exclude third-party packages from the stdlib roots.
if (!is_write && is_stdlib_path(candidate))
return {true, ""};
// A root that doesn't allow writes only matches a read-shaped request; a write/create/
// delete-shaped one falls through to "outside allowed root" for that root even though the
// path is physically inside it.
@@ -542,73 +620,14 @@ bool PluginAuditManager::request_filesystem_read_permissions(const std::string&
if (plugin_key.empty() || paths.empty())
return true;
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())
// 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);
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);
return plugin_audit::request_filesystem_read_permissions(plugin_key, paths);
}
void PluginAuditManager::report_violation(const AuditViolation& violation)
@@ -870,265 +889,18 @@ 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;
static const char* audit_category_name(AuditEventCategory category)
{
switch (category) {
case AuditEventCategory::FsRead: return "FsRead";
case AuditEventCategory::FsReadWrite: return "FsReadWrite";
case AuditEventCategory::FsCreate: return "FsCreate";
case AuditEventCategory::FsDelete: return "FsDelete";
case AuditEventCategory::Http: return "Http";
case AuditEventCategory::Socket: return "Socket";
case AuditEventCategory::ProcessCreate: return "ProcessCreate";
case AuditEventCategory::ProcessReplace: return "ProcessReplace";
case AuditEventCategory::Threading: return "Threading";
default: return "None";
}
}
// 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;
std::string targets_text = by_event ? "<none>" : "";
for (const auto& target : shown) {
if (!targets_text.empty())
targets_text += ", ";
targets_text += target;
}
BOOST_LOG_TRIVIAL(info) << "[AUDIT PROMPT] plugin=" << plugin_key << " event=" << event_name
<< " category=" << audit_category_name(category) << " targets=[" << targets_text << "]"
<< (by_event && !keys.empty() ? " grant=" + keys.front() : std::string{});
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,
@@ -1137,7 +909,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 report_denied(mgr, event_name, {false, "denied path"}, target);
return plugin_audit::report_denied(mgr, event_name, {false, "denied path"}, target);
}
}
@@ -1162,15 +934,7 @@ int audit_plugin_event(PluginAuditManager& mgr, const std::string& event_name, A
return 0;
}
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);
const int result = plugin_audit::decide_event(mgr, 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.
@@ -1232,18 +996,6 @@ bool PluginAuditManager::is_stdlib_file(const std::string& filename)
return it->second;
}
bool PluginAuditManager::is_stdlib_path(const boost::filesystem::path& path)
{
if (path.empty())
return false;
const auto inside_any = [&path](const std::vector<boost::filesystem::path>& dirs) {
return std::any_of(dirs.begin(), dirs.end(),
[&path](const boost::filesystem::path& dir) { return is_inside_allowed_root(path, dir); });
};
return inside_any(PluginAuditDetail::s_stdlib_dirs) && !inside_any(PluginAuditDetail::s_site_package_dirs);
}
void PluginAuditManager::release_frames(std::vector<PyObject*>& frames)
{
if (frames.empty())
@@ -1269,11 +1021,6 @@ 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);
@@ -1312,7 +1059,7 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user
void PluginAuditManager::install_hook()
{
// Identifies stdlib frames for operation roots and stdlib paths for read-only access.
// Tells the stdlib's own frames apart, for the operation-root frames (see record_root_frame).
PluginAuditDetail::collect_stdlib_dirs();
// data_dir() is the primary globally-allowed root during plugin execution: read+write. It
+59 -5
View File
@@ -24,6 +24,19 @@ 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.
@@ -137,6 +150,30 @@ 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();
@@ -173,16 +210,12 @@ private:
static int audit_hook(const char* event, PyObject* args, void* user_data);
// True for paths inside the interpreter's stdlib, excluding site-packages. Unlike
// is_stdlib_file(), this query is not cached because filesystem paths may resolve through
// symlinks that can change.
static bool is_stdlib_path(const boost::filesystem::path& path);
// Drops the references in `frames`, keeping the caller's error and denial state intact.
void release_frames(std::vector<PyObject*>& frames);
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;
@@ -195,6 +228,11 @@ 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
@@ -225,6 +263,22 @@ 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_
+332
View File
@@ -0,0 +1,332 @@
#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
@@ -0,0 +1,38 @@
#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_
+16 -17
View File
@@ -14,8 +14,7 @@
#include "PluginAuditManager.hpp"
#include "PythonInterpreter.hpp"
#include "slic3r/GUI/I18N.hpp"
#include "slic3r/GUI/format.hpp"
#include "PluginText.hpp"
#include <exception>
#include <miniz.h>
@@ -92,7 +91,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 = _u8L("Plugin folder could not be determined.");
error = PLUGIN_U8L("Plugin folder could not be determined.");
return false;
}
@@ -103,7 +102,7 @@ bool resolve_allowed_plugin_root(const PluginDescriptor& descriptor,
resolved_root = fs::absolute(plugin_root, ec);
}
if (ec || resolved_root.empty()) {
error = GUI::format(_u8L("Failed to resolve plugin folder: %1%"), plugin_root.string());
error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to resolve plugin folder: %1%"), plugin_root.string());
return false;
}
@@ -123,7 +122,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 = GUI::format(_u8L("Failed to delete plugin folder %1%: %2%"), resolved_root.string(), ec.message());
error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to delete plugin folder %1%: %2%"), resolved_root.string(), ec.message());
return false;
}
@@ -1199,7 +1198,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 = GUI::format(_u8L("Plugin directory does not exist: %1%"), plugin_dir.string());
error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin directory does not exist: %1%"), plugin_dir.string());
return {};
}
@@ -1216,7 +1215,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 = GUI::format(_u8L("Plugin directory contains multiple .py files: %1% and %2%"),
error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin directory contains multiple .py files: %1% and %2%"),
py_entry.filename().string(), it->path().filename().string());
return {};
}
@@ -1224,7 +1223,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 = GUI::format(_u8L("Plugin directory contains multiple .whl files: %1% and %2%"),
error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin directory contains multiple .whl files: %1% and %2%"),
whl_entry.filename().string(), it->path().filename().string());
return {};
}
@@ -1233,7 +1232,7 @@ boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::pat
}
if (!py_entry.empty() && !whl_entry.empty()) {
error = _u8L("Plugin directory contains both .py and .whl entry files");
error = PLUGIN_U8L("Plugin directory contains both .py and .whl entry files");
return {};
}
@@ -1243,7 +1242,7 @@ boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::pat
if (!whl_entry.empty())
return whl_entry;
error = _u8L("Plugin directory does not contain a .py or .whl entry file");
error = PLUGIN_U8L("Plugin directory does not contain a .py or .whl entry file");
return {};
}
@@ -1260,34 +1259,34 @@ std::vector<std::string> collect_resource_texture_files(const std::string& dir,
error.clear();
if (dir.empty()) {
error = _u8L("Resource texture directory is not set");
error = PLUGIN_U8L("Resource texture directory is not set");
return files;
}
if (plugin_root.empty()) {
error = _u8L("Plugin folder could not be determined.");
error = PLUGIN_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 = _u8L("Resource texture directory could not be resolved");
error = PLUGIN_U8L("Resource texture directory could not be resolved");
return files;
}
if (!fs::is_directory(resolved_dir, ec) || ec) {
error = _u8L("Resource texture path is not a directory");
error = PLUGIN_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 = _u8L("Plugin folder could not be resolved");
error = PLUGIN_U8L("Plugin folder could not be resolved");
return files;
}
if (!is_inside_allowed_root(resolved_dir, resolved_root)) {
error = _u8L("Resource texture directory is outside the plugin folder");
error = PLUGIN_U8L("Resource texture directory is outside the plugin folder");
return files;
}
@@ -1312,7 +1311,7 @@ std::vector<std::string> collect_resource_texture_files(const std::string& dir,
}
if (ec) {
error = _u8L("Resource texture directory could not be read");
error = PLUGIN_U8L("Resource texture directory could not be read");
return {};
}
+24 -25
View File
@@ -11,8 +11,7 @@
#include "PythonPluginBridge.hpp"
#include "pluginTypes/resourceTexture/ResourceTexturePluginCapability.hpp"
#include "libslic3r/Utils.hpp"
#include "slic3r/GUI/I18N.hpp"
#include "slic3r/GUI/format.hpp"
#include "PluginText.hpp"
#include <boost/algorithm/string.hpp>
#include <boost/algorithm/string/case_conv.hpp>
@@ -102,7 +101,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 = GUI::format(_u8L("Plugin package must be a .py or .whl file, got: %1%"), ext);
error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin package must be a .py or .whl file, got: %1%"), ext);
return false;
}
@@ -179,14 +178,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 = GUI::format(_u8L("Failed to extract plugin .whl dependency %1%: %2%"), it->path().string(), extract_error);
error = PLUGIN_FORMAT(PLUGIN_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 = GUI::format(_u8L("Failed to add .whl dependency to sys.path: %1%"), syspath_error);
error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to add .whl dependency to sys.path: %1%"), syspath_error);
return false;
}
plugin_paths.push_back(dep_dir.string());
@@ -248,12 +247,12 @@ bool load(const PluginDescriptor& descriptor,
PythonInterpreter& interpreter = PythonInterpreter::instance();
if (!interpreter.is_initialized()) {
error = GUI::format(_u8L("Python interpreter not initialized: %1%"), interpreter.last_error());
error = PLUGIN_FORMAT(PLUGIN_U8L("Python interpreter not initialized: %1%"), interpreter.last_error());
return false;
}
if (!descriptor.is_metadata_valid()) {
error = GUI::format(_u8L("Plugin manifest is invalid: %1%"), descriptor.plugin_key);
error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin manifest is invalid: %1%"), descriptor.plugin_key);
if (descriptor.has_error())
error += " - " + descriptor.normalized_error();
return false;
@@ -274,7 +273,7 @@ bool load(const PluginDescriptor& descriptor,
if (!skip_deps) {
std::string pkg_install_error;
if (!install_packages(descriptor.dependencies, pkg_install_error)) {
error = GUI::format(_u8L("Failed to install plugin dependencies: %1%"), pkg_install_error);
error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to install plugin dependencies: %1%"), pkg_install_error);
return false;
}
}
@@ -293,7 +292,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 = GUI::format(_u8L("Failed to load plugin module: %1%"), load_error);
error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to load plugin module: %1%"), load_error);
return false;
}
@@ -308,13 +307,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 = GUI::format(_u8L("Plugin registration failed: %1%"), bridge_error);
error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin registration failed: %1%"), bridge_error);
if (registration_failed != nullptr)
*registration_failed = true;
return false;
}
if (capabilities_found.empty()) {
error = _u8L("Plugin module did not register any capabilities");
error = PLUGIN_U8L("Plugin module did not register any capabilities");
return false;
}
@@ -366,7 +365,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 = GUI::format(_u8L("Plugin declares duplicate capability '%1%' for type %2%"),
materialization_error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin declares duplicate capability '%1%' for type %2%"),
found.name, PluginConfig::capability_type_label(type));
break;
}
@@ -422,9 +421,9 @@ bool load(const PluginDescriptor& descriptor,
capabilities.push_back(std::move(instance));
}
} catch (const std::exception& ex) {
materialization_error = GUI::format(_u8L("Plugin capability materialization failed: %1%"), ex.what());
materialization_error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin capability materialization failed: %1%"), ex.what());
} catch (...) {
materialization_error = _u8L("Plugin capability materialization failed");
materialization_error = PLUGIN_U8L("Plugin capability materialization failed");
}
}
@@ -466,11 +465,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 = GUI::format(_u8L("Plugin on_load failed: %1%"), ex.what());
error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin on_load failed: %1%"), ex.what());
return false;
} catch (...) {
teardown_capabilities(plugin.capabilities, lifecycle_count);
error = _u8L("Plugin on_load failed");
error = PLUGIN_U8L("Plugin on_load failed");
return false;
}
@@ -651,7 +650,7 @@ bool install_plugin(const boost::filesystem::path& filepath,
bool backup_created = false;
auto fail = [&error](const std::string& message) {
error = GUI::format(_u8L("Plugin installation failed: %1%"), message);
error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin installation failed: %1%"), message);
return false;
};
@@ -659,23 +658,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(GUI::format(_u8L("Plugin package is not a file: %1%"), filepath.string()));
return fail(PLUGIN_FORMAT(PLUGIN_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(GUI::format(_u8L("Plugin package must be a .py or .whl file, got: %1%"), ext));
return fail(PLUGIN_FORMAT(PLUGIN_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(GUI::format(_u8L("Cloud plugin UUID is not a valid identifier: %1%"), cloud_uuid));
return fail(PLUGIN_FORMAT(PLUGIN_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(GUI::format(_u8L("Failed to create plugin directory %1%: %2%"), plugin_root.string(), ec.message()));
return fail(PLUGIN_FORMAT(PLUGIN_U8L("Failed to create plugin directory %1%: %2%"), plugin_root.string(), ec.message()));
std::string meta_error;
if (is_cloud_install) {
@@ -718,7 +717,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(GUI::format(_u8L("Failed to back up existing plugin %1%: %2%"), final_dir.string(), ec.message()));
return fail(PLUGIN_FORMAT(PLUGIN_U8L("Failed to back up existing plugin %1%: %2%"), final_dir.string(), ec.message()));
backup_created = true;
}
@@ -729,7 +728,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(GUI::format(_u8L("Failed to create plugin directory %1%: %2%"), final_dir.string(), ec.message()));
return fail(PLUGIN_FORMAT(PLUGIN_U8L("Failed to create plugin directory %1%: %2%"), final_dir.string(), ec.message()));
}
fs::copy_file(source_path, dest_file, ec);
@@ -740,7 +739,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(GUI::format(_u8L("Failed to copy plugin file to %1%: %2%"), dest_file.string(), ec.message()));
return fail(PLUGIN_FORMAT(PLUGIN_U8L("Failed to copy plugin file to %1%: %2%"), dest_file.string(), ec.message()));
}
// Update entry_path to the installed location.
@@ -760,7 +759,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(GUI::format(_u8L("Failed to write plugin install state: %1%"), (final_dir / INSTALL_STATE_FILE).string()));
return fail(PLUGIN_FORMAT(PLUGIN_U8L("Failed to write plugin install state: %1%"), (final_dir / INSTALL_STATE_FILE).string()));
}
}
-12
View File
@@ -53,18 +53,6 @@ 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
+22
View File
@@ -0,0 +1,22 @@
// 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
+44
View File
@@ -0,0 +1,44 @@
#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
+2 -2
View File
@@ -30,7 +30,7 @@
#include "pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp"
#include <pybind11/pytypes.h>
#include <pybind11/gil.h>
#include "slic3r/GUI/I18N.hpp"
#include "PluginText.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 = _u8L("Plugin filesystem read permission request denied");
error = PLUGIN_U8L("Plugin filesystem read permission request denied");
BOOST_LOG_TRIVIAL(warning) << error << " for key " << plugin_key;
discard_pending_for_key();
clear_active_key();
@@ -0,0 +1,4 @@
// 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"
@@ -0,0 +1,32 @@
// 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
@@ -0,0 +1,75 @@
// 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
@@ -0,0 +1,800 @@
// 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;
}
@@ -0,0 +1,97 @@
#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_
@@ -0,0 +1,32 @@
// 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
-15
View File
@@ -22,7 +22,6 @@
#include "libslic3r/TriangleMesh.hpp"
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <catch2/matchers/catch_matchers_string.hpp>
#include <catch2/catch_all.hpp>
@@ -795,20 +794,6 @@ TEST_CASE("Print statistics in custom G-code match the final print statistics",
}
}
TEST_CASE("Print statistics in custom G-code cannot be used in a calculation", "[Print]")
{
const std::string key = GENERATE(as<std::string>{}, "total_weight", "used_filament", "total_cost", "print_time", "normal_print_time",
"silent_print_time", "total_wipe_tower_cost", "total_wipe_tower_filament", "print_time_total_sec",
"print_time_day", "print_time_hour", "print_time_minute", "print_time_sec", "used_filament_length");
CAPTURE(key);
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({ { "file_start_gcode", "; {" + key + " + 1}\n" } });
Print print;
Model model;
init_print({ cube(20) }, print, model, config);
CHECK_THROWS_WITH(Slic3r::Test::gcode(print), Catch::Matchers::ContainsSubstring("known only after the G-code is generated"));
}
TEST_CASE("export_gcode writes G-code without a result pointer", "[Print][export_gcode]")
{
Print print;
-28
View File
@@ -220,39 +220,11 @@ TEST_CASE("An item larger than the bed cannot be placed", "[Arrange]")
ArrangePolygons items;
items.emplace_back(make_square(scaled(20.)));
items.emplace_back(make_square(scaled(400.))); // far bigger than the bed
// An item that cannot be placed keeps the position it came in with, so the
// caller can leave it where the user had it.
items[1].translation = {scaled(10.), scaled(20.)};
arrange(items, bed(200, 200), quiet_params(scaled(1.)));
REQUIRE(items[0].bed_idx == 0);
REQUIRE(items[1].bed_idx == UNARRANGED);
REQUIRE(items[1].translation.x() == scaled(10.));
REQUIRE(items[1].translation.y() == scaled(20.));
}
TEST_CASE("An item filling the printable area is arranged", "[Arrange]")
{
// The arrange bed keeps a margin from the printable area and the item is widened by
// its brim. Neither may keep an item that fits the printable area from being placed.
DynamicPrintConfig cfg = bed_config(); // 200 x 200
ArrangeParams p = quiet_params();
ArrangePolygons items = squares(1, 197.);
items.front().brim_width = 1.;
update_arrange_params(p, &cfg, items);
update_selected_items_inflation(items, &cfg, p);
arrange(items, get_shrink_bedpts(&cfg, p), p);
REQUIRE(items.front().bed_idx == 0);
// Larger than the printable area is still refused.
ArrangeParams p_large = quiet_params();
ArrangePolygons large = squares(1, 201.);
update_arrange_params(p_large, &cfg, large);
update_selected_items_inflation(large, &cfg, p_large);
arrange(large, get_shrink_bedpts(&cfg, p_large), p_large);
REQUIRE(large.front().bed_idx == UNARRANGED);
}
TEST_CASE("Items overflowing one bed spill onto virtual beds", "[Arrange]")
-17
View File
@@ -187,23 +187,6 @@ TEST_CASE("A part rests on its largest hull face even when parallel inner faces
check_on_bed(plate);
}
TEST_CASE("A box laid on its top face is turned over about X", "[LayOnFace]")
{
// A face pointing straight up leaves the half turn's axis open to every horizontal
// direction. It must be the fixed X flip both times, keep the footprint and not follow
// rounding noise to an arbitrary angle.
Model model;
ModelObject &box = add_box_object(model, { 40, 20, 10 });
lay_on_face(box, 0, { 0, 0, 1 });
check_size(box, { 40, 20, 10 });
check_on_bed(box);
const Matrix3d rotation = box.instances.front()->get_matrix_no_offset().linear();
CHECK_THAT(rotation(0, 0), WithinAbs(1., 1e-9));
CHECK_THAT(rotation(1, 1), WithinAbs(-1., 1e-9));
CHECK_THAT(rotation(2, 2), WithinAbs(-1., 1e-9));
}
TEST_CASE("Faces are selected in object coordinates whatever the instance rotation", "[LayOnFace]")
{
Model model;
@@ -5,7 +5,6 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_message.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_string.hpp>
#include "libslic3r/PlaceholderParser.hpp"
@@ -399,53 +398,3 @@ SCENARIO("Placeholder parser names in branches that are not taken", "[Placeholde
CHECK(PlaceholderParser::evaluate_boolean_expression("false ? no_such_var == 1 : true", config));
}
}
TEST_CASE("Placeholder parser deferred values can be printed but not computed with", "[PlaceholderParser]") {
PlaceholderParser parser;
parser.set_deferred("total_weight", "@TOTAL_WEIGHT@");
parser.set_deferred("used_filament", "@USED_FILAMENT_LENGTH@");
parser.set("label", "W");
PlaceholderParser::ContextData context;
context.global_config = std::make_unique<DynamicConfig>();
auto process = [&parser, &context](const std::string &templ) { return parser.process(templ, 0, nullptr, nullptr, &context); };
SECTION("printed") {
CHECK(process("{total_weight}") == "@TOTAL_WEIGHT@");
CHECK(process("[total_weight]") == "@TOTAL_WEIGHT@");
CHECK(process("{label + \": \" + total_weight + \"g\"}") == "W: @TOTAL_WEIGHT@g");
CHECK(process("{total_weight + \"g, \" + used_filament + \"m\"}") == "@TOTAL_WEIGHT@g, @USED_FILAMENT_LENGTH@m");
CHECK(process("{\"W \" + total_weight + \"g for \" + 3 + \" parts\"}") == "W @TOTAL_WEIGHT@g for 3 parts");
CHECK(process("{true ? total_weight : 0}") == "@TOTAL_WEIGHT@");
CHECK(process("{regex_replace(\"x\", /x/, total_weight)}") == "@TOTAL_WEIGHT@");
CHECK(process("{local w = total_weight}{w}") == "@TOTAL_WEIGHT@");
}
SECTION("used in a calculation or a comparison") {
const auto deferred_error = Catch::Matchers::ContainsSubstring("known only after the G-code is generated");
for (const std::string templ : {
"{total_weight + 1}", "{1 + total_weight}", "{total_weight * 2}", "{-total_weight}", "{int(total_weight)}",
"{min(total_weight, 5)}", "{digits(total_weight, 5)}", "{total_weight + used_filament}",
"{if total_weight > 100}heavy{endif}", "{if total_weight == \"1\"}one{endif}", "{total_weight and true}",
"{total_weight ? 1 : 0}", "{if total_weight =~ /1.*/}one{endif}", "{one_of(total_weight, \"1\")}",
"{regex_replace(total_weight, /1/, \"2\")}", "{(\"W\" + total_weight) == \"W1\"}", "{filament_change(total_weight)}",
"{regex_replace(\"x\", /x/, total_weight) * 2}", "{if regex_replace(\"x\", /x/, \"W\" + total_weight) > \"W1\"}heavy{endif}",
// A deferred value copied to a variable is still deferred.
"{local w = total_weight}{w > 1}", "{global g = \"W\" + total_weight}{if g == \"W1\"}one{endif}" }) {
CAPTURE(templ);
CHECK_THROWS_WITH(process(templ), deferred_error);
}
// A mistake unrelated to the value being deferred keeps its own message.
CHECK_THROWS_WITH(process("{global v = (1, 2)}{global v = \"W\" + total_weight}"),
Catch::Matchers::ContainsSubstring("Cannot assign a scalar value to a vector variable"));
}
SECTION("ignored in a branch that is not taken") {
CHECK(process("{if false}{total_weight * 2}{endif}ok") == "ok");
struct Restore { ~Restore() { PlaceholderParser::check_inactive_branches = false; } } restore;
PlaceholderParser::check_inactive_branches = true;
CHECK(process("{if false}{total_weight * 2}{endif}ok") == "ok");
}
SECTION("other strings keep their behavior") {
CHECK(process("{label + 1}") == "W1");
CHECK(process("{if label > \"A\"}yes{endif}") == "yes");
CHECK(process("{if regex_replace(label, /X/, total_weight) == \"W\"}yes{endif}") == "yes");
}
}
+88 -34
View File
@@ -3,11 +3,13 @@
#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>
@@ -32,6 +34,7 @@
#include <pybind11/pytypes.h>
#include <pybind11/stl.h>
#include <algorithm>
#include <fstream>
#include <functional>
#include <ios>
@@ -67,6 +70,26 @@ 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]")
@@ -667,6 +690,71 @@ 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;
@@ -1132,37 +1220,3 @@ TEST_CASE("Stdlib code is recognized through a symlinked Python home", "[audit][
CHECK_FALSE(PluginAuditManager::is_stdlib_file((harness.outside.path() / "plugin.py").string()));
CHECK_FALSE(PluginAuditManager::is_stdlib_file("<string>"));
}
TEST_CASE("A plugin may read the CPython stdlib without a prompt, but not site-packages or for writing", "[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);
const py::dict paths = py::module_::import("sysconfig").attr("get_paths")();
const fs::path stdlib = py::str(paths["stdlib"]).cast<std::string>();
const fs::path purelib = py::str(paths["purelib"]).cast<std::string>();
// os.py ships with every CPython installation. A site-packages path is third-party code even
// when the directory is empty: the prompt must come before the missing file is noticed.
const fs::path stdlib_file = stdlib / "os.py";
REQUIRE(fs::exists(stdlib_file));
python.globals["STDLIB_FILE"] = stdlib_file.string();
python.globals["SITE_PACKAGE_FILE"] = (purelib / "regression_probe.py").string();
python.run_as(PLUGIN, R"PY(
read_outcome = attempt(lambda: open(STDLIB_FILE).read())
write_outcome = attempt(lambda: open(STDLIB_FILE, "r+").close())
site_packages_outcome = attempt(lambda: open(SITE_PACKAGE_FILE).close())
)PY");
CHECK(python.str("read_outcome") == "ok");
CHECK(python.str("write_outcome") == "denied");
CHECK(python.str("site_packages_outcome") == "denied");
// Only the write and the site-packages read are asked about; the stdlib read is implicit.
const std::string write_prompt = "open: " + python.str("STDLIB_FILE") + "\n";
const std::string site_packages_prompt = "open: " + python.str("SITE_PACKAGE_FILE") + "\n";
CHECK(describe(harness.prompts) == write_prompt + site_packages_prompt);
}