Compare commits

...
Author SHA1 Message Date
Hanif Koh 55e1e885d5 Keep the Test Font in the Flatpak Test Asset
The Flatpak unit-test leg prunes resources/ down to profiles, printers
and images before shipping the build tree to the test job, so the
bundled font the text-import tests vectorize never reached the sandbox.
Keep resources/fonts, trimmed to the one Regular face those tests read.
2026-10-20 17:06:19 +08:00
Hanif Koh 777f14fad4 Address Review of the Reworked Unit Tests
Recompute the v4 recipe comparison once instead of per feature (148 s to 6 s) and compare bounding boxes as well as volumes; drop the incidental NaN loop from the unrepairable Voronoi case; prove the renamed preset is the one selected instead of checking a dirty flag that selecting clears; tell sparse from empty infill in the solid-shell test; plus smaller cleanups.
2026-10-20 14:11:26 +08:00
Hanif Koh 42bd103958 Make Unit Tests That Could Never Fail Check Real Behavior
Rework tests whose assertions held whatever the production code did: inverted or self-referential checks, checks on values the test had just set, branches that never ran, symmetric fixtures that hid the behavior under test, font paths that skipped silently, and Voronoi reproducers that only exercised boost. Each now asserts the behavior its name describes.
2026-10-20 14:11:26 +08:00
SoftFever 6626377abc Stop re-asking for plugin permissions granted in an earlier session (#16658) 2026-10-20 13:38:44 +08:00
HanifKoh 74d4392dae Keep a Disabled Filament Override Off After a Restart (Key List Variant) (#16650)
Keep a Disabled Filament Override Off After a Restart

A child preset stores "nil" both for an override the user switched off and
for a per-variant column it inherits from its parent, and the loader filled
every nil from the parent. Whenever the parent preset sets Retract Before
Wipe or another override, switching it off lasted only until the next
restart, project open or newer cloud copy.

Treat the keys in is_filament_extruder_override_key, now including the
ironing overrides, as "nil means off": Preset::save and the project-embedded
preset diff write such an option with all its columns, and the loader keeps
its nil columns while still mapping them onto the parent's variants by
name. Every other nullable option keeps nil as "inherit this column". The
cloud diff only force-emits a disabled override whose default is a real
value.
2026-10-20 13:38:20 +08:00
HanifKoh 0d5b3a79d7 Keep a Scalar Config Option's Value When Deserializing Fails (#16641)
A failed stream extraction writes 0 into its target, so Float, Int, Percent and FloatOrPercent options were overwritten before deserialize reported the error, and Point/Point3 kept a partially scanned value. Parse into a temporary and assign only on success; FloatOrPercent sets its percent flag only then too.
2026-10-20 13:02:15 +08:00
29 changed files with 1894 additions and 466 deletions

No files matched your search

+6 -3
View File
@@ -466,13 +466,16 @@ jobs:
# the bounds checks are compiled in, so a stripped exe still catches them. # the bounds checks are compiled in, so a stripped exe still catches them.
find "$d/build_flatpak/tests" -type f -perm -u+x -exec strip --strip-unneeded {} + 2>/dev/null || true find "$d/build_flatpak/tests" -type f -perm -u+x -exec strip --strip-unneeded {} + 2>/dev/null || true
# At runtime the tests read tests/ (TEST_DATA_DIR), scripts/, and under # At runtime the tests read tests/ (TEST_DATA_DIR), scripts/, and under
# resources/ the shipped profiles (PROFILES_DIR), the printers/ maps, and # resources/ the shipped profiles (PROFILES_DIR), the printers/ maps, the
# the icon SVGs (NativeCommands icon names are checked against them). # icon SVGs (NativeCommands icon names are checked against them), and the
# one font the text-import tests vectorize.
find "$d" -mindepth 1 -maxdepth 1 -type d \ find "$d" -mindepth 1 -maxdepth 1 -type d \
! -name tests ! -name build_flatpak ! -name scripts ! -name resources -exec rm -rf {} + ! -name tests ! -name build_flatpak ! -name scripts ! -name resources -exec rm -rf {} +
find "$d/resources" -mindepth 1 -maxdepth 1 ! -name profiles ! -name printers ! -name images -exec rm -rf {} + find "$d/resources" -mindepth 1 -maxdepth 1 \
! -name profiles ! -name printers ! -name images ! -name fonts -exec rm -rf {} +
# Only the SVGs are read; the png/ico/icns/gif assets are ~35MB of dead weight. # Only the SVGs are read; the png/ico/icns/gif assets are ~35MB of dead weight.
find "$d/resources/images" -mindepth 1 -maxdepth 1 ! -name '*.svg' -exec rm -rf {} + 2>/dev/null || true find "$d/resources/images" -mindepth 1 -maxdepth 1 ! -name '*.svg' -exec rm -rf {} + 2>/dev/null || true
find "$d/resources/fonts" -mindepth 1 -maxdepth 1 ! -name HarmonyOS_Sans_SC_Regular.ttf -exec rm -rf {} +
tar -cf flatpak-test-asset.tar flatpak_app "$d" tar -cf flatpak-test-asset.tar flatpak_app "$d"
- name: Upload flatpak test asset - name: Upload flatpak test asset
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
+41 -16
View File
@@ -377,7 +377,8 @@ public:
virtual void set(const ConfigOption* rhs, size_t start, size_t len) = 0; 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(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; virtual void set_with_restore_2(const ConfigOptionVectorBase* rhs, std::vector<int>& restore_index, int start, int len, bool skip_error = false) = 0;
virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector<int>& diff_index, int stride) = 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_to_index(const ConfigOptionVectorBase* rhs, std::vector<int>& dest_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; 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. // Resize the vector of values, copy the newly added values from opt_default if provided.
@@ -580,7 +581,7 @@ public:
//set a item related with extruder variants when loading user config, only set the different value of some extruder //set a item related with extruder variants when loading user config, only set the different value of some extruder
//rhs: item from user config //rhs: item from user config
//diff_index: which index in this vector need to be set //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) override virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector<int>& diff_index, int stride, bool keep_nil) override
{ {
if (rhs->type() == this->type()) { if (rhs->type() == this->type()) {
// Assign the first value of the rhs vector. // Assign the first value of the rhs vector.
@@ -592,7 +593,7 @@ public:
for (size_t i = 0; i < diff_index.size(); i++) { for (size_t i = 0; i < diff_index.size(); i++) {
if (diff_index[i] != -1) { if (diff_index[i] != -1) {
for (size_t j = 0; j < stride; j++) { for (size_t j = 0; j < stride; j++) {
if (!other->is_nil(diff_index[i] * stride)) if (keep_nil || !other->is_nil(diff_index[i] * stride))
this->values[i * stride + j] = other->values[diff_index[i] * stride + j]; this->values[i * stride + j] = other->values[diff_index[i] * stride + j];
} }
} }
@@ -841,8 +842,12 @@ public:
{ {
UNUSED(append); UNUSED(append);
std::istringstream iss(str); std::istringstream iss(str);
iss >> this->value; double value;
return !iss.fail(); iss >> value;
if (iss.fail())
return false;
this->value = value;
return true;
} }
bool operator==(const ConfigOption &rhs) const override bool operator==(const ConfigOption &rhs) const override
@@ -1033,8 +1038,12 @@ public:
{ {
UNUSED(append); UNUSED(append);
std::istringstream iss(str); std::istringstream iss(str);
iss >> this->value; int value;
return !iss.fail(); iss >> value;
if (iss.fail())
return false;
this->value = value;
return true;
} }
ConfigOptionInt& operator=(const ConfigOption *opt) ConfigOptionInt& operator=(const ConfigOption *opt)
@@ -1252,8 +1261,12 @@ public:
UNUSED(append); UNUSED(append);
// don't try to parse the trailing % since it's optional // don't try to parse the trailing % since it's optional
std::istringstream iss(str); std::istringstream iss(str);
iss >> this->value; double value;
return !iss.fail(); iss >> value;
if (iss.fail())
return false;
this->value = value;
return true;
} }
private: private:
@@ -1365,10 +1378,14 @@ public:
bool deserialize(const std::string &str, bool append = false) override bool deserialize(const std::string &str, bool append = false) override
{ {
UNUSED(append); UNUSED(append);
this->percent = str.find_first_of("%") != std::string::npos;
std::istringstream iss(str); std::istringstream iss(str);
iss >> this->value; double value;
return !iss.fail(); iss >> value;
if (iss.fail())
return false;
this->value = value;
this->percent = str.find_first_of("%") != std::string::npos;
return true;
} }
private: private:
@@ -1538,8 +1555,12 @@ public:
{ {
UNUSED(append); UNUSED(append);
char dummy; char dummy;
return sscanf(str.data(), " %lf , %lf %c", &this->value(0), &this->value(1), &dummy) == 2 || Vec2d value;
sscanf(str.data(), " %lf x %lf %c", &this->value(0), &this->value(1), &dummy) == 2; 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;
} }
private: private:
@@ -1658,8 +1679,12 @@ public:
{ {
UNUSED(append); UNUSED(append);
char dummy; char dummy;
return sscanf(str.data(), " %lf , %lf , %lf %c", &this->value(0), &this->value(1), &this->value(2), &dummy) == 3 || Vec3d value;
sscanf(str.data(), " %lf x %lf x %lf %c", &this->value(0), &this->value(1), &this->value(2), &dummy) == 3; 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;
} }
private: private:
+8 -5
View File
@@ -797,7 +797,7 @@ bool Preset::save(DynamicPrintConfig* parent_config)
{ {
ConfigOption *opt_src = config.option(option); ConfigOption *opt_src = config.option(option);
ConfigOption *opt_dst = temp_config.option(option, true); ConfigOption *opt_dst = temp_config.option(option, true);
if (opt_dst->is_scalar() || !(opt_dst->nullable())) if (opt_dst->is_scalar() || !(opt_dst->nullable()) || is_filament_extruder_override_key(option))
opt_dst->set(opt_src); opt_dst->set(opt_src);
else { else {
ConfigOptionVectorBase* opt_vec_src = static_cast<ConfigOptionVectorBase*>(opt_src); 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_src = preset.config.option(option);
ConfigOption *opt_dst = temp_config.option(option, true); ConfigOption *opt_dst = temp_config.option(option, true);
if (opt_dst->is_scalar() || !(opt_dst->nullable())) if (opt_dst->is_scalar() || !(opt_dst->nullable()) || is_filament_extruder_override_key(option))
opt_dst->set(opt_src); opt_dst->set(opt_src);
else { else {
ConfigOptionVectorBase* opt_vec_src = static_cast<ConfigOptionVectorBase*>(opt_src); ConfigOptionVectorBase* opt_vec_src = static_cast<ConfigOptionVectorBase*>(opt_src);
@@ -2199,9 +2199,12 @@ 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 // 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. // resurrecting an override the user turned off. See GitHub issue on Retract Before Wipe.
if (m_type == Preset::TYPE_FILAMENT) { if (m_type == Preset::TYPE_FILAMENT) {
for (const std::string& opt_key : filament_extruder_override_keys) { for (const std::string& opt_key : preset.config.keys()) {
if (key_values.count(opt_key)) if (!is_filament_extruder_override_key(opt_key) || key_values.count(opt_key))
continue; // already carried by the diff 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
const auto* opt_vec = dynamic_cast<const ConfigOptionVectorBase*>(preset.config.option(opt_key)); const auto* opt_vec = dynamic_cast<const ConfigOptionVectorBase*>(preset.config.option(opt_key));
if (opt_vec == nullptr) if (opt_vec == nullptr)
continue; continue;
+11 -4
View File
@@ -119,12 +119,18 @@ const std::vector<std::string> filament_extruder_override_keys = {
}; };
// Some filament override parameters are generated from filament_extruder_override_keys, // Some filament override parameters are generated from filament_extruder_override_keys,
// while filament_retract_length_nc is defined separately. Keep the generator list // while filament_retract_length_nc and the ironing overrides are defined separately.
// unchanged and use this helper for behavior checks that need the full override set. // 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".
bool is_filament_extruder_override_key(const std::string &opt_key) 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() || 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_retract_length_nc" ||
opt_key == "filament_ironing_flow" ||
opt_key == "filament_ironing_spacing" ||
opt_key == "filament_ironing_inset" ||
opt_key == "filament_ironing_speed";
} }
size_t get_extruder_index(const GCodeConfig& config, unsigned int filament_id) size_t get_extruder_index(const GCodeConfig& config, unsigned int filament_id)
@@ -12272,7 +12278,8 @@ void DynamicPrintConfig::update_diff_values_to_child_config(DynamicPrintConfig&
opt_src->set(opt_target); opt_src->set(opt_target);
} }
else else
opt_vec_src->set_only_diff(opt_vec_dest, variant_index, stride); // 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));
} }
} }
} }
+340 -133
View File
@@ -11,6 +11,7 @@
#include <boost/log/trivial.hpp> #include <boost/log/trivial.hpp>
#include <algorithm> #include <algorithm>
#include <cassert>
#include <cctype> #include <cctype>
#include <cstddef> #include <cstddef>
#include <cstdlib> #include <cstdlib>
@@ -130,6 +131,20 @@ static AuditEventCategory event_category(const std::string& event_name)
return it == audit_event_categories.end() ? AuditEventCategory::None : it->second; 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 // 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 // address or a process command line -- the deny-path and allowed-root checks only make sense
// against a path. // against a path.
@@ -146,6 +161,24 @@ 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 // Path safety
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -215,26 +248,50 @@ 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_plugin_key = "";
thread_local std::string PluginAuditManager::m_current_capability_name = ""; thread_local std::string PluginAuditManager::m_current_capability_name = "";
thread_local std::vector<AllowedRoot> PluginAuditManager::m_scoped_allowed_roots; 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_audit_denial_pending = false;
thread_local bool PluginAuditManager::m_has_last_violation = false; thread_local bool PluginAuditManager::m_has_last_violation = false;
thread_local AuditViolation PluginAuditManager::m_last_violation; thread_local AuditViolation PluginAuditManager::m_last_violation;
ScopedPluginAuditContext::ScopedPluginAuditContext(const std::string& plugin_key, ScopedPluginAuditContext::ScopedPluginAuditContext(const std::string& plugin_key,
const std::string& capability_name) const std::string& capability_name)
: m_previous_id(PluginAuditManager::instance().current_plugin()) : m_plugin_key(plugin_key)
, m_capability(capability_name)
, m_previous_id(PluginAuditManager::instance().current_plugin())
, m_previous_capability(PluginAuditManager::instance().current_capability()) , m_previous_capability(PluginAuditManager::instance().current_capability())
, m_previous_scoped_roots(PluginAuditManager::m_scoped_allowed_roots) , 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_plugin(plugin_key);
PluginAuditManager::instance().set_current_capability(capability_name); PluginAuditManager::instance().set_current_capability(capability_name);
PluginAuditManager::m_scoped_allowed_roots.clear(); PluginAuditManager::m_scoped_allowed_roots.clear();
PluginAuditManager::m_root_frames.clear();
} }
ScopedPluginAuditContext::~ScopedPluginAuditContext() ScopedPluginAuditContext::~ScopedPluginAuditContext()
{ {
PluginAuditManager::instance().set_current_plugin(m_previous_id); PluginAuditManager& mgr = PluginAuditManager::instance();
PluginAuditManager::instance().set_current_capability(m_previous_capability);
// 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::m_scoped_allowed_roots = std::move(m_previous_scoped_roots); PluginAuditManager::m_scoped_allowed_roots = std::move(m_previous_scoped_roots);
PluginAuditManager::m_root_frames = std::move(m_previous_root_frames);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -575,34 +632,6 @@ bool PluginAuditManager::last_violation(AuditViolation& violation) const
return true; 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 // The C-level audit hook
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -663,40 +692,88 @@ std::string python_str(PyObject* object)
return result; return result;
} }
std::vector<std::string> call_site_identities(const std::string& plugin_root) // 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
std::vector<std::string> ids; // touched while the GIL is held.
if (plugin_root.empty()) static std::vector<boost::filesystem::path> s_stdlib_dirs;
return ids; 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()
{
s_stdlib_dirs.clear();
s_site_package_dirs.clear();
s_stdlib_file_verdicts.clear();
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 PyFrameObject* frame = PyEval_GetFrame(); // borrowed reference
Py_XINCREF(frame); // normalize to an owned reference for the loop below Py_XINCREF(frame); // normalize to an owned reference for the loop below
while (frame) { while (frame) {
PyCodeObject* code = PyFrame_GetCode(frame); // new reference frames.push_back(reinterpret_cast<PyObject*>(frame));
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 PyFrameObject* back = PyFrame_GetBack(frame); // new reference, or nullptr at the top of the stack
Py_DECREF(frame); Py_DECREF(frame);
frame = back; frame = back;
}
if (is_plugin_frame) return frames;
break;
if (!id.empty())
ids.push_back(std::move(id));
} }
Py_XDECREF(frame); // only holds a reference here if the loop exited via break // 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;
return ids; 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());
} }
static const std::unordered_set<std::string> two_path_fs_events{ static const std::unordered_set<std::string> two_path_fs_events{
@@ -717,12 +794,15 @@ static const std::unordered_map<std::string, std::vector<Py_ssize_t>> audit_targ
{"socket.getnameinfo", {0}}, {"socket.getnameinfo", {0}},
{"socket.getservbyname", {0}}, {"socket.getservbyname", {0}},
{"socket.getservbyport", {0}}, {"socket.getservbyport", {0}},
{"socket.sendto", {1}},
{"socket.sendmsg", {1}},
{"os.system", {0}}, {"os.system", {0}},
{"subprocess.Popen", {1, 0}}, {"subprocess.Popen", {1, 0}},
{"os.posix_spawn", {1, 0}}, {"os.posix_spawn", {1, 0}},
{"os.spawn", {2, 1}}, {"os.spawn", {2, 1}},
{"os.startfile", {0}}, {"os.startfile", {0}},
{"os.startfile/2", {0}},
{"pty.spawn", {0}}, {"pty.spawn", {0}},
{"_winapi.CreateProcess", {1, 0}}, {"_winapi.CreateProcess", {1, 0}},
{"_posixsubprocess.fork_exec", {0}}, {"_posixsubprocess.fork_exec", {0}},
@@ -767,7 +847,11 @@ std::vector<std::string> audit_targets(const std::string& event_name, AuditEvent
const auto it = audit_target_arg_indices.find(event_name); const auto it = audit_target_arg_indices.find(event_name);
if (it != audit_target_arg_indices.end()) { if (it != audit_target_arg_indices.end()) {
for (const Py_ssize_t index : it->second) { for (const Py_ssize_t index : it->second) {
std::string value = python_str(tuple_item(args, index)); // 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);
if (!value.empty()) { if (!value.empty()) {
targets.push_back(std::move(value)); targets.push_back(std::move(value));
break; break;
@@ -778,8 +862,14 @@ std::vector<std::string> audit_targets(const std::string& event_name, AuditEvent
return targets; return targets;
} }
std::vector<std::string>* permission_list_for(AuditEventCategory category, PluginPermissions& permissions) // The saved list that grants an event: its category's target list, or the event-grant list for
// an event with no target. Kept apart, so that no grant doubles as a target of the same name (an
// os.fork grant is not an os.exec of a command named "os.fork").
std::vector<std::string>* permission_list_for(AuditEventCategory category, bool by_event, PluginPermissions& permissions)
{ {
if (by_event)
return &permissions.events;
switch (category) { switch (category) {
case AuditEventCategory::FsRead: return &permissions.fs_read; case AuditEventCategory::FsRead: return &permissions.fs_read;
case AuditEventCategory::FsReadWrite: return &permissions.fs_readwrite; case AuditEventCategory::FsReadWrite: return &permissions.fs_readwrite;
@@ -792,6 +882,13 @@ std::vector<std::string>* permission_list_for(AuditEventCategory category, Plugi
} }
} }
// 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) bool has_permission(const std::vector<std::string>& granted, const std::string& target)
{ {
return std::find(granted.begin(), granted.end(), target) != granted.end(); return std::find(granted.begin(), granted.end(), target) != granted.end();
@@ -902,22 +999,25 @@ 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 // 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. // freshly because the async prompt outlives the caller's stack copy of it.
void persist_grant(const std::string& plugin_key, AuditEventCategory category, const std::vector<std::string>& targets) void persist_grant(const std::string& plugin_key, AuditEventCategory category, bool by_event, const std::vector<std::string>& keys)
{ {
PluginInstallState state; PluginInstallState state;
if (!PluginManager::instance().get_install_state(plugin_key, state)) if (!PluginManager::instance().get_install_state(plugin_key, state))
return; return;
std::vector<std::string>* permission_list = permission_list_for(category, state.permissions); std::vector<std::string>* permission_list = permission_list_for(category, by_event, state.permissions);
if (!permission_list) if (!permission_list)
return; return;
for (const auto& target : targets) for (const auto& key : keys)
persist_permission(plugin_key, state, *permission_list, target); persist_permission(plugin_key, state, *permission_list, key);
} }
// Requests permission for an audited event, returning true when it is already granted or the user static PluginAuditManager::PermissionPrompt s_permission_prompt_for_testing;
// approves an inline prompt.
// Asks the user to grant an audited event, returning true when they approve an inline prompt.
// `keys` are what a Yes saves: the unresolved targets, or the event-grant key when the event has
// no target (by_event), which the prompt does not show as a target.
// //
// An audited event can fire on a thread the UI thread may itself be blocked waiting on: the // 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 // SlicingPipeline hook runs on the slicing worker thread (see PluginHooks.cpp), and
@@ -929,56 +1029,205 @@ void persist_grant(const std::string& plugin_key, AuditEventCategory category, c
// prompt); the grant is persisted once the user accepts, so a later attempt succeeds without // prompt); the grant is persisted once the user accepts, so a later attempt succeeds without
// re-prompting. // re-prompting.
bool request_permission(AuditEventCategory category, const std::string& plugin_key, const std::string& plugin_name, bool request_permission(AuditEventCategory category, const std::string& plugin_key, const std::string& plugin_name,
const std::string& event_name, const std::vector<std::string>& unresolved) const std::string& event_name, bool by_event, const std::vector<std::string>& keys)
{ {
const std::vector<std::string> shown = by_event ? std::vector<std::string>{} : keys;
if (s_permission_prompt_for_testing)
return s_permission_prompt_for_testing(event_name, shown);
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing()) if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
return false; return false;
if (wxIsMainThread()) if (wxIsMainThread())
return prompt_for_targets(category, plugin_name, event_name, unresolved); return prompt_for_targets(category, plugin_name, event_name, shown);
GUI::wxGetApp().CallAfter([category, plugin_key, plugin_name, event_name, unresolved]() { GUI::wxGetApp().CallAfter([category, plugin_key, plugin_name, event_name, by_event, keys, shown]() {
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing()) if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
return; return;
if (prompt_for_targets(category, plugin_name, event_name, unresolved)) if (prompt_for_targets(category, plugin_name, event_name, shown))
persist_grant(plugin_key, category, unresolved); persist_grant(plugin_key, category, by_event, keys);
}); });
return false; 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, int decide_audited_event(PluginAuditManager& mgr,
PluginInstallState& state, PluginInstallState& state,
const std::string& plugin_key, const std::string& plugin_key,
const std::string& plugin_name, const std::string& plugin_name,
const std::string& event_name, const std::string& event_name,
AuditEventCategory category, 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)
{ {
std::vector<std::string> unresolved = targets; const bool by_event = targets.empty();
const std::vector<std::string> keys = by_event ? std::vector<std::string>{event_grant_key(category, event_name)} : targets;
std::vector<std::string>* permission_list = permission_list_for(category, by_event, state.permissions);
std::vector<std::string> unresolved = keys;
if (permission_list) { if (permission_list) {
unresolved.clear(); unresolved.clear();
for (const auto& target : targets) for (const auto& key : keys)
if (!has_permission(*permission_list, target)) if (!has_permission(*permission_list, key))
unresolved.push_back(target); unresolved.push_back(key);
if (!targets.empty() && unresolved.empty()) if (unresolved.empty())
return 0; return 0;
} }
if (!request_permission(category, plugin_key, plugin_name, event_name, unresolved)) // Letting a root frame go runs finalizers inside a scope destructor or another event's hook,
// where no dialog may open: what they do must already be granted.
if (mgr.releasing_frames())
return report_denied(mgr, event_name, {false, "audit permission required"});
if (!request_permission(category, plugin_key, plugin_name, event_name, by_event, unresolved))
return report_denied(mgr, event_name, {false, "audit permission required"}); return report_denied(mgr, event_name, {false, "audit permission required"});
if (permission_list) if (permission_list)
for (const auto& target : unresolved) for (const auto& key : unresolved)
persist_permission(plugin_key, state, *permission_list, target); persist_permission(plugin_key, state, *permission_list, key);
mgr.record_approved_call_sites(plugin_key, call_site_ids);
return 0; 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 } // 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) int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user_data)
{ {
auto* mgr = static_cast<PluginAuditManager*>(user_data); auto* mgr = static_cast<PluginAuditManager*>(user_data);
@@ -1006,62 +1255,20 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user
if (event_type == AuditEventCategory::None) if (event_type == AuditEventCategory::None)
return 0; return 0;
const bool fs_category = is_fs_category(event_type); // Root frames whose operation has returned are let go once this event is decided, so that a
const std::vector<std::string> targets = PluginAuditDetail::audit_targets(event_name, event_type, args); // finalizer they run re-enters a hook whose held frames are already consistent.
std::vector<PyObject*> returned_frames;
// A denied path (secrets, certificates, config files -- see is_denied_path) is an PluginAuditDetail::take_returned_frames(m_root_frames, returned_frames);
// unconditional block: checked before the ancestor-cascade check below, so a cascade const int result = PluginAuditDetail::audit_plugin_event(*mgr, event_name, event_type, args);
// approval recorded for an unrelated action can never launder access to one, and before mgr->release_frames(returned_frames);
// the allowed-root shortcut further down, so an allowed root (e.g. the read-only resources return result;
// 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() 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 // 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 // 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) -- // (data_dir()/orca_plugins) and the installed/system profile cache (data_dir()/system) --
+34 -13
View File
@@ -4,10 +4,9 @@
// Via pybind11 so this file requests the same python3xx.lib as everything else. // Via pybind11 so this file requests the same python3xx.lib as everything else.
#include <boost/filesystem/path.hpp> #include <boost/filesystem/path.hpp>
#include <pybind11/conduit/wrap_include_python_h.h> #include <pybind11/conduit/wrap_include_python_h.h>
#include <functional>
#include <mutex> #include <mutex>
#include <string> #include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector> #include <vector>
#include <boost/filesystem.hpp> #include <boost/filesystem.hpp>
@@ -144,15 +143,26 @@ public:
void clear_last_violation(); void clear_last_violation();
bool last_violation(AuditViolation& violation) const; bool last_violation(AuditViolation& violation) const;
// --- call-site cascade cache --- // --- operation-root frames ---
// A single plugin action often fires several nested CPython audit events as it passes // One stdlib operation fires several nested audit events as it passes through lower layers:
// through stdlib layers (urllib.request calling http.client calling socket, for example). // urlopen() raises urllib.Request, then http.client and socket events. When an operation's
// Once the user approves one event, every stdlib frame still on the stack for that call // own root event is granted (by a saved grant or the user), the stdlib frame that raised it
// is recorded here by (filename, function, first line) identity. A later event whose own // is held, and a later event whose stack reaches that frame through stdlib frames only is
// ancestor chain still contains one of those frames is the same logical action seen from // part of the same operation, so it is allowed without a prompt of its own. Plugin or
// a deeper layer, so it is auto-approved instead of prompting again. // library code the operation calls back into is judged on its own. A frame is let go once it
bool has_approved_ancestor(const std::string& plugin_key, const std::vector<std::string>& call_site_ids) const; // returns, a suspended generator's (os.walk) when the audit scope ends. Letting a frame go may
void record_approved_call_sites(const std::string& plugin_key, const std::vector<std::string>& call_site_ids); // 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);
bool verbose_events = true; bool verbose_events = true;
@@ -163,24 +173,32 @@ private:
static int audit_hook(const char* event, PyObject* args, void* user_data); 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_plugin_key;
static thread_local std::string m_current_capability_name; static thread_local std::string m_current_capability_name;
static thread_local std::vector<AllowedRoot> m_scoped_allowed_roots; 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_audit_denial_pending;
static thread_local bool m_has_last_violation; static thread_local bool m_has_last_violation;
static thread_local AuditViolation m_last_violation; static thread_local AuditViolation m_last_violation;
// mutable: is_denied_filename() and has_approved_ancestor() are const queries that must lock. // mutable: is_denied_filename() is a const query that must lock.
mutable std::mutex m_mutex; mutable std::mutex m_mutex;
std::vector<AllowedRoot> m_global_allowed_roots; std::vector<AllowedRoot> m_global_allowed_roots;
std::vector<std::string> m_denied_filenames; std::vector<std::string> m_denied_filenames;
std::vector<std::string> m_denied_path_keywords; 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 // 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 // pair on scope exit. `capability_name` may be empty for calls that are not scoped to a
// single capability. // 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 class ScopedPluginAuditContext
{ {
public: public:
@@ -194,9 +212,12 @@ public:
ScopedPluginAuditContext& operator=(const ScopedPluginAuditContext&) = delete; ScopedPluginAuditContext& operator=(const ScopedPluginAuditContext&) = delete;
private: private:
std::string m_plugin_key;
std::string m_capability;
std::string m_previous_id; std::string m_previous_id;
std::string m_previous_capability; std::string m_previous_capability;
std::vector<AllowedRoot> m_previous_scoped_roots; std::vector<AllowedRoot> m_previous_scoped_roots;
std::vector<PyObject*> m_previous_root_frames;
}; };
} // namespace Slic3r } // namespace Slic3r
+2
View File
@@ -808,6 +808,7 @@ bool read_install_state(const boost::filesystem::path& plugin_dir, PluginInstall
read_string_list("network_socket", parsed.permissions.network_socket); read_string_list("network_socket", parsed.permissions.network_socket);
read_string_list("process", parsed.permissions.process); read_string_list("process", parsed.permissions.process);
read_string_list("threading", parsed.permissions.threading); read_string_list("threading", parsed.permissions.threading);
read_string_list("events", parsed.permissions.events);
} }
if (state.contains("enabled") && state["enabled"].is_boolean()) if (state.contains("enabled") && state["enabled"].is_boolean())
@@ -852,6 +853,7 @@ bool write_install_state(const boost::filesystem::path& plugin_dir, const Plugin
{"network_socket", state.permissions.network_socket}, {"network_socket", state.permissions.network_socket},
{"process", state.permissions.process}, {"process", state.permissions.process},
{"threading", state.permissions.threading}, {"threading", state.permissions.threading},
{"events", state.permissions.events},
}; };
nlohmann::json capabilities = nlohmann::json::array(); nlohmann::json capabilities = nlohmann::json::array();
+2
View File
@@ -104,6 +104,8 @@ struct PluginPermissions
std::vector<std::string> network_socket; std::vector<std::string> network_socket;
std::vector<std::string> process; std::vector<std::string> process;
std::vector<std::string> threading; 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 { struct PluginInstallState {
+1 -1
View File
@@ -102,9 +102,9 @@ public:
// mistake), both fall back to the base's empty object rather than writing `"cap_config": null`. // mistake), both fall back to the base's empty object rather than writing `"cap_config": null`.
nlohmann::json get_default_config() const override nlohmann::json get_default_config() const override
{ {
pybind11::gil_scoped_acquire gil;
ORCA_PY_AUDIT_SCOPE(); ORCA_PY_AUDIT_SCOPE();
try { try {
pybind11::gil_scoped_acquire gil;
pybind11::function override = pybind11::get_override(static_cast<const Base*>(this), "get_default_config"); pybind11::function override = pybind11::get_override(static_cast<const Base*>(this), "get_default_config");
if (!override) if (!override)
return Base::get_default_config(); return Base::get_default_config();
@@ -282,11 +282,11 @@ public:
int request_bind_ticket(std::string* ticket) override int request_bind_ticket(std::string* ticket) override
{ {
try { try {
ORCA_PY_AUDIT_SCOPE();
::Slic3r::PluginCapabilityInterface::RefCounter _orca_ref_counter(*this); ::Slic3r::PluginCapabilityInterface::RefCounter _orca_ref_counter(*this);
::Slic3r::PythonGILState gil; ::Slic3r::PythonGILState gil;
if (!gil) if (!gil)
throw std::runtime_error("Python interpreter is shutting down"); throw std::runtime_error("Python interpreter is shutting down");
ORCA_PY_AUDIT_SCOPE();
pybind11::function override = pybind11::function override =
pybind11::get_override(static_cast<const PrinterAgentPluginCapability*>(this), "request_bind_ticket"); pybind11::get_override(static_cast<const PrinterAgentPluginCapability*>(this), "request_bind_ticket");
if (!override) if (!override)
+39 -5
View File
@@ -1,6 +1,7 @@
#include <catch2/catch_all.hpp> #include <catch2/catch_all.hpp>
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <catch2/catch_message.hpp>
#include "test_helpers.hpp" #include "test_helpers.hpp"
#include <algorithm> #include <algorithm>
@@ -17,15 +18,48 @@
using namespace Slic3r; using namespace Slic3r;
using namespace Slic3r::Test; using namespace Slic3r::Test;
// The fan is held off for the first close_fan_the_first_x_layers layers, so an explicit // The fan stays off while printing the first close_fan_the_first_x_layers layers and runs afterwards.
// fan-off command is emitted.
TEST_CASE("Fan is held off for the initial layers", "[Cooling]") TEST_CASE("Fan is held off for the initial layers", "[Cooling]")
{ {
const int fan_off_layers = 5;
const std::string gcode = slice({ cube(20) }, { const std::string gcode = slice({ cube(20) }, {
{ "cooling", true }, { "close_fan_the_first_x_layers", fan_off_layers },
{ "close_fan_the_first_x_layers", 5 }, { "full_fan_speed_layer", 0 },
{ "fan_min_speed", 50 },
{ "fan_max_speed", 100 },
{ "fan_cooling_layer_time", 1000 }, // every layer is short enough to run the fan
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
}); });
CHECK(gcode.find("M106 S0") != std::string::npos);
// Record, for each layer, whether any extrusion was printed with the part fan running.
std::vector<bool> extruded_with_fan;
int layer = -1;
bool fan_on = false;
std::istringstream input(gcode);
std::string line;
while (std::getline(input, line)) {
if (line.rfind(";LAYER_CHANGE", 0) == 0) {
++layer;
extruded_with_fan.push_back(false);
} else if (line.rfind("M107", 0) == 0) {
fan_on = false;
} else if (line.rfind("M106 ", 0) == 0 && line.find(" P") == std::string::npos) {
const size_t s = line.find(" S");
fan_on = s != std::string::npos && std::stod(line.substr(s + 2)) > 0.;
} else if (layer >= 0 && fan_on && line.rfind("G1 ", 0) == 0 && line.find(" E") != std::string::npos &&
(line.find(" X") != std::string::npos || line.find(" Y") != std::string::npos)) {
extruded_with_fan[layer] = true;
}
}
// A 20 mm cube at 0.2 mm layers has 100 layers.
REQUIRE(extruded_with_fan.size() == 100);
for (int i = 0; i < fan_off_layers; ++i) {
CAPTURE(i);
CHECK_FALSE(extruded_with_fan[i]);
}
CHECK(extruded_with_fan[fan_off_layers]);
} }
// The cooling pass resolves and strips its internal speed placeholders; none leak into // The cooling pass resolves and strips its internal speed placeholders; none leak into
+9 -10
View File
@@ -86,10 +86,9 @@ TEST_CASE("Pattern path length", "[Fill]") {
Slic3r::Polylines paths = test(Slic3r::ExPolygon(test_set)); Slic3r::Polylines paths = test(Slic3r::ExPolygon(test_set));
REQUIRE(paths.size() == 1); // one continuous path REQUIRE(paths.size() == 1); // one continuous path
// TODO: determine what the "Expected length" should be for rectilinear fill of a 100x100 polygon. // Lines are 50 mm apart and the square is inset by half a spacing, so the lines on its edges drop out
// This check only checks that it's above scale(3*100 + 2*50) + scaled_epsilon. // and a single line crosses the middle, ending within half a spacing of the edges.
// ok abs($paths->[0]->length - scale(3*100 + 2*50)) - scaled_epsilon, 'path has expected length'; CHECK_THAT(unscale<double>(paths[0].length()), Catch::Matchers::WithinAbs(100., filler->spacing / 2.));
REQUIRE(std::abs(paths[0].length() - static_cast<double>(scale_(3*100 + 2*50))) - SCALED_EPSILON > 0); // path has expected length
test_set.clear(); test_set.clear();
} }
@@ -148,10 +147,9 @@ TEST_CASE("Pattern path length", "[Fill]") {
Slic3r::Polylines paths = test(Slic3r::ExPolygon(points)); Slic3r::Polylines paths = test(Slic3r::ExPolygon(points));
REQUIRE(paths.size() == 1); // one continuous path REQUIRE(paths.size() == 1); // one continuous path
// TODO: determine what the "Expected length" should be for rectilinear fill of a 100x100 polygon. // A solid fill covers the area with lines one spacing apart, so a missing segment shortens the path.
// This check only checks that it's above scale(3*100 + 2*50) + scaled_epsilon. const double expected_length = unscale<double>(unscale<double>(Slic3r::ExPolygon(points).area())) / filler->spacing;
// ok abs($paths->[0]->length - scale(3*100 + 2*50)) - scaled_epsilon, 'path has expected length'; CHECK_THAT(unscale<double>(paths[0].length()), Catch::Matchers::WithinRel(expected_length, 0.01));
REQUIRE(std::abs(paths[0].length() - static_cast<double>(scale_(3*100 + 2*50))) - SCALED_EPSILON > 0); // path has expected length
} }
SECTION("Rotated Square") { SECTION("Rotated Square") {
@@ -168,8 +166,9 @@ TEST_CASE("Pattern path length", "[Fill]") {
fill_params.density = 1.0; fill_params.density = 1.0;
filler->spacing = flow.spacing(); filler->spacing = flow.spacing();
for (auto angle : { 0.0, 45.0}) { for (auto angle : { 0.0, PI / 4.0 }) {
surface.expolygon.rotate(angle, Point(0,0)); surface.expolygon.rotate(angle, Point(0,0));
filler->bounding_box = get_extents(surface.expolygon.contour);
Polylines paths = filler->fill_surface(&surface, fill_params); Polylines paths = filler->fill_surface(&surface, fill_params);
REQUIRE(paths.size() == 1); REQUIRE(paths.size() == 1);
} }
@@ -222,7 +221,7 @@ TEST_CASE("Pattern path length", "[Fill]") {
}; };
Slic3r::ExPolygon expolygon(points); Slic3r::ExPolygon expolygon(points);
REQUIRE(test_if_solid_surface_filled(expolygon, 0.5, 45.0, 0.99) == true); REQUIRE(test_if_solid_surface_filled(expolygon, 0.5, PI / 4.0, 0.99) == true);
} }
} }
+42 -18
View File
@@ -123,38 +123,58 @@ SCENARIO("Changing the number of solid shell layers does not make all surfaces i
config.set_deserialize_strict({ config.set_deserialize_strict({
{ "top_shell_layers", 2 }, { "top_shell_layers", 2 },
{ "bottom_shell_layers", 1 }, { "bottom_shell_layers", 1 },
{ "layer_height", 0.25 }, // get a known number of layers // Shell counts alone decide which layers are solid.
{ "top_shell_thickness", 0 },
{ "bottom_shell_thickness", 0 },
{ "sparse_infill_density", "20%" },
{ "layer_height", 0.25 }, // 80 layers: 0..79
{ "initial_layer_print_height", 0.25 } { "initial_layer_print_height", 0.25 }
}); });
Slic3r::Print print; Slic3r::Print print;
Slic3r::Model model; Slic3r::Model model;
Slic3r::Test::init_print({cube(20)}, print, model, config); Slic3r::Test::init_print({cube(20)}, print, model, config);
// Precondition: Ensure that the model has 2 solid top layers (79, 78) // True when the layer has fill surfaces and all of them are solid.
// and one solid bottom layer (0). auto is_solid_infill = [&print](size_t obj_id, size_t layer_id) {
auto test_is_solid_infill = [&print](size_t obj_id, size_t layer_id) {
const Layer &layer = *(print.objects().at(obj_id)->get_layer((int)layer_id)); const Layer &layer = *(print.objects().at(obj_id)->get_layer((int)layer_id));
// iterate over all of the regions in the layer bool has_fill = false;
for (const LayerRegion *region : layer.regions()) { for (const LayerRegion *region : layer.regions())
// for each region, iterate over the fill surfaces for (const Surface &surface : region->fill_surfaces.surfaces) {
for (const Surface &surface : region->fill_surfaces.surfaces) if (! surface.is_solid())
CHECK(surface.is_solid()); return false;
has_fill = true;
} }
return has_fill;
};
// True when the layer has a sparse fill surface, so a layer with no fill at all does not count.
auto has_sparse_infill = [&print](size_t obj_id, size_t layer_id) {
const Layer &layer = *(print.objects().at(obj_id)->get_layer(static_cast<int>(layer_id)));
for (const LayerRegion *region : layer.regions())
for (const Surface &surface : region->fill_surfaces.surfaces)
if (! surface.is_solid())
return true;
return false;
}; };
print.process(); print.process();
test_is_solid_infill(0, 0); // should be solid // Precondition: 2 solid top layers (79, 78) and one solid bottom layer (0).
test_is_solid_infill(0, 79); // should be solid REQUIRE(is_solid_infill(0, 0));
test_is_solid_infill(0, 78); // should be solid REQUIRE(is_solid_infill(0, 79));
REQUIRE(is_solid_infill(0, 78));
REQUIRE(has_sparse_infill(0, 77));
WHEN("Model is re-sliced with top_shell_layers == 3") { WHEN("Model is re-sliced with top_shell_layers == 3") {
config.set("top_shell_layers", 3); config.set("top_shell_layers", 3);
print.apply(model, config); print.apply(model, config);
print.process(); print.process();
THEN("Print object does not have 0 solid bottom layers.") { THEN("Print object does not have 0 solid bottom layers.") {
test_is_solid_infill(0, 0); CHECK(is_solid_infill(0, 0));
} }
AND_THEN("Print object has 3 top solid layers") { AND_THEN("Print object has 3 top solid layers") {
test_is_solid_infill(0, 79); CHECK(is_solid_infill(0, 79));
test_is_solid_infill(0, 78); CHECK(is_solid_infill(0, 78));
test_is_solid_infill(0, 77); CHECK(is_solid_infill(0, 77));
CHECK(has_sparse_infill(0, 76));
}
AND_THEN("Middle layers keep sparse infill") {
CHECK(has_sparse_infill(0, 40));
} }
} }
} }
@@ -672,14 +692,18 @@ TEST_CASE("gcode_skip_config_block omits the resolved-settings comment block", "
{ {
const std::string gcode = slice({ cube(20) }, { const std::string gcode = slice({ cube(20) }, {
{ "gcode_skip_config_block", true }, { "gcode_skip_config_block", true },
{ "gcode_comments", true },
}); });
CHECK(gcode.find("; CONFIG_BLOCK_START") == std::string::npos); CHECK(gcode.find("; CONFIG_BLOCK_START") == std::string::npos);
CHECK(gcode.find("; CONFIG_BLOCK_END") == std::string::npos); CHECK(gcode.find("; CONFIG_BLOCK_END") == std::string::npos);
CHECK(gcode.find("; layer_height =") == std::string::npos); CHECK(gcode.find("; layer_height =") == std::string::npos);
CHECK(gcode.find("; fill_density =") == std::string::npos); CHECK(gcode.find("; sparse_infill_density =") == std::string::npos);
CHECK(gcode.find("; HEADER_BLOCK_START") != std::string::npos); CHECK(gcode.find("; HEADER_BLOCK_START") != std::string::npos);
CHECK(gcode.find("; EXECUTABLE_BLOCK_START") != std::string::npos); CHECK(gcode.find("; EXECUTABLE_BLOCK_START") != std::string::npos);
const std::string with_block = slice({ cube(20) }, { { "gcode_skip_config_block", false } });
CHECK(with_block.find("; CONFIG_BLOCK_START") != std::string::npos);
CHECK(with_block.find("; layer_height =") != std::string::npos);
CHECK(with_block.find("; sparse_infill_density =") != std::string::npos);
} }
// Some firmwares only scan the last N lines of the file for "estimated printing time", so it // Some firmwares only scan the last N lines of the file for "estimated printing time", so it
+20 -10
View File
@@ -1,6 +1,8 @@
#include <catch2/catch_all.hpp> #include <catch2/catch_all.hpp>
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include "libslic3r/TriangleMesh.hpp" #include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/TriangleMeshSlicer.hpp" #include "libslic3r/TriangleMeshSlicer.hpp"
#include "libslic3r/Point.hpp" #include "libslic3r/Point.hpp"
@@ -162,21 +164,29 @@ SCENARIO("Slice behavior", "[TriangleMesh]") {
const std::vector<Vec3f> vertices {{0,0,0},{0,0,20},{0,5,0},{0,5,20},{50,0,0},{50,0,20},{15,5,0},{35,5,0},{15,20,0},{50,5,0},{35,20,0},{15,5,10},{50,5,20},{35,5,10},{35,20,10},{15,20,10}}; const std::vector<Vec3f> vertices {{0,0,0},{0,0,20},{0,5,0},{0,5,20},{50,0,0},{50,0,20},{15,5,0},{35,5,0},{15,20,0},{50,5,0},{35,20,0},{15,5,10},{50,5,20},{35,5,10},{35,20,10},{15,20,10}};
const std::vector<Vec3i32> facets {{0,1,2},{2,1,3},{1,0,4},{5,1,4},{0,2,4},{4,2,6},{7,6,8},{4,6,7},{9,4,7},{7,8,10},{2,3,6},{11,3,12},{7,12,9},{13,12,7},{6,3,11},{11,12,13},{3,1,5},{12,3,5},{5,4,9},{12,5,9},{13,7,10},{14,13,10},{8,15,10},{10,15,14},{6,11,8},{8,11,15},{15,11,13},{14,15,13}}; const std::vector<Vec3i32> facets {{0,1,2},{2,1,3},{1,0,4},{5,1,4},{0,2,4},{4,2,6},{7,6,8},{4,6,7},{9,4,7},{7,8,10},{2,3,6},{11,3,12},{7,12,9},{13,12,7},{6,3,11},{11,12,13},{3,1,5},{12,3,5},{5,4,9},{12,5,9},{13,7,10},{14,13,10},{8,15,10},{10,15,14},{6,11,8},{8,11,15},{15,11,13},{14,15,13}};
auto cube = make_cube(); TriangleMesh mesh(vertices, facets);
// A 50x5x20 wall with a 20x15x10 block against it, so the block's top is at Z = 10.
const double wall_area = 50. * 5.;
const double wall_block_area = wall_area + 20. * 15.;
const auto area_mm2 = [](const ExPolygons &slice) { return slice.at(0).area() * SCALING_FACTOR * SCALING_FACTOR; };
WHEN(" a top tangent plane is sliced") { WHEN(" a top tangent plane is sliced") {
// At Z = 10 we have a top horizontal surface. std::vector<ExPolygons> slices = mesh.slice({5.0, 10.0});
std::vector<ExPolygons> slices = cube.slice({5.0, 10.0});
THEN( "its area is included") { THEN( "its area is included") {
REQUIRE(slices.at(0).at(0).area() > 0); REQUIRE(slices.at(0).size() == 1);
REQUIRE(slices.at(1).at(0).area() > 0); REQUIRE(slices.at(1).size() == 1);
CHECK_THAT(area_mm2(slices.at(0)), Catch::Matchers::WithinRel(wall_block_area, 1e-6));
CHECK_THAT(area_mm2(slices.at(1)), Catch::Matchers::WithinRel(wall_block_area, 1e-6));
} }
} }
WHEN(" a model that has been transformed is sliced") { WHEN(" a model that has been transformed is sliced") {
cube.mirror_z(); mesh.mirror_z();
std::vector<ExPolygons> slices = cube.slice({-5.0, -10.0}); std::vector<ExPolygons> slices = mesh.slice({-5.0, -10.0});
THEN( "it is sliced properly (mirrored bottom plane area is included)") { THEN( "it is sliced like the original, and the mirrored block's bottom plane is not part of it") {
REQUIRE(slices.at(0).at(0).area() > 0); REQUIRE(slices.at(0).size() == 1);
REQUIRE(slices.at(1).at(0).area() > 0); REQUIRE(slices.at(1).size() == 1);
CHECK_THAT(area_mm2(slices.at(0)), Catch::Matchers::WithinRel(wall_block_area, 1e-6));
// A triangle owns its top edge but not its bottom edge, so a bottom tangent plane is left out.
CHECK_THAT(area_mm2(slices.at(1)), Catch::Matchers::WithinRel(wall_area, 1e-6));
} }
} }
} }
@@ -319,9 +319,18 @@ TEST_CASE("FilamentGroup property checks", "[filament_group][property]") {
// build (config_b/config_c cases evaluate the full per-layer nozzle-aware reorder for every // build (config_b/config_c cases evaluate the full per-layer nozzle-aware reorder for every
// candidate grouping; this is a guard against hangs, not a micro-perf gate). // candidate grouping; this is a guard against hangs, not a micro-perf gate).
REQUIRE(result.elapsed_ms < 40000.0); REQUIRE(result.elapsed_ms < 40000.0);
REQUIRE(result.flush_cost >= 0);
// Every case, feasible or not, must yield a usable grouping: each used filament is
// assigned to an existing nozzle.
auto used_filaments = collect_sorted_used_filaments(tc.context.model_info.layer_filaments); auto used_filaments = collect_sorted_used_filaments(tc.context.model_info.layer_filaments);
const auto &nozzle_list = tc.context.nozzle_info.nozzle_list;
for (unsigned int fil : used_filaments) {
INFO("Filament: " << fil);
REQUIRE(fil < result.filament_map.size());
CHECK(result.filament_map[fil] >= 0);
CHECK(result.filament_map[fil] < static_cast<int>(nozzle_list.size()));
}
if (is_constraint_feasible(tc.context, used_filaments)) { if (is_constraint_feasible(tc.context, used_filaments)) {
if (!result.constraints_ok) { if (!result.constraints_ok) {
for (auto& v : result.violations) for (auto& v : result.violations)
+36 -13
View File
@@ -1055,29 +1055,52 @@ SCENARIO("Non-standard nozzle diameter survives .3mf save on a single-nozzle pri
} }
} }
// A legacy / foreign project (no multi-nozzle metadata) must load crash-safe through the BBS // A project without multi-nozzle metadata must load through the BBS importer and must not
// importer and must not fabricate a filament_volume_map. // fabricate a filament_volume_map.
SCENARIO("Legacy project loads crash-safe via load_bbs_3mf", "[3mf][MultiNozzle]") { SCENARIO("Projects without multi-nozzle metadata load without a per-filament volume map", "[3mf][MultiNozzle]") {
GIVEN("a project without any multi-nozzle metadata") {
std::string path = std::string(TEST_DATA_DIR) + "/test_3mf/Geräte/Büchse.3mf";
Model model;
DynamicPrintConfig config;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable }; ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
PlateDataPtrs plates;
std::vector<Preset*> project_presets; std::vector<Preset*> project_presets;
bool is_bbl_3mf = false, is_orca_3mf = false; bool is_bbl_3mf = false, is_orca_3mf = false;
Semver file_version; Semver file_version;
GIVEN("a legacy project with a bare model and no plate metadata") {
std::string path = std::string(TEST_DATA_DIR) + "/test_3mf/Geräte/Büchse.3mf";
Model model;
DynamicPrintConfig config;
PlateDataPtrs plates;
WHEN("loaded through the BBS importer") { WHEN("loaded through the BBS importer") {
bool loaded = false; bool loaded = false;
REQUIRE_NOTHROW(loaded = load_bbs_3mf(path.c_str(), &config, &ctxt, &model, &plates, REQUIRE_NOTHROW(loaded = load_bbs_3mf(path.c_str(), &config, &ctxt, &model, &plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &project_presets, &is_bbl_3mf, &is_orca_3mf,
&file_version, nullptr, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig)); LoadStrategy::LoadModel | LoadStrategy::LoadConfig));
THEN("it does not crash and invents no per-filament volume map") { THEN("the model loads") {
for (PlateData* p : plates) { REQUIRE(loaded);
REQUIRE(p->config.option<ConfigOptionInts>("filament_volume_map") == nullptr); REQUIRE_FALSE(model.objects.empty());
} }
release_PlateData_list(plates);
}
}
GIVEN("a project with plate metadata but no filament volume map") {
ScopedTemporaryFile temp(".3mf");
store_painted_cube(temp.string());
ScopedTemporaryDir backup_dir("orca_volume_map_dst");
Model model;
model.set_backup_path(backup_dir.string());
DynamicPrintConfig config;
PlateDataPtrs plates;
WHEN("loaded through the BBS importer") {
const bool loaded = load_bbs_3mf(temp.string().c_str(), &config, &ctxt, &model, &plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version,
nullptr, LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
THEN("every plate loads without a per-filament volume map") {
REQUIRE(loaded);
REQUIRE_FALSE(plates.empty());
for (PlateData* p : plates)
CHECK(p->config.option<ConfigOptionInts>("filament_volume_map") == nullptr);
} }
release_PlateData_list(plates); release_PlateData_list(plates);
} }
@@ -2048,8 +2071,8 @@ SCENARIO("Published 3MF round-trips the extended material metadata", "[3mf]") {
REQUIRE(dst_model.model_info != nullptr); REQUIRE(dst_model.model_info != nullptr);
REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] == material_keys_json); REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] == material_keys_json);
// The value must parse back with every extended field intact. // The loaded value must parse back with every extended field intact.
nlohmann::json entries = nlohmann::json::parse(material_keys_json); nlohmann::json entries = nlohmann::json::parse(dst_model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG]);
REQUIRE(entries.is_array()); REQUIRE(entries.is_array());
REQUIRE(entries.size() == 1); REQUIRE(entries.size() == 1);
REQUIRE(entries[0]["full"].get<bool>() == true); REQUIRE(entries[0]["full"].get<bool>() == true);
+115 -90
View File
@@ -44,6 +44,7 @@
#include <cmath> #include <cmath>
#include <cstring> #include <cstring>
#include <boost/filesystem/path.hpp>
#include <fstream> #include <fstream>
#include <memory> #include <memory>
#include <set> #include <set>
@@ -53,6 +54,7 @@
#include <BRepAdaptor_Surface.hxx> #include <BRepAdaptor_Surface.hxx>
#include <TopExp_Explorer.hxx> #include <TopExp_Explorer.hxx>
#include <TopoDS.hxx> #include <TopoDS.hxx>
#include <BRepAlgoAPI_Common.hxx>
#include <BRepGProp.hxx> #include <BRepGProp.hxx>
#include <GProp_GProps.hxx> #include <GProp_GProps.hxx>
#include <BRepPrimAPI_MakeBox.hxx> #include <BRepPrimAPI_MakeBox.hxx>
@@ -772,16 +774,10 @@ TEST_CASE("entity constraints: tangent/midpoint/symmetric/angle", "[CadDocument]
TEST_CASE("imported text glyphs all extrude without failing (charset sweep)", "[CadDocument]") TEST_CASE("imported text glyphs all extrude without failing (charset sweep)", "[CadDocument]")
{ {
std::string font = resources_dir().empty() // The bundled font, from the source tree: PROFILES_DIR is <repo>/resources/profiles.
? std::string("resources/fonts/HarmonyOS_Sans_SC_Regular.ttf") const std::string font =
: resources_dir() + "/fonts/HarmonyOS_Sans_SC_Regular.ttf"; (boost::filesystem::path(PROFILES_DIR).parent_path() / "fonts" / "HarmonyOS_Sans_SC_Regular.ttf").string();
{ REQUIRE(std::ifstream(font).good());
std::ifstream probe(font);
if (!probe.good()) {
SUCCEED("bundled font not reachable in this environment; covered live on :10");
return;
}
}
const std::string charset = const std::string charset =
"ABCDEFGHIJKLMNOPQRSTUVWXYZ" "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
@@ -2853,9 +2849,10 @@ TEST_CASE("datum coordinate system: non-perpendicular inputs produce orthonormal
using Catch::Matchers::WithinAbs; using Catch::Matchers::WithinAbs;
CadDocument doc; CadDocument doc;
// Build a body so we have a face to reference for FaceAndDirection. // A cylinder's end cap is bounded by one closed circular edge, which offers no in-plane
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box"); // direction, so the frame has to fall back on coordsys_x_hint.
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "BoxExt"); int sk = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 5.0, "Circle");
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Cyl");
REQUIRE(doc.recompute()); REQUIRE(doc.recompute());
REQUIRE(doc.bodies.size() == 1); REQUIRE(doc.bodies.size() == 1);
int n_faces = GeometryEngine::face_count(doc.bodies[0].shape); int n_faces = GeometryEngine::face_count(doc.bodies[0].shape);
@@ -2865,34 +2862,33 @@ TEST_CASE("datum coordinate system: non-perpendicular inputs produce orthonormal
if (fn.z() > 0.9) { top_face = i; break; } if (fn.z() > 0.9) { top_face = i; break; }
} }
REQUIRE(top_face >= 0); REQUIRE(top_face >= 0);
const Vec3d Z = GeometryEngine::face_normal_world(GeometryEngine::face_by_index(doc.bodies[0].shape, top_face));
int cs = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 0), "CS1"); int cs = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 0), "CS1");
REQUIRE(cs >= 0); REQUIRE(cs >= 0);
doc.features[cs].coordsys_type = CoordSysType::FaceAndDirection; doc.features[cs].coordsys_type = CoordSysType::FaceAndDirection;
doc.features[cs].coordsys_body = 0; doc.features[cs].coordsys_body = 0;
doc.features[cs].coordsys_face = top_face; doc.features[cs].coordsys_face = top_face;
// Deliberately non-perpendicular X hint (NOT orthogonal to face normal ~+Z). // Deliberately non-perpendicular X hint (NOT orthogonal to the face normal +Z).
doc.features[cs].coordsys_x_hint = Vec3d(3.0, -1.0, 0.5); doc.features[cs].coordsys_x_hint = Vec3d(3.0, -1.0, 0.5);
auto css = doc.resolve_datum_coordsys(); auto css = doc.resolve_datum_coordsys();
REQUIRE(css.size() == 1); REQUIRE(css.size() == 1);
REQUIRE(css[0].error.empty()); REQUIRE(css[0].error.empty());
Vec3d X = css[0].x, Y = css[0].y; const Vec3d X = css[0].x, Y = css[0].y;
// Orthonormality: each axis has unit length // X is the hint projected into the face plane.
const Vec3d expected_x = Vec3d(3.0, -1.0, 0.0).normalized();
CHECK_THAT(X.x(), WithinAbs(expected_x.x(), 1e-9));
CHECK_THAT(X.y(), WithinAbs(expected_x.y(), 1e-9));
CHECK_THAT(X.z(), WithinAbs(expected_x.z(), 1e-9));
// Orthonormal, with Z the face normal and the frame right-handed.
CHECK_THAT(X.norm(), WithinAbs(1.0, 1e-9)); CHECK_THAT(X.norm(), WithinAbs(1.0, 1e-9));
CHECK_THAT(Y.norm(), WithinAbs(1.0, 1e-9)); CHECK_THAT(Y.norm(), WithinAbs(1.0, 1e-9));
// Pairwise dot products are ~0 CHECK_THAT(X.dot(Y), WithinAbs(0.0, 1e-9));
CHECK_THAT(std::abs(X.dot(Y)), WithinAbs(0.0, 1e-9)); CHECK_THAT(X.dot(Z), WithinAbs(0.0, 1e-9));
// Z = X x Y (derived), also unit and perpendicular CHECK_THAT(Y.dot(Z), WithinAbs(0.0, 1e-9));
Vec3d Z = X.cross(Y); CHECK_THAT(X.cross(Y).dot(Z), WithinAbs(1.0, 1e-9));
CHECK_THAT(Z.norm(), WithinAbs(1.0, 1e-9));
CHECK_THAT(std::abs(X.dot(Z)), WithinAbs(0.0, 1e-9));
CHECK_THAT(std::abs(Y.dot(Z)), WithinAbs(0.0, 1e-9));
// Right-handedness: X x Y == Z
CHECK_THAT(Z.x(), WithinAbs((X.cross(Y)).x(), 1e-9));
CHECK_THAT(Z.y(), WithinAbs((X.cross(Y)).y(), 1e-9));
CHECK_THAT(Z.z(), WithinAbs((X.cross(Y)).z(), 1e-9));
} }
TEST_CASE("datum coordinate system: a face-only frame rotates with its body", "[CadDocument]") TEST_CASE("datum coordinate system: a face-only frame rotates with its body", "[CadDocument]")
@@ -3917,17 +3913,23 @@ TEST_CASE("Use/Convert Entities: construction references never become solid", "[
} }
REQUIRE(top_face >= 0); REQUIRE(top_face >= 0);
const size_t body_count_before = doc.bodies.size();
const double volume_before = doc.body_mass_properties(0).volume;
int target = doc.add_sketch_entities({}, SketchPlane::XY(), "Use"); int target = doc.add_sketch_entities({}, SketchPlane::XY(), "Use");
int appended = doc.project_edges_into_sketch(target, 0, {}, top_face); int appended = doc.project_edges_into_sketch(target, 0, {}, top_face);
REQUIRE(appended == 4); REQUIRE(appended == 4);
doc.add_extrude(target, 5.0, false, BooleanMode::New, "UseExt");
SECTION("construction edges form no profile, so the extrude fails") {
REQUIRE_FALSE(doc.recompute());
REQUIRE_FALSE(doc.error.empty());
}
SECTION("the same edges as solid geometry extrude a 20x20x5 body") {
for (auto& e : doc.features[target].entities)
e.construction = false;
REQUIRE(doc.recompute()); REQUIRE(doc.recompute());
REQUIRE(doc.error.empty()); REQUIRE(doc.error.empty());
REQUIRE(doc.bodies.size() == body_count_before); REQUIRE(doc.bodies.size() == 2);
REQUIRE_THAT(doc.body_mass_properties(0).volume, WithinRel(volume_before, 1e-9)); REQUIRE_THAT(doc.body_mass_properties(1).volume, WithinRel(20.0 * 20.0 * 5.0, 1e-6));
}
} }
TEST_CASE("Use/Convert Entities: bad references return -1 and change nothing", "[CadDocument][project]") TEST_CASE("Use/Convert Entities: bad references return -1 and change nothing", "[CadDocument][project]")
@@ -4252,7 +4254,7 @@ TEST_CASE("a previous-format recipe is refused, not silently misread", "[CadDocu
REQUIRE(doc.features.empty()); // nothing half-read was left behind REQUIRE(doc.features.empty()); // nothing half-read was left behind
} }
TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]") TEST_CASE("a v4 recipe keeps every field value", "[CadDocument]")
{ {
using Catch::Matchers::WithinRel; using Catch::Matchers::WithinRel;
using Catch::Matchers::WithinAbs; using Catch::Matchers::WithinAbs;
@@ -4282,20 +4284,16 @@ TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]")
} }
CadDocument expected = make_golden_doc_v1(); CadDocument expected = make_golden_doc_v1();
// Scoped tightly: this advice is ONLY valid for a count mismatch. It must not be in
// scope for the field-value assertions below, where "regenerate the fixture" is the
// one thing you must never do -- regenerating after a reorder bakes the corrupted
// layout in as the new golden and permanently disarms this test.
{ {
INFO("Feature count changed - did you add/remove features in make_golden_doc_v1()?"); INFO("Feature count changed - make_golden_doc_v1() must keep building exactly the tree "
INFO("If so: run libslic3r_tests \"[.regen]\" and re-run this test."); "frozen in cad_recipe_v4.bin, so do not add or remove features there.");
REQUIRE(features.size() == expected.features.size()); REQUIRE(features.size() == expected.features.size());
} }
// A failure BELOW this point means the on-disk serialization format changed: some field // A failure BELOW this point means the on-disk serialization format changed: some field
// in CadFeature::save/load was reordered, retyped, or removed. Fields may only ever be // in CadFeature::save/load was reordered, retyped, or removed. Fields may only ever be
// APPENDED at the end of both lists. Do NOT regenerate the fixture to make this pass -- // APPENDED at the end of both lists. Do NOT regenerate the fixture to make this pass --
// fix the field order instead. See scripts/CAD/run-kernel-tests.sh and the [.regen] case. // fix the field order instead.
// Field-by-field assertions against expected values. // Field-by-field assertions against expected values.
// Every field set to a distinctive non-default literal must be checked here. // Every field set to a distinctive non-default literal must be checked here.
@@ -4550,34 +4548,48 @@ TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]")
} }
} }
// --- Layer 2: geometry check (optional — only if the document recomputes) --- // --- Layer 2: the production v4 load rebuilds the same geometry as the in-memory tree ---
// Build expected document and try to recompute it. // The golden tree is serialization coverage and does not recompute as a whole: these
// ponytail: the field-value layer above is the real tripwire; // features fail on the geometry they get, so they are left off in both trees.
// this layer is a bonus sanity check on the full recompute path. const std::set<std::string> unbuildable{"Fillet_Lat35", "Chamfer_Top2", "Draft_F3", "Shell_T1375",
"GoldenProject"};
CadDocument exp_doc = make_golden_doc_v1(); CadDocument exp_doc = make_golden_doc_v1();
bool exp_ok = exp_doc.recompute(); // Fields appended after v4 are not in the file, so a v4 project reads them as defaults
if (!exp_ok) { // (a v4 thread, for one, is not major-nominal). The reference has to match that.
INFO("Expected document from make_golden_doc_v1() could not recompute " const CadFeature def;
"(complex feature tree). Field-value checks above are sufficient."); for (CadFeature& f : exp_doc.features) {
f.thread_major_nominal = def.thread_major_nominal;
f.pattern_inclusive = def.pattern_inclusive;
f.dressup_edges = def.dressup_edges;
f.text_string = def.text_string;
f.text_font = def.text_font;
f.text_height = def.text_height;
f.revolve_axis_entity = def.revolve_axis_entity;
} }
CadDocument doc; CadDocument doc;
bool ser_ok = doc.deserialize_recipe(blob); doc.deserialize_recipe(blob);
REQUIRE(doc.features.size() == exp_doc.features.size());
if (exp_ok && ser_ok) { for (size_t i = 0; i < exp_doc.features.size(); ++i)
REQUIRE(doc.error.empty()); exp_doc.features[i].enabled = doc.features[i].enabled = unbuildable.count(exp_doc.features[i].name) == 0;
const bool exp_built = exp_doc.recompute();
INFO("reference error: " << exp_doc.error);
REQUIRE(exp_built);
const bool built = doc.recompute();
INFO("loaded error: " << doc.error);
REQUIRE(built);
REQUIRE_FALSE(exp_doc.bodies.empty());
REQUIRE(doc.bodies.size() == exp_doc.bodies.size()); REQUIRE(doc.bodies.size() == exp_doc.bodies.size());
for (size_t i = 0; i < doc.bodies.size(); ++i) { for (size_t b = 0; b < doc.bodies.size(); ++b) {
double v = double(SketchEngine::tessellate(doc.bodies[i].shape).volume()); INFO("body " << b);
double ev = double(SketchEngine::tessellate(exp_doc.bodies[i].shape).volume()); TriangleMesh mesh = SketchEngine::tessellate(doc.bodies[b].shape);
REQUIRE_THAT(v, WithinRel(ev, 1e-6)); TriangleMesh exp_mesh = SketchEngine::tessellate(exp_doc.bodies[b].shape);
CHECK_THAT(double(mesh.volume()), WithinRel(double(exp_mesh.volume()), 1e-6));
// Volume alone is blind to fields that only place a body, such as a transform.
const BoundingBoxf3 bb = mesh.bounding_box(), exp_bb = exp_mesh.bounding_box();
for (int k = 0; k < 3; ++k) {
CHECK_THAT(bb.min[k], WithinAbs(exp_bb.min[k], 1e-6));
CHECK_THAT(bb.max[k], WithinAbs(exp_bb.max[k], 1e-6));
} }
} else {
INFO("The golden recipe fixture was loaded and field-value checks passed.");
INFO("Recompute on the deserialized or expected document failed — this is");
INFO("expected for the extended golden fixture (many feature types coexist");
INFO("purely for serialization coverage). The field-value tripwire above is");
INFO("the primary format check.");
} }
} }
@@ -5781,8 +5793,9 @@ TEST_CASE("fastened mate with angle", "[CadDocument][mate]")
REQUIRE(doc.recompute()); REQUIRE(doc.recompute());
REQUIRE(doc.error.empty()); REQUIRE(doc.error.empty());
int sk2 = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl"); // A 20x10 plate, so a quarter turn about the connector's Z swaps its X and Y extents.
doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "CylExt"); int sk2 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "Plate");
doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "PlateExt");
REQUIRE(doc.recompute()); REQUIRE(doc.recompute());
REQUIRE(doc.error.empty()); REQUIRE(doc.error.empty());
@@ -5801,6 +5814,13 @@ TEST_CASE("fastened mate with angle", "[CadDocument][mate]")
gp_Pnt com = props.CentreOfMass(); gp_Pnt com = props.CentreOfMass();
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4)); REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4)); REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
Bnd_Box bb;
BRepBndLib::Add(doc.bodies[1].shape, bb);
double x0, y0, z0, x1, y1, z1;
bb.Get(x0, y0, z0, x1, y1, z1);
REQUIRE_THAT(x1 - x0, WithinAbs(10.0, 1e-3));
REQUIRE_THAT(y1 - y0, WithinAbs(20.0, 1e-3));
} }
TEST_CASE("fastened mate with flip", "[CadDocument][mate]") TEST_CASE("fastened mate with flip", "[CadDocument][mate]")
@@ -5823,7 +5843,9 @@ TEST_CASE("fastened mate with flip", "[CadDocument][mate]")
REQUIRE(doc.recompute()); REQUIRE(doc.recompute());
REQUIRE(doc.error.empty()); REQUIRE(doc.error.empty());
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Moving"); // The connector sits on the cylinder's base, so the flip decides which side of the
// fixed connector (z = 5) the 10 mm cylinder hangs: above it unflipped, below it flipped.
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 0), "CS_Moving");
doc.features[cs_moving].coordsys_body = 1; doc.features[cs_moving].coordsys_body = 1;
REQUIRE(doc.recompute()); REQUIRE(doc.recompute());
REQUIRE(doc.error.empty()); REQUIRE(doc.error.empty());
@@ -5837,8 +5859,7 @@ TEST_CASE("fastened mate with flip", "[CadDocument][mate]")
gp_Pnt com = props.CentreOfMass(); gp_Pnt com = props.CentreOfMass();
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4)); REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4)); REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
// Flip opposes Z axes: for a symmetric cylinder this is invisible in centroid, REQUIRE_THAT(double(com.Z()), WithinAbs(5.0 - 10.0 / 2, 1e-4));
// but the mate executed cleanly and the body moved to the target connector.
} }
TEST_CASE("planar mate: normal distance becomes mate_offset", "[CadDocument][mate]") TEST_CASE("planar mate: normal distance becomes mate_offset", "[CadDocument][mate]")
@@ -6525,23 +6546,19 @@ TEST_CASE("fastened mate with flip on asymmetric body", "[CadDocument][mate]")
REQUIRE(doc.recompute()); REQUIRE(doc.recompute());
REQUIRE(doc.error.empty()); REQUIRE(doc.error.empty());
// Fastened, NO flip // The same assembly mated once without and once with flip, each in its own copy.
doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, false, "MateNoFlip"); auto mated_com_z = [&](bool flip) {
REQUIRE(doc.recompute()); CadDocument d = doc;
REQUIRE(doc.error.empty()); d.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, flip, "Mate");
GProp_GProps nf_props; REQUIRE(d.recompute());
BRepGProp::VolumeProperties(doc.bodies[1].shape, nf_props); REQUIRE(d.error.empty());
double nf_z = nf_props.CentreOfMass().Z(); REQUIRE(d.mate_conflicts.empty());
GProp_GProps props;
doc.undo(); BRepGProp::VolumeProperties(d.bodies[1].shape, props);
return props.CentreOfMass().Z();
// Fastened, WITH flip };
doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, true, "MateFlip"); const double nf_z = mated_com_z(false);
REQUIRE(doc.recompute()); const double f_z = mated_com_z(true);
REQUIRE(doc.error.empty());
GProp_GProps f_props;
BRepGProp::VolumeProperties(doc.bodies[1].shape, f_props);
double f_z = f_props.CentreOfMass().Z();
// Flip changes the centroid Z for an asymmetric body // Flip changes the centroid Z for an asymmetric body
REQUIRE_THAT(f_z, !WithinAbs(nf_z, 1e-4)); REQUIRE_THAT(f_z, !WithinAbs(nf_z, 1e-4));
@@ -7324,13 +7341,22 @@ TEST_CASE("interference: sheet bodies are skipped", "[CadDocument][interference]
CadDocument doc; CadDocument doc;
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA"); int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA"); doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA");
// A sheet passing straight through the solid: it has no volume, so no interference. // Four 10x5 walls standing inside the 20x20x10 solid: a sheet has no volume, so no interference.
int sk_s = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 40, 40, 0, "Sheet"); int sk_s = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Sheet");
doc.add_surface_extrude(sk_s, 5.0, "SE"); doc.add_surface_extrude(sk_s, 5.0, "SE");
REQUIRE(doc.recompute()); REQUIRE(doc.recompute());
REQUIRE(doc.bodies.size() == 2); REQUIRE(doc.bodies.size() == 2);
REQUIRE(CadDocument::is_sheet_shape(doc.bodies[1].shape)); REQUIRE(CadDocument::is_sheet_shape(doc.bodies[1].shape));
// The raw overlap of the walls with the solid does measure a volume, so only the sheet
// skip keeps it out of the report.
BRepAlgoAPI_Common common(doc.bodies[0].shape, doc.bodies[1].shape);
common.Build();
REQUIRE(common.IsDone());
GProp_GProps props;
BRepGProp::VolumeProperties(common.Shape(), props);
REQUIRE(std::abs(props.Mass()) > 1e-6);
REQUIRE(doc.check_interference().empty()); REQUIRE(doc.check_interference().empty());
} }
@@ -7540,9 +7566,8 @@ TEST_CASE("A cut that removes no material is an error, not a silent success", "[
CHECK(doc.error.find("removed no material") != std::string::npos); CHECK(doc.error.find("removed no material") != std::string::npos);
// And the body is left as it was, not half-applied. // And the body is left as it was, not half-applied.
CadDocument again = box(); REQUIRE(doc.bodies.size() == 1);
REQUIRE(again.recompute()); CHECK_THAT(doc.body_mass_properties(0).volume,
CHECK_THAT(again.body_mass_properties(0).volume,
Catch::Matchers::WithinAbs(solid, 1e-6)); Catch::Matchers::WithinAbs(solid, 1e-6));
} }
+128 -10
View File
@@ -75,11 +75,10 @@ SCENARIO("Config accessor functions perform as expected.", "[Config]") {
} }
} }
WHEN("A boolean option is set to a string value representing something other than 0 or 1") { WHEN("A boolean option is set to a string value representing something other than 0 or 1") {
THEN("A BadOptionTypeException exception is thrown.") { config.set("gcode_comments", true);
THEN("A BadOptionTypeException exception is thrown and the value is unchanged.") {
REQUIRE_THROWS_AS(config.set("gcode_comments", "Z"), BadOptionTypeException); REQUIRE_THROWS_AS(config.set("gcode_comments", "Z"), BadOptionTypeException);
} REQUIRE(config.opt<ConfigOptionBool>("gcode_comments")->getBool() == true);
AND_THEN("Value is unchanged.") {
REQUIRE(config.opt<ConfigOptionBool>("gcode_comments")->getBool() == false);
} }
} }
WHEN("A boolean option is set to an int value") { WHEN("A boolean option is set to an int value") {
@@ -117,12 +116,10 @@ SCENARIO("Config accessor functions perform as expected.", "[Config]") {
} }
} }
WHEN("A numeric option is set to a non-numeric value.") { WHEN("A numeric option is set to a non-numeric value.") {
auto prev_value = config.opt<ConfigOptionFloat>("max_bridge_length")->getFloat(); config.set("max_bridge_length", 7.5);
THEN("A BadOptionTypeException exception is thrown.") { THEN("A BadOptionValueException exception is thrown and the value does not change.") {
REQUIRE_THROWS_AS(config.set_deserialize_strict("max_bridge_length", "zzzz"), BadOptionValueException); REQUIRE_THROWS_AS(config.set_deserialize_strict("max_bridge_length", "zzzz"), BadOptionValueException);
} REQUIRE_THAT(config.opt<ConfigOptionFloat>("max_bridge_length")->getFloat(), Catch::Matchers::WithinAbs(7.5, 1e-9));
THEN("The value does not change.") {
REQUIRE(config.opt<ConfigOptionFloat>("max_bridge_length")->getFloat() == prev_value);
} }
} }
WHEN("A string option is set through the string interface") { WHEN("A string option is set through the string interface") {
@@ -198,7 +195,7 @@ SCENARIO("Config accessor functions perform as expected.", "[Config]") {
} }
} }
WHEN("An invalid option is requested during set.") { WHEN("An invalid option is requested during set.") {
THEN("A BadOptionTypeException exception is thrown.") { THEN("A UnknownOptionException exception is thrown.") {
REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", 1), UnknownOptionException); REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", 1), UnknownOptionException);
REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", 1.0), UnknownOptionException); REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", 1.0), UnknownOptionException);
REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", "1"), UnknownOptionException); REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", "1"), UnknownOptionException);
@@ -1263,6 +1260,55 @@ TEST_CASE("read_cli rejects an invalid scalar numeric value", "[Config]") {
REQUIRE_FALSE(config.read_cli(3, argv, &extra, &keys)); 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]") { TEST_CASE("read_cli appends values when a vector option is repeated", "[Config]") {
Slic3r::DynamicPrintConfig config; Slic3r::DynamicPrintConfig config;
t_config_option_keys extra, keys; t_config_option_keys extra, keys;
@@ -1678,3 +1724,75 @@ TEST_CASE("Default options of enum lists get their definition's keys map", "[Con
} }
CHECK(enum_lists > 0); 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);
}
}
+6 -22
View File
@@ -65,41 +65,25 @@ TEST_CASE("expand_mixed_filaments replaces mixed slots with their components", "
} }
} }
TEST_CASE("belt purge tower island count ignores virtual mixed slots", "[FilamentMixer][belt]") TEST_CASE("A mixed slot whose components are already used adds no filaments", "[FilamentMixer][belt]")
{ {
// Regression for the "extra purge tower" on a belt printer with a mixed // The belt purge tower sizes its islands from the expanded set. A mixed slot is virtual: no
// filament (MCTEST5). The belt purge prism is sized as // nozzle carries it and ToolOrdering::resolve_mixed_filaments() replaces it with its
// n_islands = used_filaments.size() - 1 // components, so it must not count as a filament of its own.
// and GUI::ensure_belt_purge_tower() collected those filaments straight off
// the model objects' extruder assignments. A mixed slot is VIRTUAL -- no
// nozzle carries it, and ToolOrdering::resolve_mixed_filaments() replaces it
// with its components before any G-code is emitted -- so counting it as a
// filament of its own provisions one island that can never be reached.
//
// MCTEST5: five cubes on extruders 1..5, where filament 5 is a 50/50 blend of
// filaments 2 and 4. The G-code uses only T0..T3 and reports
// "filament used [g] = 53.35, 141.11, 40.84, 107.23, 0.00" -- filament 5
// consumes nothing, exactly as a virtual slot should.
const std::vector<unsigned char> is_mixed = {0, 0, 0, 0, 1}; const std::vector<unsigned char> is_mixed = {0, 0, 0, 0, 1};
const std::vector<std::string> comp_strs = {"", "", "", "", "2,4"}; const std::vector<std::string> comp_strs = {"", "", "", "", "2,4"};
// The set the sizer used to see: slots 0..4 (filaments 1..5).
const std::vector<unsigned int> assigned = {0, 1, 2, 3, 4}; const std::vector<unsigned int> assigned = {0, 1, 2, 3, 4};
const auto physical = expand_mixed_filaments(assigned, is_mixed, comp_strs); const auto physical = expand_mixed_filaments(assigned, is_mixed, comp_strs);
// Slot 4 dissolves into 1 and 3, which are already present. // Slot 4 dissolves into 1 and 3, which are already present.
REQUIRE(physical == std::vector<unsigned int>({0, 1, 2, 3})); REQUIRE(physical == std::vector<unsigned int>({0, 1, 2, 3}));
// Four physical filaments => three transitions => three islands, not four.
REQUIRE(int(physical.size()) - 1 == 3);
REQUIRE(int(assigned.size()) - 1 == 4); // what it produced before the fix
SECTION("A mixed slot whose components are otherwise unused still counts them") { SECTION("A mixed slot whose components are otherwise unused still counts them") {
// Only the mixed slot is assigned: it must still yield its two components, // Only the mixed slot is assigned: it must still yield its two components rather than
// i.e. one island, rather than collapsing to zero. // collapsing to nothing.
const auto only_mixed = expand_mixed_filaments({4}, is_mixed, comp_strs); const auto only_mixed = expand_mixed_filaments({4}, is_mixed, comp_strs);
REQUIRE(only_mixed == std::vector<unsigned int>({1, 3})); REQUIRE(only_mixed == std::vector<unsigned int>({1, 3}));
REQUIRE(int(only_mixed.size()) - 1 == 1);
} }
SECTION("No mixed filaments anywhere leaves the set untouched") { SECTION("No mixed filaments anywhere leaves the set untouched") {
+71 -15
View File
@@ -1334,12 +1334,17 @@ TEST_CASE("Renamed printer/process names are normalized into compatible lists on
CHECK(compatible_list(bundle.filaments, "My Filament", "compatible_printers") == CHECK(compatible_list(bundle.filaments, "My Filament", "compatible_printers") ==
(std::vector<std::string>{ "New Printer", "Unknown Printer" })); (std::vector<std::string>{ "New Printer", "Unknown Printer" }));
// Normalizing rewrites config in place without flagging the preset dirty. // Normalization runs before selection, so selecting copies the rewritten list into the edited preset.
CHECK_FALSE(bundle.prints.find_preset("My Process", false, true)->is_dirty); bundle.prints.select_preset_by_name("My Process", true);
REQUIRE(bundle.prints.get_selected_preset_name() == "My Process");
CHECK(bundle.prints.get_edited_preset().config.option<ConfigOptionStrings>("compatible_printers")->values ==
std::vector<std::string>{ "New Printer" });
// A system preset that already references the current name is left untouched (idempotent no-op). // Normalizing again is a no-op: the stored preset keeps the current name and still matches the
// edited copy, so the selection is not reported modified.
bundle.normalize_compatible_presets(); bundle.normalize_compatible_presets();
CHECK(compatible_list(bundle.prints, "My Process", "compatible_printers") == std::vector<std::string>{ "New Printer" }); CHECK(compatible_list(bundle.prints, "My Process", "compatible_printers") == std::vector<std::string>{ "New Printer" });
CHECK_FALSE(bundle.prints.current_is_dirty());
} }
TEST_CASE("Renamed names are normalized into a SYSTEM preset's compatible lists", "[Preset][Rename]") TEST_CASE("Renamed names are normalized into a SYSTEM preset's compatible lists", "[Preset][Rename]")
@@ -1369,11 +1374,17 @@ TEST_CASE("Renamed names are normalized into a SYSTEM preset's compatible lists"
CHECK(compatible_list(bundle.filaments, "System Filament", "compatible_prints") == CHECK(compatible_list(bundle.filaments, "System Filament", "compatible_prints") ==
std::vector<std::string>{ "New Process" }); std::vector<std::string>{ "New Process" });
// The rewrite does not flag the system preset dirty, and is idempotent. // Normalization runs before selection, so selecting copies the rewritten lists into the edited preset.
CHECK_FALSE(bundle.filaments.find_preset("System Filament", false, true)->is_dirty); bundle.filaments.select_preset_by_name("System Filament", true);
REQUIRE(bundle.filaments.get_selected_preset_name() == "System Filament");
CHECK(bundle.filaments.get_edited_preset().config.option<ConfigOptionStrings>("compatible_printers")->values ==
std::vector<std::string>{ "New Printer" });
// Normalizing again is a no-op, so the selection is not reported modified.
bundle.normalize_compatible_presets(); bundle.normalize_compatible_presets();
CHECK(compatible_list(bundle.filaments, "System Filament", "compatible_printers") == CHECK(compatible_list(bundle.filaments, "System Filament", "compatible_printers") ==
std::vector<std::string>{ "New Printer" }); std::vector<std::string>{ "New Printer" });
CHECK_FALSE(bundle.filaments.current_is_dirty());
} }
TEST_CASE("compatible_prints on SLA materials resolves against sla_prints, not prints", "[Preset][Rename]") TEST_CASE("compatible_prints on SLA materials resolves against sla_prints, not prints", "[Preset][Rename]")
@@ -5566,16 +5577,6 @@ TEST_CASE("Sizing down to the nozzle count plus mixes is what eats the mixed tai
CHECK(bundle.num_mixed_filaments() == 0); CHECK(bundle.num_mixed_filaments() == 0);
CHECK(bundle.num_physical_filaments() == target); CHECK(bundle.num_physical_filaments() == target);
} }
SECTION("applied as a floor, the mix is left alone") {
if (target > bundle.filament_presets.size())
bundle.set_num_filaments(target);
CHECK(bundle.filament_presets.size() == 6);
CHECK(bundle.num_mixed_filaments() == 1);
CHECK(bundle.is_mixed_filament(5));
CHECK(bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values[5] == "1,2");
}
} }
// After a published-3MF import relocated mixed-filament definitions, the freshly loaded // After a published-3MF import relocated mixed-filament definitions, the freshly loaded
@@ -8417,3 +8418,58 @@ 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.opt_serialize("nozzle_volume_type") == "High Flow");
CHECK(config.option<ConfigOptionStrings>("filament_multi_colour")->values.empty()); 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);
}
+5 -13
View File
@@ -5,9 +5,9 @@
#include <catch2/matchers/catch_matchers.hpp> #include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp> #include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "libslic3r/CAD/SketchImport.hpp" #include "libslic3r/CAD/SketchImport.hpp"
#include "libslic3r/Utils.hpp" // resources_dir
#include "test_utils.hpp" // ScopedTemporaryFile #include "test_utils.hpp" // ScopedTemporaryFile
#include <boost/filesystem/path.hpp>
#include <cstdlib> #include <cstdlib>
#include <fstream> #include <fstream>
#include "libslic3r/Point.hpp" #include "libslic3r/Point.hpp"
@@ -78,18 +78,10 @@ TEST_CASE("transform_regions moves and scales independently", "[SketchImport]")
TEST_CASE("text_to_regions vectorizes glyphs with counters", "[SketchImport]") TEST_CASE("text_to_regions vectorizes glyphs with counters", "[SketchImport]")
{ {
// Locate the bundled font; resources_dir() may be unset under ctest, so // The bundled font, from the source tree: PROFILES_DIR is <repo>/resources/profiles.
// fall back to a cwd-relative path (tests run from the repo root). const std::string font =
std::string font = resources_dir().empty() (boost::filesystem::path(PROFILES_DIR).parent_path() / "fonts" / "HarmonyOS_Sans_SC_Regular.ttf").string();
? std::string("resources/fonts/HarmonyOS_Sans_SC_Regular.ttf") REQUIRE(std::ifstream(font).good());
: resources_dir() + "/fonts/HarmonyOS_Sans_SC_Regular.ttf";
{
std::ifstream probe(font);
if (!probe.good()) {
SUCCEED("bundled font not reachable in this environment; covered live on :10");
return;
}
}
// Bad input is rejected without throwing. // Bad input is rejected without throwing.
REQUIRE(text_to_regions("", 10.0, font).empty()); REQUIRE(text_to_regions("", 10.0, font).empty());
+3 -2
View File
@@ -33,8 +33,9 @@ TEST_CASE("inference: cursor near a line endpoint snaps Coincident-able to it",
TEST_CASE("inference: endpoint beats midpoint when both are in range", "[inference]") TEST_CASE("inference: endpoint beats midpoint when both are in range", "[inference]")
{ {
std::vector<SketchEntity> ents = { line({0, 0}, {2, 0}) }; std::vector<SketchEntity> ents = { line({0, 0}, {2, 0}) };
// Query equidistant-ish but closer to the endpoint: endpoint tier wins regardless. // Query 0.2 from the midpoint (1,0) and 0.8 from the endpoint (2,0): the endpoint tier
auto s = infer_point_snap(ents, {1.9, 0.0}, 5.0); // wins even though the midpoint is nearer.
auto s = infer_point_snap(ents, {1.2, 0.0}, 5.0, /*include_origin=*/false);
CHECK(s.kind == K::Endpoint); CHECK(s.kind == K::Endpoint);
CHECK(s.role == SketchPointRole::P1); CHECK(s.role == SketchPointRole::P1);
} }
+4 -7
View File
@@ -113,14 +113,11 @@ TEST_CASE("profile: offsetting a closed rectangle keeps it closed", "[SketchProf
REQUIRE(closed_wires(out) == 1); REQUIRE(closed_wires(out) == 1);
} }
TEST_CASE("profile: offset outward grows the enclosed area by the right amount", "[SketchProfile]") TEST_CASE("profile: offsetting a closed rectangle outward keeps it one closed loop", "[SketchProfile]")
{ {
// A rectangle offset outward by d is (w+2d) x (h+2d) with the corners rounded at r=d, // Outward is -d for a CCW loop. The miter join reconnects the corners, so the result is one
// so its area is w*h + 2d(w+h) + pi*d^2 whichever way the corners are healed... except // closed wire, not four disconnected segments. Its area is checked by the sign test below.
// for a sharp-corner offset, which is exactly (w+2d)*(h+2d). Either healing is defensible; const auto out = SketchEngine::offset_entities(rect(40, 20), -5.0);
// a set of four disconnected segments is not, and that is what this measures.
const double w = 40, h = 20, d = 5;
const auto out = SketchEngine::offset_entities(rect(w, h), d);
const auto ws = SketchEngine::entities_to_wires(out, xy_plane()); const auto ws = SketchEngine::entities_to_wires(out, xy_plane());
REQUIRE(ws.size() == 1); REQUIRE(ws.size() == 1);
REQUIRE(ws[0].Closed()); REQUIRE(ws[0].Closed());
@@ -60,9 +60,8 @@ std::vector<NozzleInfo> single_nozzle_per_extruder(int extruder_count)
} }
} // namespace } // namespace
TEST_CASE("Multi-nozzle gate predicate mirrors BambuStudio", "[ToolOrdering][H2C]") TEST_CASE("Multi-nozzle options exist and default to one nozzle per extruder", "[ToolOrdering][H2C]")
{ {
// The multi-nozzle gate: std::any_of(extruder_max_nozzle_count > 1).
DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
auto *opt = config.option<ConfigOptionIntsNullable>("extruder_max_nozzle_count"); auto *opt = config.option<ConfigOptionIntsNullable>("extruder_max_nozzle_count");
@@ -72,15 +71,11 @@ TEST_CASE("Multi-nozzle gate predicate mirrors BambuStudio", "[ToolOrdering][H2C
// 3mf projects round-trip the per-extruder nozzle inventory (GUI producers wire it later). // 3mf projects round-trip the per-extruder nozzle inventory (GUI producers wire it later).
REQUIRE(config.option<ConfigOptionStrings>("extruder_nozzle_stats") != nullptr); REQUIRE(config.option<ConfigOptionStrings>("extruder_nozzle_stats") != nullptr);
auto has_multiple_nozzle = [](const std::vector<int> &values) { // Default for every existing printer: 1 nozzle per extruder, so the multi-nozzle gate
return std::any_of(values.begin(), values.end(), [](int v) { return v > 1; }); // (any extruder_max_nozzle_count > 1) stays closed.
}; REQUIRE_FALSE(opt->values.empty());
for (int count : opt->values)
// Default for every existing printer: 1 nozzle per extruder => gate is closed. CHECK(count == 1);
REQUIRE_FALSE(has_multiple_nozzle(opt->values));
// Synthetic H2C-like machine: extruder 1 is a 6-nozzle cluster => gate opens.
REQUIRE(has_multiple_nozzle(std::vector<int>{1, 6}));
} }
TEST_CASE("Single-nozzle grouping: every filament maps to its extruder nozzle", "[ToolOrdering][H2C]") TEST_CASE("Single-nozzle grouping: every filament maps to its extruder nozzle", "[ToolOrdering][H2C]")
+75 -34
View File
@@ -32,6 +32,7 @@
using namespace Slic3r; using namespace Slic3r;
using VD = Geometry::VoronoiDiagram; using VD = Geometry::VoronoiDiagram;
static bool has_missing_voronoi_vertices(const Polygons &polygons, const VD &vd);
using BoostVD = boost::polygon::voronoi_diagram<double>; using BoostVD = boost::polygon::voronoi_diagram<double>;
// https://svn.boost.org/trac10/ticket/12067 // https://svn.boost.org/trac10/ticket/12067
@@ -198,8 +199,12 @@ TEST_CASE("Voronoi missing edges - Alessandro gapfill 12707", "[Voronoi]")
REQUIRE(intersecting_edges({ poly }).empty()); REQUIRE(intersecting_edges({ poly }).empty());
Lines lines = to_lines(poly); Lines lines = to_lines(poly);
BoostVD vd; VD vd;
construct_voronoi(lines.begin(), lines.end(), &vd); vd.construct_voronoi(lines.begin(), lines.end());
// Orca's detector finds nothing wrong with this input, so no repair is needed.
CHECK(VD::detect_known_issues(vd, lines.begin(), lines.end()) == VD::IssueType::NO_ISSUE_DETECTED);
CHECK(vd.get_state() == VD::State::REPAIR_NOT_NEEDED);
CHECK_FALSE(has_missing_voronoi_vertices({ poly }, vd));
#ifdef VORONOI_DEBUG_OUT #ifdef VORONOI_DEBUG_OUT
dump_voronoi_to_svg(debug_out_path("voronoi-lines.svg").c_str(), dump_voronoi_to_svg(debug_out_path("voronoi-lines.svg").c_str(),
@@ -305,9 +310,16 @@ TEST_CASE("Voronoi weirdness", "[Voronoi]")
} }
#endif #endif
BoostVD vd; VD vd;
Lines lines = to_lines(poly); Lines lines = to_lines(poly);
construct_voronoi(lines.begin(), lines.end(), &vd); vd.construct_voronoi(lines.begin(), lines.end());
// Unrepaired, the diagram is missing a Voronoi vertex. construct_voronoi detects it and repairs it
// by rotating the input.
VD raw_vd;
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
CHECK(VD::detect_known_issues(raw_vd, lines.begin(), lines.end()) == VD::IssueType::MISSING_VORONOI_VERTEX);
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
#ifdef VORONOI_DEBUG_OUT #ifdef VORONOI_DEBUG_OUT
dump_voronoi_to_svg(debug_out_path("voronoi-weirdness.svg").c_str(), dump_voronoi_to_svg(debug_out_path("voronoi-weirdness.svg").c_str(),
@@ -347,9 +359,8 @@ TEST_CASE("Voronoi division by zero 12903", "[Voronoi]")
// https://svn.boost.org/trac10/ticket/12139 // https://svn.boost.org/trac10/ticket/12139
// Funny sample from a dental industry? // Funny sample from a dental industry?
// Vojtech confirms this test fails and rightly so, because the input data contain self intersections. // The input data contain self intersections, so no valid Voronoi diagram can be built from it.
// This test is suppressed. TEST_CASE("Voronoi NaN coordinates 12139", "[Voronoi]")
TEST_CASE("Voronoi NaN coordinates 12139", "[Voronoi][.][!mayfail]")
{ {
Lines lines = { Lines lines = {
{ { 260500,1564400 }, { 261040,1562960 } }, { { 260500,1564400 }, { 261040,1562960 } },
@@ -1327,18 +1338,13 @@ TEST_CASE("Voronoi NaN coordinates 12139", "[Voronoi][.][!mayfail]")
} }
#endif #endif
BoostVD vd; VD vd;
construct_voronoi(lines.begin(), lines.end(), &vd); vd.construct_voronoi(lines.begin(), lines.end());
// The diagram built from this self-intersecting input has non-finite vertices, and rotating the
for (const auto& edge : vd.edges()) // input does not repair it. It must be reported as invalid, which is what MedialAxis::build
if (edge.is_finite()) { // checks before falling back to a morphological closing.
auto v0 = edge.vertex0(); CHECK(vd.get_state() == VD::State::REPAIR_UNSUCCESSFUL);
auto v1 = edge.vertex1(); CHECK_FALSE(vd.is_valid());
REQUIRE((v0->x() == 0 || std::isnormal(v0->x())));
REQUIRE((v0->y() == 0 || std::isnormal(v0->y())));
REQUIRE((v1->x() == 0 || std::isnormal(v1->x())));
REQUIRE((v1->y() == 0 || std::isnormal(v1->y())));
}
#ifdef VORONOI_DEBUG_OUT #ifdef VORONOI_DEBUG_OUT
dump_voronoi_to_svg(debug_out_path("voronoi-NaNs.svg").c_str(), dump_voronoi_to_svg(debug_out_path("voronoi-NaNs.svg").c_str(),
@@ -1935,7 +1941,7 @@ TEST_CASE("Voronoi skeleton", "[VoronoiSkeleton]")
} }
// Simple detection with complexity N^2 if there is any point in the input polygons that doesn't have Voronoi vertex. // Simple detection with complexity N^2 if there is any point in the input polygons that doesn't have Voronoi vertex.
[[maybe_unused]] static bool has_missing_voronoi_vertices(const Polygons &polygons, const VD &vd) static bool has_missing_voronoi_vertices(const Polygons &polygons, const VD &vd)
{ {
auto are_equal = [](const VD::vertex_type v, const Point &p) { return (Vec2d(v.x(), v.y()) - p.cast<double>()).norm() <= SCALED_EPSILON; }; auto are_equal = [](const VD::vertex_type v, const Point &p) { return (Vec2d(v.x(), v.y()) - p.cast<double>()).norm() <= SCALED_EPSILON; };
@@ -1957,7 +1963,7 @@ TEST_CASE("Voronoi skeleton", "[VoronoiSkeleton]")
// into two parts. Even we add more points to the edge, and then for these points, the Voronoin vertex is also missing. An // into two parts. Even we add more points to the edge, and then for these points, the Voronoin vertex is also missing. An
// infinity-edge passes through the missing Voronoi vertex. Therefore, this missing Voronoi vertex and edge can be reconstructed // infinity-edge passes through the missing Voronoi vertex. Therefore, this missing Voronoi vertex and edge can be reconstructed
// using the intersection between the infinity-edge with the input polygon. // using the intersection between the infinity-edge with the input polygon.
// Rotation of the polygon solves this problem. // VoronoiDiagram::construct_voronoi repairs it by rotating the input.
TEST_CASE("Voronoi missing vertex 1", "[VoronoiMissingVertex1]") TEST_CASE("Voronoi missing vertex 1", "[VoronoiMissingVertex1]")
{ {
Polygon poly = { Polygon poly = {
@@ -1981,7 +1987,14 @@ TEST_CASE("Voronoi missing vertex 1", "[VoronoiMissingVertex1]")
dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex1-out.svg").c_str(), vd, Points(), lines); dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex1-out.svg").c_str(), vd, Points(), lines);
#endif #endif
// REQUIRE(!has_missing_voronoi_vertices({poly}, vd)); // Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
// rotating the input.
VD raw_vd;
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
CHECK(has_missing_voronoi_vertices({poly}, raw_vd));
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
CHECK_FALSE(has_missing_voronoi_vertices({poly}, vd));
} }
// This case is composed of two square polygons (contour and hole), and again one of the edges is divided into two parts by a // This case is composed of two square polygons (contour and hole), and again one of the edges is divided into two parts by a
@@ -1989,7 +2002,7 @@ TEST_CASE("Voronoi missing vertex 1", "[VoronoiMissingVertex1]")
// that for this case, through the missing Voronoi vertex is passing a finite edge between two internal Voronoin vertices. // that for this case, through the missing Voronoi vertex is passing a finite edge between two internal Voronoin vertices.
// Therefore, this missing Voronoi vertex and edge can be reconstructed using the intersection between the finite edge with the // Therefore, this missing Voronoi vertex and edge can be reconstructed using the intersection between the finite edge with the
// input polygon. // input polygon.
// Rotation of the polygons solves this problem. // VoronoiDiagram::construct_voronoi repairs it by rotating the input.
TEST_CASE("Voronoi missing vertex 2", "[VoronoiMissingVertex2]") TEST_CASE("Voronoi missing vertex 2", "[VoronoiMissingVertex2]")
{ {
Polygons poly = { Polygons poly = {
@@ -2021,13 +2034,20 @@ TEST_CASE("Voronoi missing vertex 2", "[VoronoiMissingVertex2]")
dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex2-out.svg").c_str(), vd, Points(), lines); dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex2-out.svg").c_str(), vd, Points(), lines);
#endif #endif
// REQUIRE(!has_missing_voronoi_vertices(poly, vd)); // Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
// rotating the input.
VD raw_vd;
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
CHECK(has_missing_voronoi_vertices(poly, raw_vd));
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
CHECK_FALSE(has_missing_voronoi_vertices(poly, vd));
} }
// This case is composed of two polygons, and again one of the edges is divided into two parts by a point that lies on this edge, // This case is composed of two polygons, and again one of the edges is divided into two parts by a point that lies on this edge,
// and for this point is Voronoi vertex missing. A difference between the previous cases and this case through the missing // and for this point is Voronoi vertex missing. A difference between the previous cases and this case through the missing
// Voronoi vertex is passing finite edge between one inner Voronoi vertex and one outer Voronoi vertex. // Voronoi vertex is passing finite edge between one inner Voronoi vertex and one outer Voronoi vertex.
// Rotating the polygon also help solve this problem. // VoronoiDiagram::construct_voronoi repairs it by rotating the input.
TEST_CASE("Voronoi missing vertex 3", "[VoronoiMissingVertex3]") TEST_CASE("Voronoi missing vertex 3", "[VoronoiMissingVertex3]")
{ {
Polygons poly = { Polygons poly = {
@@ -2055,20 +2075,27 @@ TEST_CASE("Voronoi missing vertex 3", "[VoronoiMissingVertex3]")
// polygons_rotate(poly, PI/180); // polygons_rotate(poly, PI/180);
// polygons_rotate(poly, PI/6); // polygons_rotate(poly, PI/6);
BoostVD vd; VD vd;
Lines lines = to_lines(poly); Lines lines = to_lines(poly);
construct_voronoi(lines.begin(), lines.end(), &vd); vd.construct_voronoi(lines.begin(), lines.end());
#ifdef VORONOI_DEBUG_OUT #ifdef VORONOI_DEBUG_OUT
dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex3-out.svg").c_str(), vd, Points(), lines); dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex3-out.svg").c_str(), vd, Points(), lines);
#endif #endif
// REQUIRE(!has_missing_voronoi_vertices(poly, vd)); // Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
// rotating the input.
VD raw_vd;
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
CHECK(has_missing_voronoi_vertices(poly, raw_vd));
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
CHECK_FALSE(has_missing_voronoi_vertices(poly, vd));
} }
// In this case, the Voronoi vertex (146873, -146873) is included twice. // In this case, the Voronoi vertex (146873, -146873) is included twice.
// Also, near to those duplicate Voronoi vertices is another Voronoi vertex (146872, -146872). // Also, near to those duplicate Voronoi vertices is another Voronoi vertex (146872, -146872).
// Rotating the polygon will help solve this problem, but then there arise three very close Voronoi vertices. // Rotating the polygon will help solve this problem, but then there arise three very close Voronoi vertices.
// Rotating of the input polygon will help solve this problem. // VoronoiDiagram::construct_voronoi repairs it by rotating the input.
TEST_CASE("Duplicate Voronoi vertices", "[Voronoi]") TEST_CASE("Duplicate Voronoi vertices", "[Voronoi]")
{ {
Polygon poly = { Polygon poly = {
@@ -2095,7 +2122,7 @@ TEST_CASE("Duplicate Voronoi vertices", "[Voronoi]")
dump_voronoi_to_svg(debug_out_path("voronoi-duplicate-vertices-out.svg").c_str(), vd, Points(), lines); dump_voronoi_to_svg(debug_out_path("voronoi-duplicate-vertices-out.svg").c_str(), vd, Points(), lines);
#endif #endif
[[maybe_unused]] auto has_duplicate_vertices = [](const VD &vd) -> bool { auto has_duplicate_vertices = [](const VD &vd) -> bool {
std::vector<Vec2d> vertices; std::vector<Vec2d> vertices;
for (const auto &vertex : vd.vertices()) for (const auto &vertex : vd.vertices())
vertices.emplace_back(Vec2d(vertex.x(), vertex.y())); vertices.emplace_back(Vec2d(vertex.x(), vertex.y()));
@@ -2104,14 +2131,21 @@ TEST_CASE("Duplicate Voronoi vertices", "[Voronoi]")
return std::unique(vertices.begin(), vertices.end()) != vertices.end(); return std::unique(vertices.begin(), vertices.end()) != vertices.end();
}; };
// REQUIRE(!has_duplicate_vertices(vd)); // Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
// rotating the input.
VD raw_vd;
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
CHECK(has_duplicate_vertices(raw_vd));
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
CHECK_FALSE(has_duplicate_vertices(vd));
} }
// In this case, there are three very close Voronoi vertices like in the previous test case after rotation. There is also one // In this case, there are three very close Voronoi vertices like in the previous test case after rotation. There is also one
// missing Voronoi vertex. One infinity-edge (after clip [(146872, -70146871), (146872, -146871)]) passes through this missing // missing Voronoi vertex. One infinity-edge (after clip [(146872, -70146871), (146872, -146871)]) passes through this missing
// Voronoi vertex. This infinite edge [(146872, -70146871), (146872, -146871)] and edge [(146873, -146873), (0, 0)] are intersecting. // Voronoi vertex. This infinite edge [(146872, -70146871), (146872, -146871)] and edge [(146873, -146873), (0, 0)] are intersecting.
// They intersect probably because the three points are very close to each other, with a combination of the missing Voronoi vertex. // They intersect probably because the three points are very close to each other, with a combination of the missing Voronoi vertex.
// Rotating of the input polygon will help solve this problem. // VoronoiDiagram::construct_voronoi repairs it by rotating the input.
TEST_CASE("Intersecting Voronoi edges", "[Voronoi]") TEST_CASE("Intersecting Voronoi edges", "[Voronoi]")
{ {
Polygon poly = { Polygon poly = {
@@ -2135,7 +2169,7 @@ TEST_CASE("Intersecting Voronoi edges", "[Voronoi]")
dump_voronoi_to_svg(debug_out_path("voronoi-intersecting-edges-out.svg").c_str(), vd, Points(), lines); dump_voronoi_to_svg(debug_out_path("voronoi-intersecting-edges-out.svg").c_str(), vd, Points(), lines);
#endif #endif
[[maybe_unused]] auto has_intersecting_edges = [](const Polygon &poly, const VD &vd) -> bool { auto has_intersecting_edges = [](const Polygon &poly, const VD &vd) -> bool {
BoundingBox bbox = get_extents(poly); BoundingBox bbox = get_extents(poly);
const double bbox_dim_max = double(std::max(bbox.size().x(), bbox.size().y())); const double bbox_dim_max = double(std::max(bbox.size().x(), bbox.size().y()));
@@ -2166,5 +2200,12 @@ TEST_CASE("Intersecting Voronoi edges", "[Voronoi]")
return false; return false;
}; };
// REQUIRE(!has_intersecting_edges(poly, vd)); // Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
// rotating the input.
VD raw_vd;
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
CHECK(has_intersecting_edges(poly, raw_vd));
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
CHECK_FALSE(has_intersecting_edges(poly, vd));
} }
+770
View File
@@ -1,22 +1,48 @@
#include <boost/filesystem/path.hpp> #include <boost/filesystem/path.hpp>
#include <boost/filesystem/operations.hpp> #include <boost/filesystem/operations.hpp>
#include <boost/system/error_code.hpp>
#include <catch2/catch_all.hpp> #include <catch2/catch_all.hpp>
#include <libslic3r/Utils.hpp> #include <libslic3r/Utils.hpp>
#include <libslic3r/libslic3r.h> // GCODEVIEWER_APP_KEY, SLIC3R_APP_KEY (via libslic3r_version.h) #include <libslic3r/libslic3r.h> // GCODEVIEWER_APP_KEY, SLIC3R_APP_KEY (via libslic3r_version.h)
#include <slic3r/plugin/PluginAuditManager.hpp> #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 <slic3r/Utils/OrcaCloudServiceAgent.hpp> // secret_constants::USER_SECRET_FILENAME
#include <catch2/catch_message.hpp>
#include <catch2/catch_test_macros.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 "libslic3r_version.h"
#include "plugin_test_utils.hpp" #include "plugin_test_utils.hpp"
#include "test_utils.hpp"
#include <boost/filesystem.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 <string>
#include <utility>
#include <vector>
using namespace Slic3r; using namespace Slic3r;
namespace fs = boost::filesystem; namespace fs = boost::filesystem;
namespace py = pybind11;
namespace { namespace {
@@ -349,3 +375,747 @@ 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); 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>"));
}
@@ -8,6 +8,7 @@
#include <slic3r/plugin/PythonPluginInterface.hpp> #include <slic3r/plugin/PythonPluginInterface.hpp>
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <catch2/catch_message.hpp>
#include "plugin_test_utils.hpp" #include "plugin_test_utils.hpp"
#include <pybind11/pytypes.h> #include <pybind11/pytypes.h>
#include <pybind11/eval.h> #include <pybind11/eval.h>
@@ -17,6 +18,12 @@
#include <pybind11/embed.h> #include <pybind11/embed.h>
#include <pybind11/pybind11.h> #include <pybind11/pybind11.h>
#include <boost/filesystem/operations.hpp>
#include <boost/filesystem/path.hpp>
#include <chrono>
#include <fstream>
#include <ios>
#include <memory> #include <memory>
#include <string> #include <string>
#include <pybind11/cast.h> #include <pybind11/cast.h>
@@ -105,6 +112,72 @@ PluginCapabilityId capability_id(PluginCapabilityType type, const char* name, co
return {type, name, plugin_key}; return {type, name, plugin_key};
} }
// restore_config_response looks the capability up in PluginManager, so the restore tests load a real
// plugin rather than building a detached capability.
const char* const RESTORE_PLUGIN_KEY = "Restore_Plugin";
const char* const RESTORE_PLUGIN_SOURCE = R"PY(# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# name = "Restore Plugin"
# description = "Restore defaults fixture"
# author = "OrcaSlicer"
# version = "1.0"
# type = "script"
# ///
import orca
class Target(orca.script.ScriptPluginCapabilityBase):
def get_name(self): return "Target"
def execute(self, ctx): return orca.ExecutionResult.success()
def get_default_config(self): return {"speed": 1}
class Bystander(orca.script.ScriptPluginCapabilityBase):
def get_name(self): return "Bystander"
def execute(self, ctx): return orca.ExecutionResult.success()
def get_default_config(self): return {"speed": 2}
# Loading seeds defaults through get_default_config and fails the plugin if it raises, so this one
# answers the load and raises from then on.
class Raising(orca.script.ScriptPluginCapabilityBase):
calls = 0
def get_name(self): return "Raising"
def execute(self, ctx): return orca.ExecutionResult.success()
def get_default_config(self):
Raising.calls += 1
if Raising.calls > 1:
raise RuntimeError("boom")
return {}
@orca.plugin
class RestorePackage(orca.base):
def register_capabilities(self):
orca.register_capability(Target)
orca.register_capability(Bystander)
orca.register_capability(Raising)
)PY";
// Writes the restore fixture into {data_dir}/orca_plugins and loads it, returning the load error
// (empty on success). Call without holding the GIL: the load runs on a worker thread.
std::string load_restore_plugin(const ScopedDataDir& data_dir_guard)
{
const boost::filesystem::path plugin_dir = data_dir_guard.plugins_dir() / RESTORE_PLUGIN_KEY;
boost::filesystem::create_directories(plugin_dir);
{
std::ofstream out((plugin_dir / (std::string(RESTORE_PLUGIN_KEY) + ".py")).string(), std::ios::binary);
out << RESTORE_PLUGIN_SOURCE;
}
PluginManager& manager = PluginManager::instance();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
manager.load_plugin(RESTORE_PLUGIN_KEY, /*skip_deps=*/true);
std::string error;
if (!manager.wait_for_plugin_load(RESTORE_PLUGIN_KEY, std::chrono::seconds(120), error) && error.empty())
error = "the plugin did not load";
return error;
}
} // namespace } // namespace
TEST_CASE("Capability config API is exposed on every Python capability", "[PluginConfig][Python]") TEST_CASE("Capability config API is exposed on every Python capability", "[PluginConfig][Python]")
@@ -357,26 +430,27 @@ TEST_CASE("Restoring defaults overwrites only the target capability", "[PluginCo
ScopedPluginManager plugin_system; // declared first: destroyed last ScopedPluginManager plugin_system; // declared first: destroyed last
if (!plugin_system.initialized) if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down
ScopedDataDir data_dir_guard("plugin-config-py-restore"); ScopedDataDir data_dir_guard("plugin-config-py-restore");
host_config().load(); host_config().load();
const std::string load_error = load_restore_plugin(data_dir_guard);
INFO("load error: " << load_error);
REQUIRE(load_error.empty());
const std::string defaults_body = " def get_name(self): return 'cap'\n" const PluginCapabilityId target = capability_id(PluginCapabilityType::Script, "Target", RESTORE_PLUGIN_KEY);
" def get_default_config(self): return {'speed': 1}\n"; const PluginCapabilityId bystander = capability_id(PluginCapabilityType::Script, "Bystander", RESTORE_PLUGIN_KEY);
py::object target = make_capability("RestoreTargetCap", defaults_body, "plugin_a", "cap_a");
py::object bystander = make_capability("RestoreBystanderCap", defaults_body, "plugin_b", "cap_a");
const json edited = json{{"speed", 99}}; const json edited = json{{"speed", 99}};
REQUIRE(py_save_config(target, edited)); REQUIRE(host_config().store_capability_config(target, edited));
REQUIRE(py_save_config(bystander, edited)); REQUIRE(host_config().store_capability_config(bystander, edited));
// What PluginsDialog::restore_capability_config does: ask the capability, store the answer. const json response = PluginConfig::restore_config_response(target);
auto iface = as_interface(target);
REQUIRE(host_config().store_capability_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"), iface->get_default_config()));
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"speed", 1}}); CHECK(response["ok"] == true);
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_b"))->config == json{{"speed", 99}}); CHECK(response["config"] == json{{"speed", 1}});
CHECK(host_config().get_config(target)->config == json{{"speed", 1}});
CHECK(host_config().get_config(bystander)->config == edited);
PluginManager::instance().unload_plugin(RESTORE_PLUGIN_KEY);
} }
TEST_CASE("A raising get_default_config leaves the stored config untouched", "[PluginConfig][Python]") TEST_CASE("A raising get_default_config leaves the stored config untouched", "[PluginConfig][Python]")
@@ -384,24 +458,23 @@ TEST_CASE("A raising get_default_config leaves the stored config untouched", "[P
ScopedPluginManager plugin_system; // declared first: destroyed last ScopedPluginManager plugin_system; // declared first: destroyed last
if (!plugin_system.initialized) if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down
ScopedDataDir data_dir_guard("plugin-config-py-restore-raise"); ScopedDataDir data_dir_guard("plugin-config-py-restore-raise");
host_config().load(); host_config().load();
const std::string load_error = load_restore_plugin(data_dir_guard);
INFO("load error: " << load_error);
REQUIRE(load_error.empty());
py::object cap = make_capability("RaisingDefaultsCap", const PluginCapabilityId raising = capability_id(PluginCapabilityType::Script, "Raising", RESTORE_PLUGIN_KEY);
" def get_name(self): return 'cap_a'\n" REQUIRE(host_config().store_capability_config(raising, json{{"keep", "me"}}));
" def get_default_config(self): raise RuntimeError('boom')\n",
"plugin_a", "cap_a");
REQUIRE(py_save_config(cap, json{{"keep", "me"}})); const json response = PluginConfig::restore_config_response(raising);
auto iface = as_interface(cap); CHECK(response["ok"] == false);
REQUIRE(iface); CHECK_FALSE(response["error"].get<std::string>().empty());
CHECK_THROWS_AS(iface->get_default_config(), py::error_already_set); CHECK(host_config().get_config(raising)->config == json{{"keep", "me"}});
// The dialog stores nothing when the hook throws: a broken plugin must not wipe user settings. PluginManager::instance().unload_plugin(RESTORE_PLUGIN_KEY);
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"keep", "me"}});
} }
TEST_CASE("A raising config UI hook surfaces as an exception the host can catch", "[PluginConfig][Python]") TEST_CASE("A raising config UI hook surfaces as an exception the host can catch", "[PluginConfig][Python]")
@@ -76,12 +76,12 @@ TEST_CASE("plugin latest version uses the authoritative catalog field", "[Plugin
{ {
PluginDescriptor descriptor; PluginDescriptor descriptor;
descriptor.version = "1.3.0"; descriptor.version = "1.3.0";
descriptor.latest_version = "1.3.0"; descriptor.latest_version = "1.4.0";
PluginChangelog changelog; PluginChangelog changelog;
changelog.version = "1.2.0"; changelog.version = "1.2.0";
descriptor.changelog.push_back(changelog); descriptor.changelog.push_back(changelog);
CHECK(descriptor.latest_available_version() == "1.3.0"); CHECK(descriptor.latest_available_version() == "1.4.0");
} }
TEST_CASE("plugin latest version falls back to the descriptor version", "[PluginDescriptor]") TEST_CASE("plugin latest version falls back to the descriptor version", "[PluginDescriptor]")
@@ -615,12 +615,19 @@ TEST_CASE("orca.host: SurfaceCollection.set mutates geometry; lslices via make_s
.attr("expolygon").attr("contour").attr("as_array")().cast<py::array>(); .attr("expolygon").attr("contour").attr("as_array")().cast<py::array>();
CHECK(c.shape(0) == 4); CHECK(c.shape(0) == 4);
// lslices are derived: make_slices() re-derives them + refreshes the bbox cache. // lslices are derived: make_slices() re-derives them from the region slices and refreshes the
// bbox cache.
TestLayer layer; TestLayer layer;
layer.add_region(nullptr);
py::object ly = py::cast(static_cast<Slic3r::Layer*>(&layer), py::return_value_policy::reference); py::object ly = py::cast(static_cast<Slic3r::Layer*>(&layer), py::return_value_policy::reference);
// (A hand-built layer has no regions, so make_slices() yields empty lslices — still null-safe.) ly.attr("regions")().cast<py::list>()[0].attr("slices").attr("set")(expolys, ST.attr("stInternalSolid"));
REQUIRE(layer.lslices.empty());
ly.attr("make_slices")(); ly.attr("make_slices")();
CHECK(layer.lslices_bboxes.size() == layer.lslices.size()); REQUIRE(layer.lslices.size() == 1);
CHECK_THAT(layer.lslices.front().area(), WithinRel((double) s * (double) s, 1e-9));
REQUIRE(layer.lslices_bboxes.size() == 1);
CHECK(layer.lslices_bboxes.front().min == Point(0, 0));
CHECK(layer.lslices_bboxes.front().max == Point(s, s));
} }
TEST_CASE("orca.host ExPolygon in-place transforms + SurfaceCollection.append (sample ops)", "[slicing_pipeline]") { TEST_CASE("orca.host ExPolygon in-place transforms + SurfaceCollection.append (sample ops)", "[slicing_pipeline]") {