mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-20 14:08:20 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aecd76594f | ||
|
|
e9d1bb85b8 |
No files matched your search
@@ -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)
|
||||
|
||||
+16
-41
@@ -377,8 +377,7 @@ public:
|
||||
virtual void set(const ConfigOption* rhs, size_t start, size_t len) = 0;
|
||||
virtual void set_with_restore(const ConfigOptionVectorBase* rhs, std::vector<int>& restore_index, int stride) = 0;
|
||||
virtual void set_with_restore_2(const ConfigOptionVectorBase* rhs, std::vector<int>& restore_index, int start, int len, bool skip_error = false) = 0;
|
||||
// keep_nil: copy a nil column from rhs as well, for options whose nil means "disabled" rather than "inherit".
|
||||
virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector<int>& diff_index, int stride, bool keep_nil = false) = 0;
|
||||
virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector<int>& diff_index, int stride) = 0;
|
||||
virtual void set_to_index(const ConfigOptionVectorBase* rhs, std::vector<int>& dest_index, int stride) = 0;
|
||||
virtual void set_with_nil(const ConfigOptionVectorBase* rhs, const ConfigOptionVectorBase* inherits, int stride) = 0;
|
||||
// Resize the vector of values, copy the newly added values from opt_default if provided.
|
||||
@@ -581,7 +580,7 @@ public:
|
||||
//set a item related with extruder variants when loading user config, only set the different value of some extruder
|
||||
//rhs: item from user config
|
||||
//diff_index: which index in this vector need to be set
|
||||
virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector<int>& diff_index, int stride, bool keep_nil) override
|
||||
virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector<int>& diff_index, int stride) override
|
||||
{
|
||||
if (rhs->type() == this->type()) {
|
||||
// Assign the first value of the rhs vector.
|
||||
@@ -593,7 +592,7 @@ public:
|
||||
for (size_t i = 0; i < diff_index.size(); i++) {
|
||||
if (diff_index[i] != -1) {
|
||||
for (size_t j = 0; j < stride; j++) {
|
||||
if (keep_nil || !other->is_nil(diff_index[i] * stride))
|
||||
if (!other->is_nil(diff_index[i] * stride))
|
||||
this->values[i * stride + j] = other->values[diff_index[i] * stride + j];
|
||||
}
|
||||
}
|
||||
@@ -842,12 +841,8 @@ public:
|
||||
{
|
||||
UNUSED(append);
|
||||
std::istringstream iss(str);
|
||||
double value;
|
||||
iss >> value;
|
||||
if (iss.fail())
|
||||
return false;
|
||||
this->value = value;
|
||||
return true;
|
||||
iss >> this->value;
|
||||
return !iss.fail();
|
||||
}
|
||||
|
||||
bool operator==(const ConfigOption &rhs) const override
|
||||
@@ -1038,12 +1033,8 @@ public:
|
||||
{
|
||||
UNUSED(append);
|
||||
std::istringstream iss(str);
|
||||
int value;
|
||||
iss >> value;
|
||||
if (iss.fail())
|
||||
return false;
|
||||
this->value = value;
|
||||
return true;
|
||||
iss >> this->value;
|
||||
return !iss.fail();
|
||||
}
|
||||
|
||||
ConfigOptionInt& operator=(const ConfigOption *opt)
|
||||
@@ -1261,12 +1252,8 @@ public:
|
||||
UNUSED(append);
|
||||
// don't try to parse the trailing % since it's optional
|
||||
std::istringstream iss(str);
|
||||
double value;
|
||||
iss >> value;
|
||||
if (iss.fail())
|
||||
return false;
|
||||
this->value = value;
|
||||
return true;
|
||||
iss >> this->value;
|
||||
return !iss.fail();
|
||||
}
|
||||
|
||||
private:
|
||||
@@ -1378,14 +1365,10 @@ public:
|
||||
bool deserialize(const std::string &str, bool append = false) override
|
||||
{
|
||||
UNUSED(append);
|
||||
std::istringstream iss(str);
|
||||
double value;
|
||||
iss >> value;
|
||||
if (iss.fail())
|
||||
return false;
|
||||
this->value = value;
|
||||
this->percent = str.find_first_of("%") != std::string::npos;
|
||||
return true;
|
||||
std::istringstream iss(str);
|
||||
iss >> this->value;
|
||||
return !iss.fail();
|
||||
}
|
||||
|
||||
private:
|
||||
@@ -1555,12 +1538,8 @@ public:
|
||||
{
|
||||
UNUSED(append);
|
||||
char dummy;
|
||||
Vec2d value;
|
||||
if (sscanf(str.data(), " %lf , %lf %c", &value(0), &value(1), &dummy) != 2 &&
|
||||
sscanf(str.data(), " %lf x %lf %c", &value(0), &value(1), &dummy) != 2)
|
||||
return false;
|
||||
this->value = value;
|
||||
return true;
|
||||
return sscanf(str.data(), " %lf , %lf %c", &this->value(0), &this->value(1), &dummy) == 2 ||
|
||||
sscanf(str.data(), " %lf x %lf %c", &this->value(0), &this->value(1), &dummy) == 2;
|
||||
}
|
||||
|
||||
private:
|
||||
@@ -1679,12 +1658,8 @@ public:
|
||||
{
|
||||
UNUSED(append);
|
||||
char dummy;
|
||||
Vec3d value;
|
||||
if (sscanf(str.data(), " %lf , %lf , %lf %c", &value(0), &value(1), &value(2), &dummy) != 3 &&
|
||||
sscanf(str.data(), " %lf x %lf x %lf %c", &value(0), &value(1), &value(2), &dummy) != 3)
|
||||
return false;
|
||||
this->value = value;
|
||||
return true;
|
||||
return sscanf(str.data(), " %lf , %lf , %lf %c", &this->value(0), &this->value(1), &this->value(2), &dummy) == 3 ||
|
||||
sscanf(str.data(), " %lf x %lf x %lf %c", &this->value(0), &this->value(1), &this->value(2), &dummy) == 3;
|
||||
}
|
||||
|
||||
private:
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
@@ -797,7 +797,7 @@ bool Preset::save(DynamicPrintConfig* parent_config)
|
||||
{
|
||||
ConfigOption *opt_src = config.option(option);
|
||||
ConfigOption *opt_dst = temp_config.option(option, true);
|
||||
if (opt_dst->is_scalar() || !(opt_dst->nullable()) || is_filament_extruder_override_key(option))
|
||||
if (opt_dst->is_scalar() || !(opt_dst->nullable()))
|
||||
opt_dst->set(opt_src);
|
||||
else {
|
||||
ConfigOptionVectorBase* opt_vec_src = static_cast<ConfigOptionVectorBase*>(opt_src);
|
||||
@@ -2132,7 +2132,7 @@ Preset* PresetCollection::get_preset_differed_for_save(Preset& preset)
|
||||
{
|
||||
ConfigOption *opt_src = preset.config.option(option);
|
||||
ConfigOption *opt_dst = temp_config.option(option, true);
|
||||
if (opt_dst->is_scalar() || !(opt_dst->nullable()) || is_filament_extruder_override_key(option))
|
||||
if (opt_dst->is_scalar() || !(opt_dst->nullable()))
|
||||
opt_dst->set(opt_src);
|
||||
else {
|
||||
ConfigOptionVectorBase* opt_vec_src = static_cast<ConfigOptionVectorBase*>(opt_src);
|
||||
@@ -2199,12 +2199,9 @@ int PresetCollection::get_differed_values_to_update(Preset& preset, std::map<std
|
||||
// option's non-nil default (e.g. filament_retract_before_wipe -> 100%), silently
|
||||
// resurrecting an override the user turned off. See GitHub issue on Retract Before Wipe.
|
||||
if (m_type == Preset::TYPE_FILAMENT) {
|
||||
for (const std::string& opt_key : preset.config.keys()) {
|
||||
if (!is_filament_extruder_override_key(opt_key) || key_values.count(opt_key))
|
||||
continue; // not an override, or already carried by the diff
|
||||
const ConfigOptionDef *def = print_config_def.get(opt_key);
|
||||
if (def == nullptr || !def->default_value || def->default_value->is_nil())
|
||||
continue; // the option's default is nil too, so an absent key cannot come back as a value
|
||||
for (const std::string& opt_key : filament_extruder_override_keys) {
|
||||
if (key_values.count(opt_key))
|
||||
continue; // already carried by the diff
|
||||
const auto* opt_vec = dynamic_cast<const ConfigOptionVectorBase*>(preset.config.option(opt_key));
|
||||
if (opt_vec == nullptr)
|
||||
continue;
|
||||
|
||||
@@ -119,18 +119,12 @@ const std::vector<std::string> filament_extruder_override_keys = {
|
||||
};
|
||||
|
||||
// Some filament override parameters are generated from filament_extruder_override_keys,
|
||||
// while filament_retract_length_nc and the ironing overrides are defined separately.
|
||||
// Keep the generator list unchanged and use this helper for behavior checks that need
|
||||
// the full override set. A nil in one of these keys means the override is disabled, so
|
||||
// child presets store and load it verbatim instead of treating nil as "inherit".
|
||||
// while filament_retract_length_nc is defined separately. Keep the generator list
|
||||
// unchanged and use this helper for behavior checks that need the full override set.
|
||||
bool is_filament_extruder_override_key(const std::string &opt_key)
|
||||
{
|
||||
return std::find(filament_extruder_override_keys.begin(), filament_extruder_override_keys.end(), opt_key) != filament_extruder_override_keys.end() ||
|
||||
opt_key == "filament_retract_length_nc" ||
|
||||
opt_key == "filament_ironing_flow" ||
|
||||
opt_key == "filament_ironing_spacing" ||
|
||||
opt_key == "filament_ironing_inset" ||
|
||||
opt_key == "filament_ironing_speed";
|
||||
opt_key == "filament_retract_length_nc";
|
||||
}
|
||||
|
||||
size_t get_extruder_index(const GCodeConfig& config, unsigned int filament_id)
|
||||
@@ -12278,8 +12272,7 @@ void DynamicPrintConfig::update_diff_values_to_child_config(DynamicPrintConfig&
|
||||
opt_src->set(opt_target);
|
||||
}
|
||||
else
|
||||
// An override's nil means "disabled" and is the child's value; every other nil column inherits.
|
||||
opt_vec_src->set_only_diff(opt_vec_dest, variant_index, stride, is_filament_extruder_override_key(opt));
|
||||
opt_vec_src->set_only_diff(opt_vec_dest, variant_index, stride);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -11034,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
|
||||
|
||||
@@ -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>
|
||||
@@ -1473,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());
|
||||
}
|
||||
|
||||
@@ -1495,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)
|
||||
@@ -1525,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
|
||||
@@ -1539,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);
|
||||
|
||||
@@ -1570,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));
|
||||
};
|
||||
|
||||
@@ -1581,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; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
#include <boost/log/trivial.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <cctype>
|
||||
#include <cstddef>
|
||||
#include <cstdlib>
|
||||
@@ -131,20 +130,6 @@ static AuditEventCategory event_category(const std::string& event_name)
|
||||
return it == audit_event_categories.end() ? AuditEventCategory::None : it->second;
|
||||
}
|
||||
|
||||
// Events the 3.12 stdlib raises with sys.audit() inside the function that performs the whole
|
||||
// operation, so the raising frame bounds everything the operation does. Every other mapped event
|
||||
// is a primitive raised from C, whose current frame is whatever Python code called it -- possibly
|
||||
// a library loop over many targets -- so it never marks an operation. glob.glob and glob.glob/2
|
||||
// are raised in iglob(), which returns its lazy generator before any directory is listed.
|
||||
static const std::unordered_set<std::string> operation_root_events{
|
||||
"urllib.Request", "http.client.connect", "os.walk", "os.fwalk",
|
||||
"glob.glob", "glob.glob/2", "pathlib.Path.glob", "pathlib.Path.rglob",
|
||||
"subprocess.Popen", "pty.spawn", "tempfile.mkstemp", "tempfile.mkdtemp",
|
||||
"shutil.copyfile", "shutil.copymode", "shutil.copystat", "shutil.copytree",
|
||||
"shutil.chown", "shutil.move", "shutil.rmtree", "shutil.make_archive",
|
||||
"shutil.unpack_archive",
|
||||
};
|
||||
|
||||
// True for the categories whose targets are filesystem paths, as opposed to a network
|
||||
// address or a process command line -- the deny-path and allowed-root checks only make sense
|
||||
// against a path.
|
||||
@@ -161,24 +146,6 @@ static bool is_fs_category(AuditEventCategory category)
|
||||
}
|
||||
}
|
||||
|
||||
// The category's name in a saved event grant, e.g. "FsRead" in "FsRead:open".
|
||||
static const char* 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";
|
||||
case AuditEventCategory::None: break;
|
||||
}
|
||||
return "None";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path safety
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -248,50 +215,26 @@ 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::vector<AllowedRoot> PluginAuditManager::m_scoped_allowed_roots;
|
||||
thread_local std::vector<PyObject*> PluginAuditManager::m_root_frames;
|
||||
thread_local int PluginAuditManager::m_releasing_frames = 0;
|
||||
thread_local bool PluginAuditManager::m_audit_denial_pending = false;
|
||||
thread_local bool PluginAuditManager::m_has_last_violation = false;
|
||||
thread_local AuditViolation PluginAuditManager::m_last_violation;
|
||||
|
||||
ScopedPluginAuditContext::ScopedPluginAuditContext(const std::string& plugin_key,
|
||||
const std::string& capability_name)
|
||||
: m_plugin_key(plugin_key)
|
||||
, m_capability(capability_name)
|
||||
, m_previous_id(PluginAuditManager::instance().current_plugin())
|
||||
: m_previous_id(PluginAuditManager::instance().current_plugin())
|
||||
, m_previous_capability(PluginAuditManager::instance().current_capability())
|
||||
, m_previous_scoped_roots(PluginAuditManager::m_scoped_allowed_roots)
|
||||
, m_previous_root_frames(std::move(PluginAuditManager::m_root_frames))
|
||||
{
|
||||
PluginAuditManager::instance().set_current_plugin(plugin_key);
|
||||
PluginAuditManager::instance().set_current_capability(capability_name);
|
||||
PluginAuditManager::m_scoped_allowed_roots.clear();
|
||||
PluginAuditManager::m_root_frames.clear();
|
||||
}
|
||||
|
||||
ScopedPluginAuditContext::~ScopedPluginAuditContext()
|
||||
{
|
||||
PluginAuditManager& mgr = PluginAuditManager::instance();
|
||||
|
||||
// Let this scope's root frames go while it is still the current context, and as its own
|
||||
// plugin: the trampoline's denial path clears the key before the scope ends, and a finalizer
|
||||
// must never be judged as the outer plugin, or bypass the audit when there is none. A
|
||||
// finalizer may record a new root frame, so repeat until none is held.
|
||||
if (!PluginAuditManager::m_root_frames.empty()) {
|
||||
assert(PyGILState_Check());
|
||||
mgr.set_current_plugin(m_plugin_key);
|
||||
mgr.set_current_capability(m_capability);
|
||||
while (!PluginAuditManager::m_root_frames.empty()) {
|
||||
std::vector<PyObject*> frames = std::move(PluginAuditManager::m_root_frames);
|
||||
PluginAuditManager::m_root_frames.clear();
|
||||
mgr.release_frames(frames);
|
||||
}
|
||||
}
|
||||
|
||||
mgr.set_current_plugin(m_previous_id);
|
||||
mgr.set_current_capability(m_previous_capability);
|
||||
PluginAuditManager::instance().set_current_plugin(m_previous_id);
|
||||
PluginAuditManager::instance().set_current_capability(m_previous_capability);
|
||||
PluginAuditManager::m_scoped_allowed_roots = std::move(m_previous_scoped_roots);
|
||||
PluginAuditManager::m_root_frames = std::move(m_previous_root_frames);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -632,6 +575,34 @@ bool PluginAuditManager::last_violation(AuditViolation& violation) const
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PluginAuditManager::has_approved_ancestor(const std::string& plugin_key,
|
||||
const std::vector<std::string>& call_site_ids) const
|
||||
{
|
||||
if (plugin_key.empty() || call_site_ids.empty())
|
||||
return false;
|
||||
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
auto it = m_approved_call_sites.find(plugin_key);
|
||||
if (it == m_approved_call_sites.end())
|
||||
return false;
|
||||
|
||||
for (const auto& id : call_site_ids)
|
||||
if (it->second.count(id))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
void PluginAuditManager::record_approved_call_sites(const std::string& plugin_key,
|
||||
const std::vector<std::string>& call_site_ids)
|
||||
{
|
||||
if (plugin_key.empty() || call_site_ids.empty())
|
||||
return;
|
||||
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
auto& approved = m_approved_call_sites[plugin_key];
|
||||
approved.insert(call_site_ids.begin(), call_site_ids.end());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The C-level audit hook
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -692,88 +663,40 @@ std::string python_str(PyObject* object)
|
||||
return result;
|
||||
}
|
||||
|
||||
// The interpreter's stdlib folders, and the site-packages folders inside them that hold
|
||||
// third-party code, both resolved. Set by install_hook(); like the verdict cache below, only
|
||||
// touched while the GIL is held.
|
||||
static std::vector<boost::filesystem::path> s_stdlib_dirs;
|
||||
static std::vector<boost::filesystem::path> s_site_package_dirs;
|
||||
static std::unordered_map<std::string, bool> s_stdlib_file_verdicts; // co_filename -> is stdlib
|
||||
|
||||
void collect_stdlib_dirs()
|
||||
std::vector<std::string> call_site_identities(const std::string& plugin_root)
|
||||
{
|
||||
s_stdlib_dirs.clear();
|
||||
s_site_package_dirs.clear();
|
||||
s_stdlib_file_verdicts.clear();
|
||||
std::vector<std::string> ids;
|
||||
if (plugin_root.empty())
|
||||
return ids;
|
||||
|
||||
PyObject* sysconfig = PyImport_ImportModule("sysconfig");
|
||||
PyObject* paths = sysconfig ? PyObject_CallMethod(sysconfig, "get_paths", nullptr) : nullptr;
|
||||
if (paths && PyDict_Check(paths)) {
|
||||
const auto collect = [paths](const char* key, std::vector<boost::filesystem::path>& out) {
|
||||
const std::string dir = python_unicode(PyDict_GetItemString(paths, key)); // borrowed
|
||||
if (dir.empty())
|
||||
return;
|
||||
boost::system::error_code ec;
|
||||
const boost::filesystem::path resolved = boost::filesystem::weakly_canonical(dir, ec);
|
||||
out.push_back(ec ? boost::filesystem::path(dir) : resolved);
|
||||
};
|
||||
collect("stdlib", s_stdlib_dirs);
|
||||
collect("platstdlib", s_stdlib_dirs);
|
||||
collect("purelib", s_site_package_dirs);
|
||||
collect("platlib", s_site_package_dirs);
|
||||
}
|
||||
Py_XDECREF(paths);
|
||||
Py_XDECREF(sysconfig);
|
||||
if (PyErr_Occurred()) {
|
||||
PyErr_Clear();
|
||||
BOOST_LOG_TRIVIAL(error) << "[AUDIT] Failed to read the stdlib folders from sysconfig";
|
||||
}
|
||||
}
|
||||
|
||||
bool is_stdlib_frame(PyObject* frame)
|
||||
{
|
||||
PyCodeObject* code = PyFrame_GetCode(reinterpret_cast<PyFrameObject*>(frame)); // new reference
|
||||
const std::string filename = python_unicode(code->co_filename);
|
||||
Py_DECREF(code);
|
||||
return PluginAuditManager::is_stdlib_file(filename);
|
||||
}
|
||||
|
||||
// The frames on this thread's Python stack, innermost first. The pointers stay valid while the
|
||||
// caller runs, because a running activation keeps its frame object alive.
|
||||
std::vector<PyObject*> stack_frames()
|
||||
{
|
||||
std::vector<PyObject*> frames;
|
||||
PyFrameObject* frame = PyEval_GetFrame(); // borrowed reference
|
||||
Py_XINCREF(frame); // normalize to an owned reference for the loop below
|
||||
|
||||
while (frame) {
|
||||
frames.push_back(reinterpret_cast<PyObject*>(frame));
|
||||
PyCodeObject* code = PyFrame_GetCode(frame); // new reference
|
||||
const std::string filename = python_unicode(reinterpret_cast<PyObject*>(code->co_filename));
|
||||
const bool is_plugin_frame = !filename.empty() && boost::algorithm::starts_with(filename, plugin_root);
|
||||
|
||||
std::string id;
|
||||
if (!is_plugin_frame && !filename.empty()) {
|
||||
const std::string funcname = python_unicode(reinterpret_cast<PyObject*>(code->co_name));
|
||||
id = filename + ":" + funcname + ":" + std::to_string(code->co_firstlineno);
|
||||
}
|
||||
Py_DECREF(code);
|
||||
|
||||
PyFrameObject* back = PyFrame_GetBack(frame); // new reference, or nullptr at the top of the stack
|
||||
Py_DECREF(frame);
|
||||
frame = back;
|
||||
|
||||
if (is_plugin_frame)
|
||||
break;
|
||||
if (!id.empty())
|
||||
ids.push_back(std::move(id));
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
// Moves the held root frames whose operation has returned from `held` to `dropped`. A suspended
|
||||
// generator's frame (os.walk between two items) is off the stack, but its operation resumes, so
|
||||
// it stays held until the scope ends.
|
||||
void take_returned_frames(std::vector<PyObject*>& held, std::vector<PyObject*>& dropped)
|
||||
{
|
||||
if (held.empty())
|
||||
return;
|
||||
Py_XDECREF(frame); // only holds a reference here if the loop exited via break
|
||||
|
||||
const std::vector<PyObject*> stack = stack_frames();
|
||||
const auto running = [&stack](PyObject* frame) {
|
||||
if (std::find(stack.begin(), stack.end(), frame) != stack.end())
|
||||
return true;
|
||||
// NULL once the generator has finished or been deleted: its frame then owns its own data.
|
||||
PyObject* generator = PyFrame_GetGenerator(reinterpret_cast<PyFrameObject*>(frame)); // new reference
|
||||
const bool suspended = generator != nullptr;
|
||||
Py_XDECREF(generator);
|
||||
return suspended;
|
||||
};
|
||||
const auto returned = std::stable_partition(held.begin(), held.end(), running);
|
||||
dropped.insert(dropped.end(), returned, held.end());
|
||||
held.erase(returned, held.end());
|
||||
return ids;
|
||||
}
|
||||
|
||||
static const std::unordered_set<std::string> two_path_fs_events{
|
||||
@@ -794,15 +717,12 @@ static const std::unordered_map<std::string, std::vector<Py_ssize_t>> audit_targ
|
||||
{"socket.getnameinfo", {0}},
|
||||
{"socket.getservbyname", {0}},
|
||||
{"socket.getservbyport", {0}},
|
||||
{"socket.sendto", {1}},
|
||||
{"socket.sendmsg", {1}},
|
||||
|
||||
{"os.system", {0}},
|
||||
{"subprocess.Popen", {1, 0}},
|
||||
{"os.posix_spawn", {1, 0}},
|
||||
{"os.spawn", {2, 1}},
|
||||
{"os.startfile", {0}},
|
||||
{"os.startfile/2", {0}},
|
||||
{"pty.spawn", {0}},
|
||||
{"_winapi.CreateProcess", {1, 0}},
|
||||
{"_posixsubprocess.fork_exec", {0}},
|
||||
@@ -847,11 +767,7 @@ std::vector<std::string> audit_targets(const std::string& event_name, AuditEvent
|
||||
const auto it = audit_target_arg_indices.find(event_name);
|
||||
if (it != audit_target_arg_indices.end()) {
|
||||
for (const Py_ssize_t index : it->second) {
|
||||
// None is an argument left out (sendmsg without an address), not a target named "None".
|
||||
PyObject* const item = tuple_item(args, index);
|
||||
if (item == Py_None)
|
||||
continue;
|
||||
std::string value = python_str(item);
|
||||
std::string value = python_str(tuple_item(args, index));
|
||||
if (!value.empty()) {
|
||||
targets.push_back(std::move(value));
|
||||
break;
|
||||
@@ -862,14 +778,8 @@ 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)
|
||||
std::vector<std::string>* permission_list_for(AuditEventCategory category, PluginPermissions& permissions)
|
||||
{
|
||||
if (by_event)
|
||||
return &permissions.events;
|
||||
|
||||
switch (category) {
|
||||
case AuditEventCategory::FsRead: return &permissions.fs_read;
|
||||
case AuditEventCategory::FsReadWrite: return &permissions.fs_readwrite;
|
||||
@@ -882,13 +792,6 @@ std::vector<std::string>* permission_list_for(AuditEventCategory category, bool
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
@@ -999,25 +902,22 @@ bool prompt_for_targets(AuditEventCategory category, const std::string& plugin_n
|
||||
|
||||
// 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)
|
||||
void persist_grant(const std::string& plugin_key, AuditEventCategory category, const std::vector<std::string>& targets)
|
||||
{
|
||||
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);
|
||||
std::vector<std::string>* permission_list = permission_list_for(category, state.permissions);
|
||||
if (!permission_list)
|
||||
return;
|
||||
|
||||
for (const auto& key : keys)
|
||||
persist_permission(plugin_key, state, *permission_list, key);
|
||||
for (const auto& target : targets)
|
||||
persist_permission(plugin_key, state, *permission_list, target);
|
||||
}
|
||||
|
||||
static PluginAuditManager::PermissionPrompt s_permission_prompt_for_testing;
|
||||
|
||||
// Asks the user to grant an audited event, returning true when they approve an inline prompt.
|
||||
// `keys` are what a Yes saves: the unresolved targets, or the event-grant key when the event has
|
||||
// no target (by_event), which the prompt does not show as a target.
|
||||
// Requests permission for an audited event, returning true when it is already granted or the user
|
||||
// approves an inline prompt.
|
||||
//
|
||||
// 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
|
||||
@@ -1029,205 +929,56 @@ static PluginAuditManager::PermissionPrompt s_permission_prompt_for_testing;
|
||||
// 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::string& event_name, const std::vector<std::string>& unresolved)
|
||||
{
|
||||
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);
|
||||
return prompt_for_targets(category, plugin_name, event_name, unresolved);
|
||||
|
||||
GUI::wxGetApp().CallAfter([category, plugin_key, plugin_name, event_name, by_event, keys, shown]() {
|
||||
GUI::wxGetApp().CallAfter([category, plugin_key, plugin_name, event_name, unresolved]() {
|
||||
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);
|
||||
if (prompt_for_targets(category, plugin_name, event_name, unresolved))
|
||||
persist_grant(plugin_key, category, unresolved);
|
||||
});
|
||||
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 std::vector<std::string>& targets,
|
||||
std::vector<std::string>* permission_list,
|
||||
const std::vector<std::string>& call_site_ids)
|
||||
{
|
||||
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;
|
||||
std::vector<std::string> unresolved = targets;
|
||||
if (permission_list) {
|
||||
unresolved.clear();
|
||||
for (const auto& key : keys)
|
||||
if (!has_permission(*permission_list, key))
|
||||
unresolved.push_back(key);
|
||||
if (unresolved.empty())
|
||||
for (const auto& target : targets)
|
||||
if (!has_permission(*permission_list, target))
|
||||
unresolved.push_back(target);
|
||||
if (!targets.empty() && 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))
|
||||
if (!request_permission(category, plugin_key, plugin_name, event_name, 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);
|
||||
for (const auto& target : unresolved)
|
||||
persist_permission(plugin_key, state, *permission_list, target);
|
||||
|
||||
mgr.record_approved_call_sites(plugin_key, call_site_ids);
|
||||
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);
|
||||
|
||||
// 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,
|
||||
// so an allowed root (e.g. the read-only resources folder) cannot make a denied path
|
||||
// underneath it reachable.
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// A nested event of an operation already granted, e.g. socket.connect inside an approved
|
||||
// urlopen(), is not asked again. urllib.Request is always judged on its own URL, so a
|
||||
// redirect or a nested urlopen() inside a running request is not covered by it.
|
||||
if (event_name != "urllib.Request" && mgr.covered_by_root_frame())
|
||||
return 0;
|
||||
|
||||
// A filesystem target that resolves entirely inside a pre-determined allowed root -- the
|
||||
// plugin system's own data_dir() tree (which holds each plugin's storage folder and the
|
||||
// installed/system profile cache), the read-only bundled resources folder, or a per-call
|
||||
// scoped root such as the current G-code folder -- is part of the plugin system's normal
|
||||
// workflow and does not need a prompt.
|
||||
if (fs_category && !targets.empty()) {
|
||||
const bool is_write = event_type != AuditEventCategory::FsRead;
|
||||
const bool all_inside_allowed_root =
|
||||
std::all_of(targets.begin(), targets.end(), [&](const std::string& target) {
|
||||
return mgr.check_path_access(boost::filesystem::path(target), is_write).allowed;
|
||||
});
|
||||
if (all_inside_allowed_root)
|
||||
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);
|
||||
|
||||
// A granted operation covers its nested events while it runs. A targetless grant names no
|
||||
// operation, so it covers nothing else.
|
||||
if (result == 0 && !targets.empty() && operation_root_events.count(event_name))
|
||||
mgr.record_root_frame();
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace PluginAuditDetail
|
||||
|
||||
void PluginAuditManager::record_root_frame()
|
||||
{
|
||||
PyObject* frame = reinterpret_cast<PyObject*>(PyEval_GetFrame()); // borrowed reference
|
||||
if (!frame || !PluginAuditDetail::is_stdlib_frame(frame))
|
||||
return;
|
||||
if (std::find(m_root_frames.begin(), m_root_frames.end(), frame) != m_root_frames.end())
|
||||
return; // glob.glob and glob.glob/2 share a frame
|
||||
Py_INCREF(frame);
|
||||
m_root_frames.push_back(frame);
|
||||
}
|
||||
|
||||
bool PluginAuditManager::covered_by_root_frame() const
|
||||
{
|
||||
if (m_root_frames.empty())
|
||||
return false;
|
||||
|
||||
// Only stdlib frames may sit between the event and the operation: plugin or library code
|
||||
// the operation calls back into (an os.walk onerror, a urllib handler) acts on its own.
|
||||
for (PyObject* frame : PluginAuditDetail::stack_frames()) {
|
||||
if (std::find(m_root_frames.begin(), m_root_frames.end(), frame) != m_root_frames.end())
|
||||
return true;
|
||||
if (!PluginAuditDetail::is_stdlib_frame(frame))
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool PluginAuditManager::releasing_frames() const { return m_releasing_frames > 0; }
|
||||
|
||||
bool PluginAuditManager::is_stdlib_file(const std::string& filename)
|
||||
{
|
||||
// Frozen stdlib modules (os, posixpath, codecs, ...) carry a pseudo filename.
|
||||
if (boost::algorithm::starts_with(filename, "<frozen "))
|
||||
return true;
|
||||
if (filename.empty() || filename.front() == '<')
|
||||
return false;
|
||||
|
||||
// Both sides are resolved: co_filename follows the Python home as it was given, which may
|
||||
// be a symlink.
|
||||
const auto [it, inserted] = PluginAuditDetail::s_stdlib_file_verdicts.try_emplace(filename, false);
|
||||
if (inserted) {
|
||||
const boost::filesystem::path file(filename);
|
||||
const auto inside_any = [&file](const std::vector<boost::filesystem::path>& dirs) {
|
||||
return std::any_of(dirs.begin(), dirs.end(),
|
||||
[&file](const boost::filesystem::path& dir) { return is_inside_allowed_root(file, dir); });
|
||||
};
|
||||
it->second = inside_any(PluginAuditDetail::s_stdlib_dirs) && !inside_any(PluginAuditDetail::s_site_package_dirs);
|
||||
}
|
||||
return it->second;
|
||||
}
|
||||
|
||||
void PluginAuditManager::release_frames(std::vector<PyObject*>& frames)
|
||||
{
|
||||
if (frames.empty())
|
||||
return;
|
||||
|
||||
// A finalizer's own denial must neither replace the PermissionError of the event being
|
||||
// decided nor leave a stale denial behind for the next call.
|
||||
PyObject* const error = PyErr_GetRaisedException();
|
||||
const bool denial_pending = m_audit_denial_pending;
|
||||
const bool has_violation = m_has_last_violation;
|
||||
const AuditViolation violation = m_last_violation;
|
||||
|
||||
std::vector<PyObject*> released;
|
||||
released.swap(frames);
|
||||
++m_releasing_frames;
|
||||
for (PyObject* frame : released)
|
||||
Py_DECREF(frame);
|
||||
--m_releasing_frames;
|
||||
|
||||
m_audit_denial_pending = denial_pending;
|
||||
m_has_last_violation = has_violation;
|
||||
m_last_violation = violation;
|
||||
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);
|
||||
@@ -1255,20 +1006,62 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user
|
||||
if (event_type == AuditEventCategory::None)
|
||||
return 0;
|
||||
|
||||
// Root frames whose operation has returned are let go once this event is decided, so that a
|
||||
// finalizer they run re-enters a hook whose held frames are already consistent.
|
||||
std::vector<PyObject*> returned_frames;
|
||||
PluginAuditDetail::take_returned_frames(m_root_frames, returned_frames);
|
||||
const int result = PluginAuditDetail::audit_plugin_event(*mgr, event_name, event_type, args);
|
||||
mgr->release_frames(returned_frames);
|
||||
return result;
|
||||
const bool fs_category = is_fs_category(event_type);
|
||||
const std::vector<std::string> targets = PluginAuditDetail::audit_targets(event_name, event_type, args);
|
||||
|
||||
// A denied path (secrets, certificates, config files -- see is_denied_path) is an
|
||||
// unconditional block: checked before the ancestor-cascade check below, so a cascade
|
||||
// approval recorded for an unrelated action can never launder access to one, and before
|
||||
// the allowed-root shortcut further down, so an allowed root (e.g. the read-only resources
|
||||
// folder) cannot make a denied path underneath it reachable.
|
||||
if (fs_category) {
|
||||
for (const auto& target : targets) {
|
||||
if (mgr->is_denied_path(boost::filesystem::path(target)))
|
||||
return PluginAuditDetail::report_denied(*mgr, event_name, {false, "denied path"}, target);
|
||||
}
|
||||
}
|
||||
|
||||
PluginDescriptor plugin_descriptor;
|
||||
PluginManager::instance().try_get_plugin_descriptor(mgr->current_plugin(), plugin_descriptor);
|
||||
|
||||
// Some plugins call other audited events, e.g. urlib.request will call socket.connect. So this is so that if urlib.request
|
||||
// was already approved, socket.connect won't trigger another permission dialog.
|
||||
const std::vector<std::string> call_site_ids =
|
||||
PluginAuditDetail::call_site_identities(plugin_descriptor.plugin_root);
|
||||
if (mgr->has_approved_ancestor(mgr->current_plugin(), call_site_ids))
|
||||
return 0;
|
||||
|
||||
// A filesystem target that resolves entirely inside a pre-determined allowed root -- the
|
||||
// plugin system's own data_dir() tree (which holds each plugin's storage folder and the
|
||||
// installed/system profile cache), the read-only bundled resources folder, or a per-call
|
||||
// scoped root such as the current G-code folder -- is part of the plugin system's normal
|
||||
// workflow and does not need a prompt.
|
||||
if (fs_category && !targets.empty()) {
|
||||
const bool is_write = event_type != AuditEventCategory::FsRead;
|
||||
const bool all_inside_allowed_root =
|
||||
std::all_of(targets.begin(), targets.end(), [&](const std::string& target) {
|
||||
return mgr->check_path_access(boost::filesystem::path(target), is_write).allowed;
|
||||
});
|
||||
if (all_inside_allowed_root)
|
||||
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;
|
||||
|
||||
std::vector<std::string>* permission_list = PluginAuditDetail::permission_list_for(event_type, state.permissions);
|
||||
|
||||
return PluginAuditDetail::decide_audited_event(*mgr, state, mgr->current_plugin(), plugin_name, event_name,
|
||||
event_type, targets, permission_list, call_site_ids);
|
||||
}
|
||||
|
||||
void PluginAuditManager::install_hook()
|
||||
{
|
||||
// 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
|
||||
// covers the plugin system's own workflow needs -- each plugin's storage folder
|
||||
// (data_dir()/orca_plugins) and the installed/system profile cache (data_dir()/system) --
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
// Via pybind11 so this file requests the same python3xx.lib as everything else.
|
||||
#include <boost/filesystem/path.hpp>
|
||||
#include <pybind11/conduit/wrap_include_python_h.h>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
@@ -143,26 +144,15 @@ public:
|
||||
void clear_last_violation();
|
||||
bool last_violation(AuditViolation& violation) const;
|
||||
|
||||
// --- operation-root frames ---
|
||||
// One stdlib operation fires several nested audit events as it passes through lower layers:
|
||||
// urlopen() raises urllib.Request, then http.client and socket events. When an operation's
|
||||
// own root event is granted (by a saved grant or the user), the stdlib frame that raised it
|
||||
// is held, and a later event whose stack reaches that frame through stdlib frames only is
|
||||
// part of the same operation, so it is allowed without a prompt of its own. Plugin or
|
||||
// library code the operation calls back into is judged on its own. A frame is let go once it
|
||||
// returns, a suspended generator's (os.walk) when the audit scope ends. Letting a frame go may
|
||||
// run finalizers of objects its locals held; they are judged as the plugin and never prompt.
|
||||
void record_root_frame();
|
||||
bool covered_by_root_frame() const;
|
||||
bool releasing_frames() const;
|
||||
|
||||
// True when code from `filename` (a code object's co_filename) is the interpreter's own
|
||||
// stdlib: the only code a root frame's coverage passes through. Valid after install_hook().
|
||||
static bool is_stdlib_file(const std::string& filename);
|
||||
|
||||
// Answers permission prompts in place of the dialog, for tests. Production never sets it.
|
||||
using PermissionPrompt = std::function<bool(const std::string& event_name, const std::vector<std::string>& targets)>;
|
||||
static void set_permission_prompt_for_testing(PermissionPrompt prompt);
|
||||
// --- call-site cascade cache ---
|
||||
// A single plugin action often fires several nested CPython audit events as it passes
|
||||
// through stdlib layers (urllib.request calling http.client calling socket, for example).
|
||||
// Once the user approves one event, every stdlib frame still on the stack for that call
|
||||
// is recorded here by (filename, function, first line) identity. A later event whose own
|
||||
// ancestor chain still contains one of those frames is the same logical action seen from
|
||||
// a deeper layer, so it is auto-approved instead of prompting again.
|
||||
bool has_approved_ancestor(const std::string& plugin_key, const std::vector<std::string>& call_site_ids) const;
|
||||
void record_approved_call_sites(const std::string& plugin_key, const std::vector<std::string>& call_site_ids);
|
||||
|
||||
bool verbose_events = true;
|
||||
|
||||
@@ -173,32 +163,24 @@ private:
|
||||
|
||||
static int audit_hook(const char* event, PyObject* args, void* user_data);
|
||||
|
||||
// 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::vector<AllowedRoot> m_scoped_allowed_roots;
|
||||
static thread_local std::vector<PyObject*> m_root_frames; // strong references
|
||||
static thread_local int m_releasing_frames;
|
||||
static thread_local bool m_audit_denial_pending;
|
||||
static thread_local bool m_has_last_violation;
|
||||
static thread_local AuditViolation m_last_violation;
|
||||
|
||||
// mutable: is_denied_filename() is a const query that must lock.
|
||||
// mutable: is_denied_filename() and has_approved_ancestor() are const queries that must lock.
|
||||
mutable std::mutex m_mutex;
|
||||
std::vector<AllowedRoot> m_global_allowed_roots;
|
||||
std::vector<std::string> m_denied_filenames;
|
||||
std::vector<std::string> m_denied_path_keywords;
|
||||
std::unordered_map<std::string, std::unordered_set<std::string>> m_approved_call_sites; // plugin_key -> call-site ids
|
||||
};
|
||||
|
||||
// RAII guard that sets the current plugin key and capability name, restoring the previous
|
||||
// pair on scope exit. `capability_name` may be empty for calls that are not scoped to a
|
||||
// single capability.
|
||||
//
|
||||
// Each scope owns the root frames recorded inside it and lets them go, as its own plugin, before
|
||||
// restoring the outer context. That needs the GIL whenever a frame is still held, so construct
|
||||
// the scope after the GIL guard, to be destroyed before it.
|
||||
class ScopedPluginAuditContext
|
||||
{
|
||||
public:
|
||||
@@ -212,12 +194,9 @@ public:
|
||||
ScopedPluginAuditContext& operator=(const ScopedPluginAuditContext&) = delete;
|
||||
|
||||
private:
|
||||
std::string m_plugin_key;
|
||||
std::string m_capability;
|
||||
std::string m_previous_id;
|
||||
std::string m_previous_capability;
|
||||
std::vector<AllowedRoot> m_previous_scoped_roots;
|
||||
std::vector<PyObject*> m_previous_root_frames;
|
||||
};
|
||||
|
||||
} // namespace Slic3r
|
||||
|
||||
@@ -808,7 +808,6 @@ bool read_install_state(const boost::filesystem::path& plugin_dir, PluginInstall
|
||||
read_string_list("network_socket", parsed.permissions.network_socket);
|
||||
read_string_list("process", parsed.permissions.process);
|
||||
read_string_list("threading", parsed.permissions.threading);
|
||||
read_string_list("events", parsed.permissions.events);
|
||||
}
|
||||
|
||||
if (state.contains("enabled") && state["enabled"].is_boolean())
|
||||
@@ -853,7 +852,6 @@ bool write_install_state(const boost::filesystem::path& plugin_dir, const Plugin
|
||||
{"network_socket", state.permissions.network_socket},
|
||||
{"process", state.permissions.process},
|
||||
{"threading", state.permissions.threading},
|
||||
{"events", state.permissions.events},
|
||||
};
|
||||
|
||||
nlohmann::json capabilities = nlohmann::json::array();
|
||||
|
||||
@@ -104,8 +104,6 @@ struct PluginPermissions
|
||||
std::vector<std::string> network_socket;
|
||||
std::vector<std::string> process;
|
||||
std::vector<std::string> threading;
|
||||
// Grants for audit events that expose no target, keyed "<category>:<event>" (e.g. "Socket:socket.__new__").
|
||||
std::vector<std::string> events;
|
||||
};
|
||||
|
||||
struct PluginInstallState {
|
||||
|
||||
@@ -102,9 +102,9 @@ public:
|
||||
// mistake), both fall back to the base's empty object rather than writing `"cap_config": null`.
|
||||
nlohmann::json get_default_config() const override
|
||||
{
|
||||
pybind11::gil_scoped_acquire gil;
|
||||
ORCA_PY_AUDIT_SCOPE();
|
||||
try {
|
||||
pybind11::gil_scoped_acquire gil;
|
||||
pybind11::function override = pybind11::get_override(static_cast<const Base*>(this), "get_default_config");
|
||||
if (!override)
|
||||
return Base::get_default_config();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#include "GeneratedConfig.hpp"
|
||||
#include "libslic3r/Utils.hpp"
|
||||
#include "PluginAuditManager.hpp"
|
||||
#include "PythonPluginBridge.hpp"
|
||||
#include <atomic>
|
||||
#include <boost/filesystem/operations.hpp>
|
||||
#include <boost/filesystem/path.hpp>
|
||||
@@ -664,6 +665,11 @@ bool PythonInterpreter::initialize()
|
||||
// background-thread exceptions) to <data_dir>/log/python_*.log.
|
||||
install_python_stderr_redirect();
|
||||
|
||||
// Attribute plugin-spawned Python threads to the plugin/capability that created them.
|
||||
// Must run while the GIL is still held here, after the audit hook is in place and
|
||||
// before the GIL is released below.
|
||||
install_python_thread_audit_shim();
|
||||
|
||||
// Release the GIL so other threads can acquire it via PyGILState_Ensure.
|
||||
// Without this, calls from background threads will block trying to acquire the GIL.
|
||||
m_main_thread_state = PyEval_SaveThread();
|
||||
|
||||
@@ -603,6 +603,93 @@ request aborts the plugin load.)pbdoc");
|
||||
|
||||
}
|
||||
|
||||
void install_python_thread_audit_shim()
|
||||
{
|
||||
// Runs with the GIL held from PythonInterpreter::initialize(). CPython's own thread bootstrap
|
||||
// calls the target with no knowledge of the audit context that created it: the calling
|
||||
// thread is the only place the plugin key and capability are still available. Capture them
|
||||
// here, then re-establish the same ScopedPluginAuditContext inside the spawned thread before
|
||||
// the plugin body runs. The identity lives only in this C++ closure -- no Python-visible
|
||||
// setter is added.
|
||||
try {
|
||||
py::object thread_module = py::module_::import("_thread");
|
||||
|
||||
const int installed = PyObject_HasAttrString(thread_module.ptr(), "_orca_thread_audit_shim_installed");
|
||||
if (installed == 1) {
|
||||
BOOST_LOG_TRIVIAL(debug) << "[AUDIT] Python thread attribution shim already installed";
|
||||
return;
|
||||
}
|
||||
if (installed < 0)
|
||||
PyErr_Clear();
|
||||
|
||||
py::object original = py::reinterpret_steal<py::object>(
|
||||
PyObject_GetAttrString(thread_module.ptr(), "start_new_thread"));
|
||||
if (!original || !PyCallable_Check(original.ptr())) {
|
||||
PyErr_Clear();
|
||||
BOOST_LOG_TRIVIAL(error) << "[AUDIT] _thread.start_new_thread is missing or not callable; "
|
||||
"plugin threads will run without audit attribution";
|
||||
return;
|
||||
}
|
||||
|
||||
py::object shim = py::cpp_function(
|
||||
[original](py::object function, py::object args, py::object kwargs) -> py::object {
|
||||
const std::string plugin_key = PluginAuditManager::instance().current_plugin();
|
||||
if (plugin_key.empty()) {
|
||||
// Not called from a plugin: preserve the stock implementation untouched.
|
||||
return kwargs.is_none() ? original(function, args) : original(function, args, kwargs);
|
||||
}
|
||||
const std::string capability = PluginAuditManager::instance().current_capability();
|
||||
|
||||
py::object guarded = py::cpp_function(
|
||||
[function, plugin_key, capability](py::args call_args, py::kwargs call_kwargs) -> py::object {
|
||||
ScopedPluginAuditContext audit_scope(plugin_key, capability);
|
||||
PyObject* result = PyObject_Call(function.ptr(), call_args.ptr(), call_kwargs.ptr());
|
||||
if (result == nullptr)
|
||||
throw py::error_already_set();
|
||||
return py::reinterpret_steal<py::object>(result);
|
||||
});
|
||||
return kwargs.is_none() ? original(guarded, args) : original(guarded, args, kwargs);
|
||||
},
|
||||
py::arg("function"), py::arg("args"), py::arg("kwargs") = py::none());
|
||||
|
||||
if (PyObject_SetAttrString(thread_module.ptr(), "start_new_thread", shim.ptr()) < 0) {
|
||||
PyErr_Clear();
|
||||
BOOST_LOG_TRIVIAL(error)
|
||||
<< "[AUDIT] Failed to install Python thread attribution shim on _thread.start_new_thread";
|
||||
return;
|
||||
}
|
||||
if (PyObject_SetAttrString(thread_module.ptr(), "_orca_thread_audit_shim_installed", Py_True) < 0)
|
||||
PyErr_Clear();
|
||||
|
||||
// threading.py caches the function at import time (as `_start_new_thread` in 3.12, and as
|
||||
// `start_new_thread` in older versions). Patch the cached reference too when the module is
|
||||
// already loaded; otherwise its Thread.start() would keep calling the unwrapped original.
|
||||
py::object sys_modules = py::module_::import("sys").attr("modules");
|
||||
PyObject* threading_module = PyDict_GetItemString(sys_modules.ptr(), "threading");
|
||||
if (threading_module && threading_module != Py_None) {
|
||||
const char* cached_names[] = {"_start_new_thread", "start_new_thread"};
|
||||
for (const char* cached_name : cached_names) {
|
||||
const int has_cached_name = PyObject_HasAttrString(threading_module, cached_name);
|
||||
if (has_cached_name < 0) {
|
||||
PyErr_Clear();
|
||||
continue;
|
||||
}
|
||||
if (has_cached_name != 1)
|
||||
continue;
|
||||
if (PyObject_SetAttrString(threading_module, cached_name, shim.ptr()) < 0) {
|
||||
PyErr_Clear();
|
||||
BOOST_LOG_TRIVIAL(warning) << "[AUDIT] Failed to update threading." << cached_name
|
||||
<< " with the Python thread attribution shim";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_LOG_TRIVIAL(info) << "[AUDIT] Python thread attribution shim installed";
|
||||
} catch (const std::exception& ex) {
|
||||
BOOST_LOG_TRIVIAL(error) << "[AUDIT] Failed to install Python thread attribution shim: " << ex.what();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace Slic3r
|
||||
|
||||
#ifdef ORCA_PYTHON_STUBGEN_MODULE
|
||||
|
||||
@@ -13,6 +13,15 @@ namespace Slic3r { class PluginCapabilityInterface; }
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
// Install the thread-attribution shim: wraps `_thread.start_new_thread` (and the reference
|
||||
// cached by an already-imported `threading` module) so a thread spawned from inside a plugin
|
||||
// call runs with that call's plugin key/capability audit scope. The captured identity is held
|
||||
// in a private C++ wrapper; nothing is exposed to Python that could set it.
|
||||
//
|
||||
// Call with the GIL held, after PluginAuditManager::install_hook() and before the interpreter
|
||||
// releases the GIL. Safe to call more than once per interpreter.
|
||||
void install_python_thread_audit_shim();
|
||||
|
||||
// One materialized capability returned from finalize_plugin_capture: the C++ instance
|
||||
// and its resolved get_name() (cached while the GIL was held).
|
||||
struct CapturedCapability
|
||||
|
||||
+1
-1
@@ -282,11 +282,11 @@ public:
|
||||
int request_bind_ticket(std::string* ticket) override
|
||||
{
|
||||
try {
|
||||
ORCA_PY_AUDIT_SCOPE();
|
||||
::Slic3r::PluginCapabilityInterface::RefCounter _orca_ref_counter(*this);
|
||||
::Slic3r::PythonGILState gil;
|
||||
if (!gil)
|
||||
throw std::runtime_error("Python interpreter is shutting down");
|
||||
ORCA_PY_AUDIT_SCOPE();
|
||||
pybind11::function override =
|
||||
pybind11::get_override(static_cast<const PrinterAgentPluginCapability*>(this), "request_bind_ticket");
|
||||
if (!override)
|
||||
|
||||
@@ -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]")
|
||||
|
||||
@@ -1263,55 +1263,6 @@ TEST_CASE("read_cli rejects an invalid scalar numeric value", "[Config]") {
|
||||
REQUIRE_FALSE(config.read_cli(3, argv, &extra, &keys));
|
||||
}
|
||||
|
||||
TEST_CASE("A scalar option keeps its value when deserializing fails", "[Config]") {
|
||||
using Catch::Matchers::WithinAbs;
|
||||
|
||||
SECTION("float") {
|
||||
ConfigOptionFloat opt(7.5);
|
||||
REQUIRE_FALSE(opt.deserialize("abc"));
|
||||
CHECK_THAT(opt.value, WithinAbs(7.5, 1e-9));
|
||||
}
|
||||
SECTION("int") {
|
||||
ConfigOptionInt opt(3);
|
||||
REQUIRE_FALSE(opt.deserialize("abc"));
|
||||
CHECK(opt.value == 3);
|
||||
}
|
||||
SECTION("percent") {
|
||||
ConfigOptionPercent opt(40.);
|
||||
REQUIRE_FALSE(opt.deserialize("abc%"));
|
||||
CHECK_THAT(opt.value, WithinAbs(40., 1e-9));
|
||||
}
|
||||
SECTION("float or percent") {
|
||||
ConfigOptionFloatOrPercent opt(0.4, false);
|
||||
REQUIRE_FALSE(opt.deserialize("abc%"));
|
||||
CHECK_THAT(opt.value, WithinAbs(0.4, 1e-9));
|
||||
CHECK_FALSE(opt.percent);
|
||||
}
|
||||
SECTION("point") {
|
||||
ConfigOptionPoint opt(Vec2d(1., 2.));
|
||||
REQUIRE_FALSE(opt.deserialize("5,abc"));
|
||||
CHECK(opt.value == Vec2d(1., 2.));
|
||||
}
|
||||
SECTION("point3") {
|
||||
ConfigOptionPoint3 opt(Vec3d(1., 2., 3.));
|
||||
REQUIRE_FALSE(opt.deserialize("5,6,abc"));
|
||||
CHECK(opt.value == Vec3d(1., 2., 3.));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("A scalar option still takes a valid value", "[Config]") {
|
||||
using Catch::Matchers::WithinAbs;
|
||||
|
||||
ConfigOptionFloatOrPercent opt(0.4, false);
|
||||
REQUIRE(opt.deserialize("50%"));
|
||||
CHECK_THAT(opt.value, WithinAbs(50., 1e-9));
|
||||
CHECK(opt.percent);
|
||||
|
||||
ConfigOptionPoint point;
|
||||
REQUIRE(point.deserialize("3x4"));
|
||||
CHECK(point.value == Vec2d(3., 4.));
|
||||
}
|
||||
|
||||
TEST_CASE("read_cli appends values when a vector option is repeated", "[Config]") {
|
||||
Slic3r::DynamicPrintConfig config;
|
||||
t_config_option_keys extra, keys;
|
||||
@@ -1727,75 +1678,3 @@ TEST_CASE("Default options of enum lists get their definition's keys map", "[Con
|
||||
}
|
||||
CHECK(enum_lists > 0);
|
||||
}
|
||||
|
||||
TEST_CASE("A disabled filament override survives the merge with a parent that sets it", "[Config][Variant]")
|
||||
{
|
||||
std::set<std::string> no_keys;
|
||||
Slic3r::DynamicPrintConfig parent;
|
||||
parent.set_deserialize_strict("filament_retract_before_wipe", "100%");
|
||||
parent.set_deserialize_strict("filament_ironing_flow", "15%");
|
||||
Slic3r::DynamicPrintConfig child;
|
||||
child.set_deserialize_strict("filament_retract_before_wipe", "nil");
|
||||
child.set_deserialize_strict("filament_ironing_flow", "nil");
|
||||
parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant",
|
||||
Slic3r::filament_options_with_variant, no_keys);
|
||||
CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil");
|
||||
CHECK(parent.opt_serialize("filament_ironing_flow") == "nil");
|
||||
}
|
||||
|
||||
SCENARIO("A two-variant child's disabled override column is matched to the parent's variant by name", "[Config][Variant]")
|
||||
{
|
||||
std::set<std::string> no_keys;
|
||||
GIVEN("A two-variant filament parent") {
|
||||
Slic3r::DynamicPrintConfig parent;
|
||||
parent.set_deserialize_strict("filament_extruder_variant", "Direct Drive Standard;Direct Drive High Flow");
|
||||
parent.set_deserialize_strict("filament_retract_before_wipe", "100%,100%");
|
||||
parent.set_deserialize_strict("filament_flow_ratio", "1,1");
|
||||
WHEN("the child disables the override on one variant and leaves a plain option nil on it") {
|
||||
Slic3r::DynamicPrintConfig child;
|
||||
child.set_deserialize_strict("filament_extruder_variant", "Direct Drive Standard;Direct Drive High Flow");
|
||||
child.set_deserialize_strict("filament_retract_before_wipe", "nil,100%");
|
||||
child.set_deserialize_strict("filament_flow_ratio", "nil,1.1");
|
||||
parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant",
|
||||
Slic3r::filament_options_with_variant, no_keys);
|
||||
THEN("the override column is disabled while the plain option's nil column inherits") {
|
||||
CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil,100%");
|
||||
CHECK(parent.opt_serialize("filament_flow_ratio") == "1,1.1");
|
||||
}
|
||||
}
|
||||
WHEN("the child lists the same variants in the other order") {
|
||||
Slic3r::DynamicPrintConfig child;
|
||||
child.set_deserialize_strict("filament_extruder_variant", "Direct Drive High Flow;Direct Drive Standard");
|
||||
child.set_deserialize_strict("filament_retract_before_wipe", "70%,nil");
|
||||
child.set_deserialize_strict("filament_flow_ratio", "1.1,nil");
|
||||
parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant",
|
||||
Slic3r::filament_options_with_variant, no_keys);
|
||||
THEN("columns are matched by variant name, the disabled one included") {
|
||||
CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil,70%");
|
||||
CHECK(parent.opt_serialize("filament_flow_ratio") == "1,1.1");
|
||||
}
|
||||
}
|
||||
WHEN("the child was saved with a single variant") {
|
||||
Slic3r::DynamicPrintConfig child;
|
||||
child.set_deserialize_strict("filament_extruder_variant", "Direct Drive Standard");
|
||||
child.set_deserialize_strict("filament_retract_before_wipe", "nil");
|
||||
parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant",
|
||||
Slic3r::filament_options_with_variant, no_keys);
|
||||
THEN("its one value applies to every variant of that extruder, as slicing does") {
|
||||
CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil,nil");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Every listed filament override is nullable", "[Config]")
|
||||
{
|
||||
std::set<std::string> overrides(Slic3r::filament_extruder_override_keys.begin(), Slic3r::filament_extruder_override_keys.end());
|
||||
overrides.insert({ "filament_retract_length_nc", "filament_ironing_flow", "filament_ironing_spacing", "filament_ironing_inset", "filament_ironing_speed" });
|
||||
for (const auto &[key, def] : Slic3r::print_config_def.options) {
|
||||
INFO(key);
|
||||
CHECK(Slic3r::is_filament_extruder_override_key(key) == (overrides.count(key) == 1));
|
||||
if (overrides.count(key) == 1)
|
||||
CHECK(def.nullable);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -8417,58 +8417,3 @@ TEST_CASE("An empty choice list in a G-code's settings keeps the value it had be
|
||||
CHECK(config.opt_serialize("nozzle_volume_type") == "High Flow");
|
||||
CHECK(config.option<ConfigOptionStrings>("filament_multi_colour")->values.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("A disabled filament override is saved and reloaded column by column", "[Preset][Bundle]")
|
||||
{
|
||||
ScopedPresetDataDir data_dir;
|
||||
PresetBundle bundle;
|
||||
|
||||
Preset &parent = add_inmemory_preset(bundle.filaments, "Parent PLA @System");
|
||||
parent.config.option<ConfigOptionStrings>("filament_extruder_variant", true)->values = { "Direct Drive Standard", "Direct Drive High Flow" };
|
||||
parent.config.option<ConfigOptionPercentsNullable>("filament_retract_before_wipe", true)->values = { 100., 100. };
|
||||
parent.config.option<ConfigOptionFloatsNullable>("filament_flow_ratio", true)->values = { 1., 1. };
|
||||
|
||||
// The child turns the override off on the first variant and changes a plain option on the second.
|
||||
const fs::path file = data_dir.tmp.path() / PRESET_FILAMENT_NAME / "Child PLA.json";
|
||||
Preset child(Preset::TYPE_FILAMENT, "Child PLA");
|
||||
child.file = file.string();
|
||||
child.version = *Semver::parse("1.0.0");
|
||||
child.config = parent.config;
|
||||
child.config.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = parent.name;
|
||||
child.config.option<ConfigOptionPercentsNullable>("filament_retract_before_wipe")->set_at_to_nil(0);
|
||||
child.config.option<ConfigOptionFloatsNullable>("filament_flow_ratio")->values = { 1., 1.1 };
|
||||
REQUIRE(child.save(&parent.config));
|
||||
|
||||
// On disk the override keeps every column, so its nil can only mean "off"; a plain
|
||||
// option still stores nil for the columns that match the parent.
|
||||
DynamicPrintConfig saved;
|
||||
std::map<std::string, std::string> key_values;
|
||||
std::string reason;
|
||||
saved.load_from_json(file.string(), ForwardCompatibilitySubstitutionRule::EnableSilent, key_values, reason);
|
||||
REQUIRE(reason.empty());
|
||||
CHECK(saved.opt_serialize("filament_retract_before_wipe") == "nil,100%");
|
||||
CHECK(saved.opt_serialize("filament_flow_ratio") == "nil,1.1");
|
||||
|
||||
PresetsConfigSubstitutions substitutions;
|
||||
bundle.filaments.load_presets(data_dir.tmp.path().string(), PRESET_FILAMENT_NAME, substitutions,
|
||||
ForwardCompatibilitySubstitutionRule::EnableSilent);
|
||||
const Preset *loaded = bundle.filaments.find_preset("Child PLA");
|
||||
REQUIRE(loaded != nullptr);
|
||||
CHECK(loaded->config.opt_serialize("filament_retract_before_wipe") == "nil,100%");
|
||||
CHECK(loaded->config.opt_serialize("filament_flow_ratio") == "1,1.1");
|
||||
}
|
||||
|
||||
TEST_CASE("The cloud diff force-emits a disabled override only where the cloud would fill a value in", "[Preset][Bundle]")
|
||||
{
|
||||
PresetBundle bundle;
|
||||
add_inmemory_preset(bundle.filaments, "Parent PLA @System");
|
||||
Preset &child = add_inmemory_preset(bundle.filaments, "Child PLA", "Parent PLA @System");
|
||||
// Every override is nil in both parent and child, so none is in the plain diff.
|
||||
std::map<std::string, std::string> key_values;
|
||||
REQUIRE(bundle.filaments.get_differed_values_to_update(child, key_values) >= 0);
|
||||
// Retract Before Wipe defaults to 100%: an absent key would come back as 100%, so nil is sent.
|
||||
CHECK(key_values.count("filament_retract_before_wipe") == 1);
|
||||
CHECK(key_values["filament_retract_before_wipe"] == "nil");
|
||||
// An ironing override defaults to nil: an absent key stays nil, so it is not sent.
|
||||
CHECK(key_values.count("filament_ironing_flow") == 0);
|
||||
}
|
||||
@@ -1,48 +1,22 @@
|
||||
#include <boost/filesystem/path.hpp>
|
||||
#include <boost/filesystem/operations.hpp>
|
||||
#include <boost/system/error_code.hpp>
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#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/PythonInterpreter.hpp>
|
||||
#include <slic3r/plugin/PythonPluginBridge.hpp>
|
||||
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||
#include <slic3r/Utils/IPrinterAgent.hpp>
|
||||
#include <slic3r/Utils/OrcaCloudServiceAgent.hpp> // secret_constants::USER_SECRET_FILENAME
|
||||
|
||||
#include <catch2/catch_message.hpp>
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/matchers/catch_matchers.hpp>
|
||||
#include <catch2/matchers/catch_matchers_string.hpp>
|
||||
#include "libslic3r_version.h"
|
||||
#include "plugin_test_utils.hpp"
|
||||
#include "test_utils.hpp"
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <pybind11/cast.h>
|
||||
#include <pybind11/embed.h>
|
||||
#include <pybind11/eval.h>
|
||||
#include <pybind11/gil.h>
|
||||
#include <pybind11/pybind11.h>
|
||||
#include <pybind11/pytypes.h>
|
||||
#include <pybind11/stl.h>
|
||||
|
||||
#include <fstream>
|
||||
#include <functional>
|
||||
#include <ios>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
using namespace Slic3r;
|
||||
namespace fs = boost::filesystem;
|
||||
namespace py = pybind11;
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -375,747 +349,3 @@ TEST_CASE("Plugin audit a scoped root can also be registered read-only", "[audit
|
||||
CHECK_FALSE(mgr.check_open((readonly_dir / "vendor.json").string(), "w").allowed);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The audit hook, run in the embedded interpreter
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Each TEST_CASE runs in its own process (catch_discover_tests), so each brings the plugin system
|
||||
// up with the audit hook installed, once, and shuts it down at the end. The tests loop over their
|
||||
// cases instead of using SECTIONs, which would restart the interpreter for every section.
|
||||
|
||||
namespace {
|
||||
|
||||
// Discovered, never loaded: the hook only needs the plugin's root and its sidecar.
|
||||
const char* const AUDIT_PLUGIN_SOURCE = R"PY(# /// script
|
||||
# requires-python = ">=3.12"
|
||||
#
|
||||
# [tool.orcaslicer.plugin]
|
||||
# name = "Audit Plugin"
|
||||
# description = "Plugin audit hook fixture"
|
||||
# author = "OrcaSlicer"
|
||||
# version = "1.0"
|
||||
# type = "script"
|
||||
# ///
|
||||
)PY";
|
||||
|
||||
// A third-party library, installed outside the stdlib and outside the plugin.
|
||||
const char* const LIBRARY_SOURCE = R"PY(
|
||||
import socket
|
||||
import urllib.request
|
||||
|
||||
def read_all(paths):
|
||||
for path in paths:
|
||||
with open(path) as f:
|
||||
f.read()
|
||||
|
||||
def connect_from_host(port):
|
||||
socket.gethostname()
|
||||
socket.create_connection(("127.0.0.1", port)).close()
|
||||
|
||||
def fetch(url):
|
||||
with urllib.request.urlopen(url) as response:
|
||||
return response.read()
|
||||
|
||||
def fetch_each(urls):
|
||||
for url in urls:
|
||||
yield fetch(url)
|
||||
|
||||
class ReadingHandler(urllib.request.BaseHandler):
|
||||
"""Reads a file while each response is processed."""
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
self.outcome = None
|
||||
|
||||
def http_response(self, request, response):
|
||||
try:
|
||||
open(self.path).close()
|
||||
self.outcome = "ok"
|
||||
except PermissionError:
|
||||
self.outcome = "denied"
|
||||
return response
|
||||
)PY";
|
||||
|
||||
// Test-side Python, run outside any plugin context: files outside every allowed root, a local
|
||||
// HTTP server, the library, and every module the tests use imported up front, so that no import
|
||||
// happens inside a plugin context. Code from exec() runs as "<string>", which is not stdlib, so
|
||||
// attempt() and fetch() stand in for plugin code.
|
||||
const char* const AUDIT_SETUP = R"PY(
|
||||
import http.client, http.server, os, pathlib, shutil, socket, subprocess, sys, tempfile, threading
|
||||
import urllib.request, zipfile
|
||||
if os.name != "nt":
|
||||
import pty
|
||||
|
||||
APPROVED = os.path.join(OUTSIDE, "approved.txt")
|
||||
PRIVATE = os.path.join(OUTSIDE, "private.txt")
|
||||
MISSING = os.path.join(OUTSIDE, "missing")
|
||||
TREE = os.path.join(OUTSIDE, "tree")
|
||||
TRUE_COMMAND = ["cmd", "/c", "exit 0"] if os.name == "nt" else ["true"]
|
||||
for path in (APPROVED, PRIVATE):
|
||||
with open(path, "w") as f:
|
||||
f.write("x")
|
||||
for sub in ("one", "two"):
|
||||
os.makedirs(os.path.join(TREE, sub))
|
||||
with open(os.path.join(TREE, sub, "f.txt"), "w") as f:
|
||||
f.write("x")
|
||||
|
||||
LIBRARY = os.path.join(OUTSIDE, "library")
|
||||
os.makedirs(LIBRARY)
|
||||
with open(os.path.join(LIBRARY, "netlib.py"), "w") as f:
|
||||
f.write(LIBRARY_SOURCE)
|
||||
sys.path.insert(0, LIBRARY)
|
||||
import netlib
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path == "/redirect":
|
||||
self.send_response(302)
|
||||
self.send_header("Location", URL_B)
|
||||
body = b""
|
||||
else:
|
||||
self.send_response(200)
|
||||
body = b"ok"
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
PORT = server.server_address[1]
|
||||
BASE = "http://127.0.0.1:%d" % PORT
|
||||
URL_A, URL_B, URL_REDIRECT = BASE + "/a", BASE + "/b", BASE + "/redirect"
|
||||
urllib.request.install_opener(urllib.request.build_opener(urllib.request.ProxyHandler({})))
|
||||
|
||||
def fetch(url):
|
||||
with urllib.request.urlopen(url) as response:
|
||||
return response.read()
|
||||
|
||||
fetch(URL_A) # urllib imports some modules on its first request
|
||||
|
||||
def attempt(action):
|
||||
try:
|
||||
action()
|
||||
return "ok"
|
||||
except PermissionError:
|
||||
return "denied"
|
||||
|
||||
def spawn_checked(argv):
|
||||
parent = os.getpid()
|
||||
try:
|
||||
return pty.spawn(argv)
|
||||
finally:
|
||||
if os.getpid() != parent:
|
||||
os._exit(1) # a child whose exec was refused must not go on running the tests
|
||||
|
||||
finalizer_outcomes = []
|
||||
|
||||
class TrackedRequest(urllib.request.Request):
|
||||
def __del__(self):
|
||||
finalizer_outcomes.append(attempt(lambda: open(PRIVATE).close()))
|
||||
)PY";
|
||||
|
||||
struct Prompt
|
||||
{
|
||||
std::string plugin;
|
||||
std::string event;
|
||||
std::vector<std::string> targets;
|
||||
};
|
||||
|
||||
// One line per prompt, "event: target ...", so a failed check shows every prompt.
|
||||
std::string describe(const std::vector<Prompt>& prompts)
|
||||
{
|
||||
std::string text;
|
||||
for (const Prompt& prompt : prompts) {
|
||||
text += prompt.event + ":";
|
||||
for (const std::string& target : prompt.targets)
|
||||
text += " " + target;
|
||||
text += "\n";
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// The plugin system with the audit hook installed, and the permission dialog replaced by a
|
||||
// recorder that answers with `approve`. Declare it first in a test, so it is destroyed last.
|
||||
struct AuditHarness
|
||||
{
|
||||
// Before initialize(): the interpreter creates {data_dir}/python/packages and {data_dir}/log.
|
||||
// data_dir() is a global allowed root from then on, so the test files live in `outside`.
|
||||
ScopedDataDir data{"plugin-audit-hook"};
|
||||
bool initialized = PluginManager::instance().initialize();
|
||||
ScopedTemporaryDir outside{"orca-plugin-audit-outside"};
|
||||
std::vector<Prompt> prompts;
|
||||
std::function<bool(const Prompt&)> approve = [](const Prompt&) { return false; };
|
||||
|
||||
AuditHarness()
|
||||
{
|
||||
PluginAuditManager::set_permission_prompt_for_testing(
|
||||
[this](const std::string& event, const std::vector<std::string>& targets) {
|
||||
prompts.push_back({PluginAuditManager::instance().current_plugin(), event, targets});
|
||||
return approve(prompts.back());
|
||||
});
|
||||
}
|
||||
|
||||
~AuditHarness()
|
||||
{
|
||||
PluginAuditManager::set_permission_prompt_for_testing(nullptr);
|
||||
PluginManager::instance().shutdown();
|
||||
PythonInterpreter::instance().shutdown();
|
||||
}
|
||||
|
||||
// Call before taking the GIL: discovery is not Python work.
|
||||
void add_plugin(const std::string& key)
|
||||
{
|
||||
const fs::path root = data.plugins_dir() / key;
|
||||
fs::create_directories(root);
|
||||
std::ofstream((root / (key + ".py")).string(), std::ios::binary) << AUDIT_PLUGIN_SOURCE;
|
||||
save_permissions(key, {});
|
||||
PluginManager::instance().discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
}
|
||||
|
||||
// Grants as if saved in an earlier session.
|
||||
void save_permissions(const std::string& key, const PluginPermissions& permissions)
|
||||
{
|
||||
PluginInstallState state;
|
||||
state.installed_from = "local";
|
||||
state.installed_version = "1.0";
|
||||
state.plugin_name = key;
|
||||
state.permissions = permissions;
|
||||
REQUIRE(write_install_state(data.plugins_dir() / key, state));
|
||||
}
|
||||
|
||||
PluginPermissions saved_permissions(const std::string& key) const
|
||||
{
|
||||
PluginInstallState state;
|
||||
read_install_state(data.plugins_dir() / key, state);
|
||||
return state.permissions;
|
||||
}
|
||||
};
|
||||
|
||||
// The test-side Python globals. Construct and destroy it with the GIL held; it stops the HTTP
|
||||
// server before the interpreter shuts down.
|
||||
struct PythonSide
|
||||
{
|
||||
py::dict globals;
|
||||
|
||||
explicit PythonSide(const AuditHarness& harness)
|
||||
{
|
||||
globals["OUTSIDE"] = harness.outside.path().string();
|
||||
globals["LIBRARY_SOURCE"] = LIBRARY_SOURCE;
|
||||
exec(AUDIT_SETUP);
|
||||
}
|
||||
|
||||
// A Python error fails the test here, as text: an error_already_set left to propagate would
|
||||
// outlive the interpreter the harness shuts down.
|
||||
void exec(const char* code)
|
||||
{
|
||||
std::string error;
|
||||
try {
|
||||
py::exec(code, globals);
|
||||
} catch (py::error_already_set& raised) {
|
||||
error = raised.what();
|
||||
}
|
||||
if (!error.empty())
|
||||
FAIL(error);
|
||||
}
|
||||
|
||||
~PythonSide()
|
||||
{
|
||||
try {
|
||||
globals["server"].attr("shutdown")();
|
||||
globals["server"].attr("server_close")();
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
|
||||
std::string str(const char* name) const { return py::str(globals[name]).cast<std::string>(); }
|
||||
std::string eval(const char* expression) const { return py::str(py::eval(expression, globals)).cast<std::string>(); }
|
||||
std::vector<std::string> strings(const char* name) const { return globals[name].cast<std::vector<std::string>>(); }
|
||||
|
||||
// Runs `code` as `plugin_key`, as a trampoline call does: inside the caller's GIL, in an
|
||||
// audit scope of its own.
|
||||
void run_as(const std::string& plugin_key, const char* code)
|
||||
{
|
||||
ScopedPluginAuditContext audit(plugin_key);
|
||||
exec(code);
|
||||
}
|
||||
};
|
||||
|
||||
PluginPermissions http_grants(std::vector<std::string> urls)
|
||||
{
|
||||
PluginPermissions permissions;
|
||||
permissions.network_http = std::move(urls);
|
||||
return permissions;
|
||||
}
|
||||
|
||||
const char* const PLUGIN = "AuditPlugin";
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("A saved request grant covers the request's own connection in a later session", "[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);
|
||||
harness.save_permissions(PLUGIN, http_grants({python.str("URL_A")}));
|
||||
|
||||
python.run_as(PLUGIN, "outcome = attempt(lambda: fetch(URL_A))");
|
||||
|
||||
CHECK(python.str("outcome") == "ok");
|
||||
CHECK(describe(harness.prompts).empty());
|
||||
}
|
||||
|
||||
TEST_CASE("A saved request grant does not cover another URL or a redirect", "[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);
|
||||
harness.save_permissions(PLUGIN, http_grants({python.str("URL_A"), python.str("URL_REDIRECT")}));
|
||||
|
||||
python.run_as(PLUGIN, R"PY(
|
||||
outcomes = [attempt(lambda: fetch(url)) for url in (URL_A, URL_B, URL_REDIRECT)]
|
||||
outcomes += [attempt(lambda: netlib.fetch(url)) for url in (URL_A, URL_B)]
|
||||
outcomes.append(attempt(lambda: list(netlib.fetch_each([URL_A, URL_B]))))
|
||||
)PY");
|
||||
|
||||
// The redirect target is asked for inside the approved redirecting request, and a library's
|
||||
// wrapper or generator asks for the second URL as plain code does.
|
||||
CHECK(python.strings("outcomes") == std::vector<std::string>{"ok", "denied", "denied", "ok", "denied", "denied"});
|
||||
const std::string url_b = "urllib.Request: " + python.str("URL_B") + "\n";
|
||||
CHECK(describe(harness.prompts) == url_b + url_b + url_b + url_b);
|
||||
}
|
||||
|
||||
TEST_CASE("Code an approved operation calls back into is judged on its own", "[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);
|
||||
python.exec(R"PY(
|
||||
CALLBACK_COPY = os.path.join(OUTSIDE, "callback-copy")
|
||||
handler = netlib.ReadingHandler(PRIVATE)
|
||||
handler_opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), handler)
|
||||
walk_outcomes = []
|
||||
copy_outcomes = []
|
||||
|
||||
def on_walk_error(error):
|
||||
walk_outcomes.append(attempt(lambda: open(PRIVATE).close()))
|
||||
|
||||
def copy_file(source, destination):
|
||||
copy_outcomes.append(attempt(lambda: open(PRIVATE).close()))
|
||||
)PY");
|
||||
PluginPermissions permissions = http_grants({python.str("URL_A")});
|
||||
permissions.fs_read = {python.str("APPROVED"), python.str("MISSING")};
|
||||
permissions.fs_readwrite = {python.str("TREE"), python.str("CALLBACK_COPY")};
|
||||
harness.save_permissions(PLUGIN, permissions);
|
||||
|
||||
// A library handler that urllib calls while the approved request runs, the plugin's own
|
||||
// os.walk error callback and copytree copy function (called once per file), and a library
|
||||
// loop over a saved file and an unsaved one.
|
||||
python.run_as(PLUGIN, R"PY(
|
||||
request_outcome = attempt(lambda: handler_opener.open(URL_A).read())
|
||||
for _ in os.walk(MISSING, onerror=on_walk_error):
|
||||
pass
|
||||
shutil.copytree(TREE, CALLBACK_COPY, copy_function=copy_file)
|
||||
loop_outcome = attempt(lambda: netlib.read_all([APPROVED, PRIVATE]))
|
||||
)PY");
|
||||
|
||||
CHECK(python.str("request_outcome") == "ok");
|
||||
CHECK(python.eval("handler.outcome") == "denied");
|
||||
CHECK(python.strings("walk_outcomes") == std::vector<std::string>{"denied"});
|
||||
CHECK(python.strings("copy_outcomes") == std::vector<std::string>{"denied", "denied"});
|
||||
CHECK(python.str("loop_outcome") == "denied");
|
||||
const std::string private_file = "open: " + python.str("PRIVATE") + "\n";
|
||||
CHECK(describe(harness.prompts) == private_file + private_file + private_file + private_file + private_file);
|
||||
}
|
||||
|
||||
TEST_CASE("A saved directory walk covers each directory it lists", "[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);
|
||||
PluginPermissions permissions;
|
||||
permissions.fs_read = {python.str("TREE"), python.str("APPROVED")};
|
||||
harness.save_permissions(PLUGIN, permissions);
|
||||
|
||||
// The walk lists the tree's two subdirectories on later resumes, after the plugin's own
|
||||
// audited reads in between. Abandoned walks leave frames whose generator is gone, which the
|
||||
// next audited event lets go.
|
||||
python.run_as(PLUGIN, R"PY(
|
||||
walked = []
|
||||
for root, dirs, files in os.walk(TREE):
|
||||
walked.append(root)
|
||||
open(APPROVED).close()
|
||||
globbed = list(pathlib.Path(TREE).glob("**/*"))
|
||||
|
||||
abandoned = os.walk(TREE)
|
||||
next(abandoned)
|
||||
del abandoned
|
||||
open(APPROVED).close()
|
||||
for _ in pathlib.Path(TREE).glob("*"):
|
||||
break
|
||||
open(APPROVED).close()
|
||||
)PY");
|
||||
|
||||
CHECK(py::len(python.globals["walked"]) == 3);
|
||||
CHECK(py::len(python.globals["globbed"]) == 4);
|
||||
CHECK(describe(harness.prompts).empty());
|
||||
}
|
||||
|
||||
TEST_CASE("A freshly approved operation covers its nested events", "[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);
|
||||
|
||||
struct Case
|
||||
{
|
||||
const char* prompts;
|
||||
const char* code;
|
||||
};
|
||||
// Each operation names targets no earlier one saved, so each is asked once. FsCreate and
|
||||
// FsDelete grants are never saved, so those operations are asked every time. Only the
|
||||
// expected prompts are approved, so a nested event left uncovered fails the operation.
|
||||
std::vector<Case> cases = {
|
||||
{"shutil.copytree", "shutil.copytree(TREE, os.path.join(OUTSIDE, 'copy'))"},
|
||||
{"shutil.copyfile", "shutil.copyfile(APPROVED, os.path.join(OUTSIDE, 'copied.txt'))"},
|
||||
{"shutil.move", "shutil.move(os.path.join(OUTSIDE, 'copied.txt'), os.path.join(OUTSIDE, 'moved.txt'))"},
|
||||
{"shutil.copystat", "shutil.copystat(os.path.join(TREE, 'one', 'f.txt'), PRIVATE)"},
|
||||
{"shutil.make_archive", "shutil.make_archive(os.path.join(OUTSIDE, 'archive'), 'zip', root_dir=TREE)"},
|
||||
{"shutil.unpack_archive", "shutil.unpack_archive(os.path.join(OUTSIDE, 'archive.zip'), os.path.join(OUTSIDE, 'unpacked'))"},
|
||||
{"pathlib.Path.glob", "list(pathlib.Path(TREE).glob('**/*'))"},
|
||||
{"subprocess.Popen", "subprocess.run(TRUE_COMMAND, check=True)"},
|
||||
{"tempfile.mkdtemp", "tempfile.mkdtemp(dir=OUTSIDE)"},
|
||||
{"tempfile.mkstemp", "os.close(tempfile.mkstemp(dir=OUTSIDE)[0])"},
|
||||
{"shutil.rmtree", "shutil.rmtree(os.path.join(OUTSIDE, 'copy'))"},
|
||||
// send() runs after connect() has returned, so its own grant is asked once.
|
||||
{"http.client.connect\nhttp.client.send",
|
||||
"connection = http.client.HTTPConnection('127.0.0.1', PORT)\n"
|
||||
"connection.request('GET', '/a')\n"
|
||||
"connection.getresponse().read()\n"
|
||||
"connection.close()"},
|
||||
};
|
||||
#ifndef _WIN32
|
||||
cases.push_back({"os.fwalk", "list(os.fwalk(os.path.join(OUTSIDE, 'unpacked')))"});
|
||||
// The forked child's exec is covered by the spawn too: had it prompted, the child's own
|
||||
// denial would end it with status 1.
|
||||
cases.push_back({"pty.spawn", "spawn_status = spawn_checked(TRUE_COMMAND + ['spawned'])"});
|
||||
#endif
|
||||
|
||||
for (const Case& operation : cases) {
|
||||
INFO(operation.code);
|
||||
const std::string expected = std::string(operation.prompts) + "\n";
|
||||
harness.approve = [&expected](const Prompt& prompt) {
|
||||
return expected.find(prompt.event + "\n") != std::string::npos;
|
||||
};
|
||||
harness.prompts.clear();
|
||||
python.run_as(PLUGIN, operation.code);
|
||||
|
||||
std::string asked;
|
||||
for (const Prompt& prompt : harness.prompts)
|
||||
asked += (asked.empty() ? "" : "\n") + prompt.event;
|
||||
CHECK(asked == operation.prompts);
|
||||
}
|
||||
#ifndef _WIN32
|
||||
CHECK(python.eval("spawn_status") == "0");
|
||||
#endif
|
||||
}
|
||||
|
||||
TEST_CASE("An event with no target is granted by name and remembered", "[audit][Python]")
|
||||
{
|
||||
AuditHarness harness;
|
||||
if (!harness.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
harness.add_plugin(PLUGIN);
|
||||
harness.approve = [](const Prompt& prompt) { return prompt.event != "socket.sendto"; };
|
||||
py::gil_scoped_acquire gil;
|
||||
PythonSide python(harness);
|
||||
|
||||
python.run_as(PLUGIN, R"PY(
|
||||
socket.socket().close()
|
||||
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sendto_outcome = attempt(lambda: udp.sendto(b"x", ("127.0.0.1", 9)))
|
||||
udp.close()
|
||||
)PY");
|
||||
|
||||
// The socket is asked for once, without a target to show; the datagram's address is a target.
|
||||
CHECK(python.str("sendto_outcome") == "denied");
|
||||
CHECK(describe(harness.prompts) == "socket.__new__:\nsocket.sendto: ('127.0.0.1', 9)\n");
|
||||
CHECK(harness.saved_permissions(PLUGIN).events == std::vector<std::string>{"Socket:socket.__new__"});
|
||||
CHECK(harness.saved_permissions(PLUGIN).network_socket.empty());
|
||||
|
||||
harness.prompts.clear();
|
||||
python.run_as(PLUGIN, "socket.socket().close()");
|
||||
CHECK(describe(harness.prompts).empty());
|
||||
|
||||
// A grant for an event with no target covers nothing else: a library's approved
|
||||
// gethostname() does not carry the connection it makes next.
|
||||
harness.approve = [](const Prompt& prompt) { return prompt.event == "socket.gethostname"; };
|
||||
python.run_as(PLUGIN, "connect_outcome = attempt(lambda: netlib.connect_from_host(PORT))");
|
||||
CHECK(python.str("connect_outcome") == "denied");
|
||||
CHECK(describe(harness.prompts) == "socket.gethostname:\nsocket.getaddrinfo: 127.0.0.1\n");
|
||||
harness.prompts.clear();
|
||||
harness.approve = [](const Prompt&) { return true; };
|
||||
|
||||
#ifndef _WIN32
|
||||
// sendmsg() without an address names no target either.
|
||||
python.run_as(PLUGIN, R"PY(
|
||||
left, right = socket.socketpair()
|
||||
left.sendmsg([b"x"])
|
||||
left.close()
|
||||
right.close()
|
||||
)PY");
|
||||
CHECK(describe(harness.prompts) == "socket.sendmsg:\n");
|
||||
CHECK(harness.saved_permissions(PLUGIN).events ==
|
||||
std::vector<std::string>{"Socket:socket.__new__", "Socket:socket.gethostname", "Socket:socket.sendmsg"});
|
||||
#endif
|
||||
}
|
||||
|
||||
TEST_CASE("An event grant does not stand in for another access or a target of the same name", "[audit][Python]")
|
||||
{
|
||||
AuditHarness harness;
|
||||
if (!harness.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
harness.add_plugin(PLUGIN);
|
||||
harness.approve = [asked = 0](const Prompt&) mutable { return asked++ == 0; };
|
||||
py::gil_scoped_acquire gil;
|
||||
PythonSide python(harness);
|
||||
python.exec(R"PY(
|
||||
READ_FD = os.open(APPROVED, os.O_RDONLY)
|
||||
WRITE_FD = os.open(os.path.join(OUTSIDE, "scratch.txt"), os.O_WRONLY | os.O_CREAT)
|
||||
)PY");
|
||||
|
||||
// A file descriptor has no path to show or save: a granted read of one is not a write.
|
||||
python.run_as(PLUGIN, R"PY(
|
||||
fd_outcomes = [attempt(lambda: open(READ_FD, "r", closefd=False).close()),
|
||||
attempt(lambda: open(WRITE_FD, "w", closefd=False).close())]
|
||||
)PY");
|
||||
CHECK(python.strings("fd_outcomes") == std::vector<std::string>{"ok", "denied"});
|
||||
CHECK(describe(harness.prompts) == "open:\nopen:\n");
|
||||
CHECK(harness.saved_permissions(PLUGIN).events == std::vector<std::string>{"FsRead:open"});
|
||||
|
||||
// A saved os.fork event grant is not an os.system command named "os.fork".
|
||||
PluginPermissions permissions;
|
||||
permissions.events = {"FsRead:open", "ProcessCreate:os.fork"};
|
||||
harness.save_permissions(PLUGIN, permissions);
|
||||
harness.prompts.clear();
|
||||
python.run_as(PLUGIN, R"PY(
|
||||
reread_outcome = attempt(lambda: open(READ_FD, "r", closefd=False).close())
|
||||
system_outcome = attempt(lambda: os.system("os.fork"))
|
||||
os.close(READ_FD)
|
||||
os.close(WRITE_FD)
|
||||
)PY");
|
||||
CHECK(python.str("reread_outcome") == "ok");
|
||||
CHECK(python.str("system_outcome") == "denied");
|
||||
REQUIRE(harness.prompts.size() == 1);
|
||||
CHECK(harness.prompts.front().event == "os.system");
|
||||
CHECK(harness.saved_permissions(PLUGIN).process.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("A finalizer run when a root frame is let go is judged as the plugin without a prompt", "[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);
|
||||
PluginPermissions permissions = http_grants({python.str("URL_A")});
|
||||
permissions.fs_read = {python.str("APPROVED")};
|
||||
harness.save_permissions(PLUGIN, permissions);
|
||||
PluginAuditManager& mgr = PluginAuditManager::instance();
|
||||
|
||||
// Once the request returns, only its held frame keeps the request object alive, and the
|
||||
// request's finalizer reads a file the plugin was never granted.
|
||||
python.run_as(PLUGIN, "fetch(TrackedRequest(URL_A))");
|
||||
CHECK(python.strings("finalizer_outcomes") == std::vector<std::string>{"denied"}); // at scope end
|
||||
CHECK(describe(harness.prompts).empty());
|
||||
CHECK_FALSE(mgr.audit_denial_pending());
|
||||
|
||||
python.exec("finalizer_outcomes.clear()");
|
||||
python.run_as(PLUGIN, R"PY(
|
||||
fetch(TrackedRequest(URL_A))
|
||||
open(APPROVED).close() # the next event lets the returned request's frame go
|
||||
outcomes_before_scope_end = list(finalizer_outcomes)
|
||||
)PY");
|
||||
CHECK(python.strings("outcomes_before_scope_end") == std::vector<std::string>{"denied"});
|
||||
CHECK(describe(harness.prompts).empty());
|
||||
|
||||
// The finalizer's denial during another event's decision leaves that event's own denial intact.
|
||||
python.exec("finalizer_outcomes.clear()");
|
||||
python.run_as(PLUGIN, R"PY(
|
||||
fetch(TrackedRequest(URL_A))
|
||||
try:
|
||||
os.listdir(OUTSIDE)
|
||||
outer_error = ""
|
||||
except PermissionError as error:
|
||||
outer_error = str(error)
|
||||
)PY");
|
||||
CHECK(python.strings("finalizer_outcomes") == std::vector<std::string>{"denied"});
|
||||
CHECK_THAT(python.str("outer_error"), Catch::Matchers::ContainsSubstring("os.listdir"));
|
||||
CHECK(describe(harness.prompts) == "os.listdir: " + python.str("OUTSIDE") + "\n");
|
||||
AuditViolation violation;
|
||||
REQUIRE(mgr.last_violation(violation));
|
||||
CHECK(violation.event_name == "os.listdir");
|
||||
CHECK(mgr.audit_denial_pending());
|
||||
}
|
||||
|
||||
TEST_CASE("A nested audit scope does not see the outer scope's running operations", "[audit][Python]")
|
||||
{
|
||||
AuditHarness harness;
|
||||
if (!harness.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
harness.add_plugin("PluginA");
|
||||
harness.add_plugin("PluginB");
|
||||
py::gil_scoped_acquire gil;
|
||||
PythonSide python(harness);
|
||||
PluginPermissions permissions;
|
||||
permissions.fs_read = {python.str("TREE")};
|
||||
harness.save_permissions("PluginA", permissions);
|
||||
|
||||
{
|
||||
// Two walks plugin A started and suspended after listing the tree itself.
|
||||
ScopedPluginAuditContext plugin_a("PluginA");
|
||||
python.exec("first = os.walk(TREE)\nnext(first)\nsecond = os.walk(TREE)\nnext(second)");
|
||||
{
|
||||
// Plugin B resuming one of them is asked for each subdirectory, as plugin B.
|
||||
ScopedPluginAuditContext plugin_b("PluginB");
|
||||
python.exec("listed_by_b = len(list(first))");
|
||||
}
|
||||
// Back in plugin A's scope, its walks are covered again.
|
||||
python.exec("listed_by_a = len(list(second))");
|
||||
}
|
||||
|
||||
CHECK(python.globals["listed_by_b"].cast<int>() == 0);
|
||||
CHECK(python.globals["listed_by_a"].cast<int>() == 2);
|
||||
REQUIRE(harness.prompts.size() == 2);
|
||||
for (const Prompt& prompt : harness.prompts) {
|
||||
CHECK(prompt.plugin == "PluginB");
|
||||
CHECK(prompt.event == "os.scandir");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Root frames held when a capability call ends are let go with the GIL, as the plugin", "[audit][Python]")
|
||||
{
|
||||
AuditHarness harness;
|
||||
if (!harness.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
harness.approve = [](const Prompt& prompt) { return prompt.event == "os.walk"; };
|
||||
py::gil_scoped_acquire gil;
|
||||
PythonSide python(harness);
|
||||
(void) PythonPluginBridge::instance(); // force the embedded module registration into the binary
|
||||
python.globals["orca"] = py::module_::import("orca");
|
||||
|
||||
// walk_and_drop() leaves a returned walk's frame held by the audit scope alone, with a marker
|
||||
// in its locals; the frame is let go when the capability call ends, which runs the marker's
|
||||
// finalizer.
|
||||
python.exec(R"PY(
|
||||
class Marker:
|
||||
def __init__(self, action=None):
|
||||
self.action = action
|
||||
def __del__(self):
|
||||
finalized.append(self.action() if self.action else "ran")
|
||||
|
||||
finalized = []
|
||||
|
||||
def walk_and_drop(marker):
|
||||
walker = os.walk(TREE, onerror=marker)
|
||||
next(walker)
|
||||
del walker
|
||||
|
||||
class ConfigCapability(orca.PythonPluginBase):
|
||||
def get_name(self):
|
||||
return "config_cap"
|
||||
def get_default_config(self):
|
||||
walk_and_drop(Marker())
|
||||
return {"finalized_during_call": len(finalized)}
|
||||
def has_config_ui(self):
|
||||
walker = os.walk(TREE, onerror=Marker(lambda: attempt(lambda: open(PRIVATE).close())))
|
||||
next(walker)
|
||||
attempt(lambda: os.listdir(OUTSIDE)) # denied: the trampoline clears the plugin key
|
||||
del walker # the walk's frame is now held by the audit scope alone
|
||||
raise RuntimeError("the capability fails after a denial")
|
||||
|
||||
class TicketAgent(orca.printer_agent.PrinterAgentBase):
|
||||
def get_name(self):
|
||||
return "ticket_agent"
|
||||
def request_bind_ticket(self):
|
||||
walk_and_drop(Marker())
|
||||
return (0, "ticket")
|
||||
)PY");
|
||||
|
||||
py::object config_instance = python.globals["ConfigCapability"]();
|
||||
auto config_cap = config_instance.cast<std::shared_ptr<PluginCapabilityInterface>>();
|
||||
config_cap->set_audit_plugin_key("GilPlugin");
|
||||
config_cap->set_resolved_identity("config_cap", PluginCapabilityType::Script);
|
||||
|
||||
py::object agent_instance = python.globals["TicketAgent"]();
|
||||
auto agent_cap = agent_instance.cast<std::shared_ptr<PluginCapabilityInterface>>();
|
||||
agent_cap->set_audit_plugin_key("GilPlugin");
|
||||
agent_cap->set_resolved_identity("ticket_agent", PluginCapabilityType::PrinterConnection);
|
||||
auto agent = std::dynamic_pointer_cast<IPrinterAgent>(agent_cap);
|
||||
REQUIRE(agent);
|
||||
|
||||
// A host call arrives without the GIL; both calls take it before their audit scope.
|
||||
nlohmann::json config;
|
||||
int ticket_result = -1;
|
||||
std::string ticket;
|
||||
{
|
||||
py::gil_scoped_release released;
|
||||
config = config_cap->get_default_config();
|
||||
ticket_result = agent->request_bind_ticket(&ticket);
|
||||
}
|
||||
CHECK(config == nlohmann::json{{"finalized_during_call", 0}});
|
||||
CHECK(ticket_result == 0);
|
||||
CHECK(ticket == "ticket");
|
||||
CHECK(python.strings("finalized") == std::vector<std::string>{"ran", "ran"});
|
||||
|
||||
// On the trampoline's denial path the frame is let go as the plugin, though the ambient key
|
||||
// was cleared: the finalizer's read is audited and denied without a prompt.
|
||||
python.exec("finalized.clear()");
|
||||
harness.prompts.clear();
|
||||
CHECK_THROWS_AS(config_cap->has_config_ui(), py::error_already_set);
|
||||
CHECK(python.strings("finalized") == std::vector<std::string>{"denied"});
|
||||
CHECK(describe(harness.prompts) == "os.walk: " + python.str("TREE") + "\nos.listdir: " + python.str("OUTSIDE") + "\n");
|
||||
}
|
||||
|
||||
TEST_CASE("Stdlib code is recognized through a symlinked Python home", "[audit][Python]")
|
||||
{
|
||||
AuditHarness harness;
|
||||
if (!harness.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
py::gil_scoped_acquire gil;
|
||||
|
||||
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>();
|
||||
const fs::path link = harness.outside.path() / "linked-stdlib";
|
||||
boost::system::error_code ec;
|
||||
fs::create_directory_symlink(stdlib, link, ec);
|
||||
if (ec)
|
||||
SKIP("Cannot create a directory symlink here: " + ec.message());
|
||||
|
||||
CHECK(PluginAuditManager::is_stdlib_file((stdlib / "os.py").string()));
|
||||
CHECK(PluginAuditManager::is_stdlib_file((link / "urllib" / "request.py").string()));
|
||||
CHECK(PluginAuditManager::is_stdlib_file("<frozen os>"));
|
||||
CHECK_FALSE(PluginAuditManager::is_stdlib_file((purelib / "requests" / "api.py").string()));
|
||||
CHECK_FALSE(PluginAuditManager::is_stdlib_file((link / "site-packages" / "requests" / "api.py").string()));
|
||||
CHECK_FALSE(PluginAuditManager::is_stdlib_file((harness.outside.path() / "plugin.py").string()));
|
||||
CHECK_FALSE(PluginAuditManager::is_stdlib_file("<string>"));
|
||||
}
|
||||
Reference in new issue
Block a user