Compare commits

..
Author SHA1 Message Date
yw4z 2457173764 Fix: Instance position not syncs after drop and Object list sink icon/button not appears or non functional (#16501)
* sync instances on drop

* fix sinking object not works while auto drop is off
2026-10-20 22:40:49 +03:00
Javad Shafique c081aa97f7 fix: SimplyPrint device tab ignores the uploaded file import URL (#16555)
The tab switch queued a reload of the host's home page, which ran after the import URL and replaced it. Switch tabs synchronously first, then queue the import URL so it is the last page loaded.
2026-10-20 13:28:36 -05:00
Ian ChuaandSoftFever 37dcc65752 fix: Allow plugins to read the embedded Python stdlib (#16669)
* Stop re-asking for plugin permissions granted in an earlier session

* fix: Allow plugins to read the embedded Python stdlib

---------

Co-authored-by: SoftFever <softfeverever@gmail.com>
2026-10-20 21:31:25 +08:00
HanifKoh cdb7eda54f Update GMP to 6.3.0 to Avoid the x18 Register on Apple Silicon (#16686)
* Update GMP to 6.3.0 to Avoid the x18 Register on Apple Silicon

GMP 6.2.1's arm64 assembly keeps loop counters in x18, which Apple reserves and
macOS zeroes on every return to user space (syscalls, page faults, preemption).
When that happens inside one of those loops the counter underflows and the
routine walks the stack until the guard page: EXC_BAD_ACCESS / SIGBUS inside
__gmpn_* whenever CGAL falls back to exact arithmetic (texture import boundary
smoothing, text emboss cuts, mesh repair, mesh boolean).

6.3.0 moves those counters to ordinary temporaries (gmp-6.2 changeset
f4ff6ff711ed); no x18 use remains under mpn/arm64. The GCC 15 patch applies
unchanged apart from a line offset. The tarball comes from the GNU mirror, like
MPFR. Windows is unaffected: MSVC copies the prebuilt libgmp-10 and the arm64
job stages MSYS2's GMP.

Built with the deps recipe flags and ran make check on Linux x86_64 (clang 18,
200/200) and macOS arm64 (Apple clang, 199/199).

* Add gmplib.org as a Fallback Download for GMP

Same two-source pattern as MPFR: the GNU mirror first, the project's own
server second, one hash for both.

* Use the OrcaSlicer_deps Mirror as the GMP Fallback Download

SoftFever published gmp-6.3.0.tar.xz in SoftFever/OrcaSlicer_deps, the
same mirror 6.2.1 came from. Both sources serve the .tar.xz release file,
byte-identical (one SHA-256): the GNU server first, the mirror as fallback.

* Stage GMP 6.3.0 for the Flatpak Build

The Flatpak sandbox has no network, so the manifest pre-downloads every
dependency tarball into external-packages/. Point the GMP entry at the
6.3.0 .tar.xz the deps recipe now expects.
2026-10-20 19:52:44 +08:00
Ian Chua e7d6cc32a7 fix: deny plugin from modifying plugin sidecar (#16634) 2026-10-20 18:04:42 +08:00
SoftFever 069bd402d9 Forward CMake arguments after -- in the macOS build script (#16683)
Unknown options and missing option values now stop the script instead of being ignored.
2026-10-20 16:06:33 +08:00
10 changed files with 208 additions and 201 deletions

No files matched your search

+25 -3
View File
@@ -47,7 +47,7 @@ while getopts ":dpa:snt:xbc:i:j:Tuh" opt; do
u ) u )
export BUILD_TARGET="universal" export BUILD_TARGET="universal"
;; ;;
h ) echo "Usage: ./build_release_macos.sh [-d]" h ) echo "Usage: ./build_release_macos.sh [-d] [-- CMAKE_ARGS...]"
echo " -d: Build deps only" echo " -d: Build deps only"
echo " -a: Set ARCHITECTURE (arm64 or x86_64 or universal)" echo " -a: Set ARCHITECTURE (arm64 or x86_64 or universal)"
echo " -s: Build slicer only" echo " -s: Build slicer only"
@@ -60,13 +60,33 @@ while getopts ":dpa:snt:xbc:i:j:Tuh" opt; do
echo " -i: Add a prefix to ignore during CMake dependency discovery (repeatable), defaults to /opt/local:/usr/local:/opt/homebrew" echo " -i: Add a prefix to ignore during CMake dependency discovery (repeatable), defaults to /opt/local:/usr/local:/opt/homebrew"
echo " -j: Set the number of parallel build jobs (CMAKE_BUILD_PARALLEL_LEVEL)" echo " -j: Set the number of parallel build jobs (CMAKE_BUILD_PARALLEL_LEVEL)"
echo " -T: Build and run tests (set ORCA_TESTS_BUILD_ONLY=1 to build without running)" echo " -T: Build and run tests (set ORCA_TESTS_BUILD_ONLY=1 to build without running)"
echo " -- CMAKE_ARGS...: pass everything after '--' to the cmake configure step of the deps (-d) and slicer (-s) builds,"
echo " e.g. -DDESTDIR=<dir> for the deps, -DCMAKE_PREFIX_PATH=<dir>/usr/local -DSLIC3R_PCH=OFF for the slicer, --fresh."
echo " They come last on the cmake command line. Use absolute paths: cmake runs in the build directory."
exit 0 exit 0
;; ;;
: )
echo "Option -${OPTARG} requires an argument, aborting."
exit 1
;;
* ) * )
echo "Unknown option -${OPTARG}, aborting. CMake arguments must follow '--'."
exit 1
;; ;;
esac esac
done done
# getopts stops at '--'; whatever follows is handed to cmake unchanged.
if [[ ${OPTIND} -le $# ]] ; then
separator_index=$((OPTIND-1))
if [[ "${!separator_index}" != "--" ]] ; then
echo "Unexpected argument '${!OPTIND}': cmake arguments must follow '--', aborting."
exit 1
fi
fi
shift $((OPTIND-1))
EXTRA_CMAKE_ARGS=("$@")
# Set defaults # Set defaults
if [ -z "$ARCH" ]; then if [ -z "$ARCH" ]; then
@@ -211,7 +231,8 @@ function build_deps() {
-DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \ -DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \
-DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \ -DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \
"${CMAKE_CCACHE_ARGS[@]}" \ "${CMAKE_CCACHE_ARGS[@]}" \
${CMAKE_POLICY_COMPAT} ${CMAKE_POLICY_COMPAT} \
"${EXTRA_CMAKE_ARGS[@]}"
fi fi
cmake --build . --config "$BUILD_CONFIG" --target deps cmake --build . --config "$BUILD_CONFIG" --target deps
) )
@@ -314,7 +335,8 @@ function build_slicer() {
-DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \ -DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \
-DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \ -DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \
"${CMAKE_CCACHE_ARGS[@]}" \ "${CMAKE_CCACHE_ARGS[@]}" \
${CMAKE_POLICY_COMPAT} ${CMAKE_POLICY_COMPAT} \
"${EXTRA_CMAKE_ARGS[@]}"
fi fi
cmake --build . --config "$BUILD_CONFIG" --target "$SLICER_BUILD_TARGET" cmake --build . --config "$BUILD_CONFIG" --target "$SLICER_BUILD_TARGET"
) )
+6 -2
View File
@@ -59,9 +59,13 @@ else ()
set(_cross_compile_arg --host=${TOOLCHAIN_PREFIX}) set(_cross_compile_arg --host=${TOOLCHAIN_PREFIX})
endif () endif ()
# 6.3.0 is the first release whose arm64 assembly avoids x18, which macOS
# reserves and zeroes on every return to user space; 6.2.1 crashes on Apple
# silicon whenever CGAL falls back to exact arithmetic.
ExternalProject_Add(dep_GMP ExternalProject_Add(dep_GMP
URL https://github.com/SoftFever/OrcaSlicer_deps/releases/download/gmp-6.2.1/gmp-6.2.1.tar.bz2 URL https://ftp.gnu.org/gnu/gmp/gmp-6.3.0.tar.xz
URL_HASH SHA256=eae9326beb4158c386e39a356818031bd28f3124cf915f8c5b1dc4c7a36b4d7c https://github.com/SoftFever/OrcaSlicer_deps/releases/download/gmp-6.3.0/gmp-6.3.0.tar.xz
URL_HASH SHA256=a3c2b80201b89e68616f4ad30bc66aee4927c3ce50e33929ca819d5c43538898
DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/GMP DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/GMP
PATCH_COMMAND git apply ${GMP_DIRECTORY_FLAG} --verbose ${CMAKE_CURRENT_LIST_DIR}/0001-GMP_GCC15.patch PATCH_COMMAND git apply ${GMP_DIRECTORY_FLAG} --verbose ${CMAKE_CURRENT_LIST_DIR}/0001-GMP_GCC15.patch
BUILD_IN_SOURCE ON BUILD_IN_SOURCE ON
@@ -226,10 +226,10 @@ modules:
sha256: f353e7b99bd0cbfc27ac9082de51acf32a8bc0b3e21ff9661ecca6f205ec1d81 sha256: f353e7b99bd0cbfc27ac9082de51acf32a8bc0b3e21ff9661ecca6f205ec1d81
dest: external-packages/OpenVDB dest: external-packages/OpenVDB
# GMP 6.2.1 # GMP 6.3.0
- type: file - type: file
url: https://github.com/SoftFever/OrcaSlicer_deps/releases/download/gmp-6.2.1/gmp-6.2.1.tar.bz2 url: https://ftp.gnu.org/gnu/gmp/gmp-6.3.0.tar.xz
sha256: eae9326beb4158c386e39a356818031bd28f3124cf915f8c5b1dc4c7a36b4d7c sha256: a3c2b80201b89e68616f4ad30bc66aee4927c3ce50e33929ca819d5c43538898
dest: external-packages/GMP dest: external-packages/GMP
# MPFR 4.2.2 # MPFR 4.2.2
+4 -20
View File
@@ -5632,14 +5632,10 @@ void GLCanvas3D::do_move(const std::string& snapshot_type, bool force_volume_mov
// Fixes sinking/flying instances (snaps object to buildplate) // Fixes sinking/flying instances (snaps object to buildplate)
for (const std::pair<int, int>& i : done) { for (const std::pair<int, int>& i : done) {
ModelObject* mo = m_model->objects[i.first]; ModelObject* mo = m_model->objects[i.first];
if (!mo->auto_drop) {
continue;
}
const double shift_z = mo->get_instance_min_z(i.second); const double shift_z = mo->get_instance_min_z(i.second);
//BBS: don't call translate if the z is zero //BBS: don't call translate if the z is zero
if ((current_printer_technology() == ptSLA || shift_z > SINKING_Z_THRESHOLD) && (shift_z != 0.0f)) { if (mo->auto_drop && (current_printer_technology() == ptSLA || shift_z > SINKING_Z_THRESHOLD) && (shift_z != 0.0f)) {
const Vec3d shift(0.0, 0.0, -shift_z); const Vec3d shift(0.0, 0.0, -shift_z);
m_selection.translate(i.first, i.second, shift); m_selection.translate(i.first, i.second, shift);
mo->translate_instance(i.second, shift); mo->translate_instance(i.second, shift);
@@ -5763,14 +5759,10 @@ void GLCanvas3D::do_rotate(const std::string& snapshot_type)
for (const std::pair<int, int> &i : done) { for (const std::pair<int, int> &i : done) {
ModelObject *mo = m_model->objects[i.first]; ModelObject *mo = m_model->objects[i.first];
if (!mo->auto_drop) {
continue;
}
// BBS: don't call translate if the z is zero // BBS: don't call translate if the z is zero
const double shift_z = mo->get_instance_min_z(i.second); const double shift_z = mo->get_instance_min_z(i.second);
// leave sinking instances as sinking // leave sinking instances as sinking
if ((min_zs.find({i.first, i.second})->second >= SINKING_Z_THRESHOLD || shift_z > SINKING_Z_THRESHOLD) && (shift_z != 0.0f)) { if (mo->auto_drop && (min_zs.find({i.first, i.second})->second >= SINKING_Z_THRESHOLD || shift_z > SINKING_Z_THRESHOLD) && (shift_z != 0.0f)) {
const Vec3d shift(0.0, 0.0, -shift_z); const Vec3d shift(0.0, 0.0, -shift_z);
m_selection.translate(i.first, i.second, shift); m_selection.translate(i.first, i.second, shift);
mo->translate_instance(i.second, shift); mo->translate_instance(i.second, shift);
@@ -5865,14 +5857,10 @@ void GLCanvas3D::do_scale(const std::string& snapshot_type)
for (const std::pair<int, int>& i : done) { for (const std::pair<int, int>& i : done) {
ModelObject* mo = m_model->objects[i.first]; ModelObject* mo = m_model->objects[i.first];
if (!mo->auto_drop) {
continue;
}
//BBS: don't call translate if the z is zero //BBS: don't call translate if the z is zero
double shift_z = mo->get_instance_min_z(i.second); double shift_z = mo->get_instance_min_z(i.second);
// leave sinking instances as sinking // leave sinking instances as sinking
if ((min_zs.empty() || min_zs.find({ i.first, i.second })->second >= SINKING_Z_THRESHOLD || shift_z > SINKING_Z_THRESHOLD) && (shift_z != 0.0f)) { if (mo->auto_drop && (min_zs.empty() || min_zs.find({ i.first, i.second })->second >= SINKING_Z_THRESHOLD || shift_z > SINKING_Z_THRESHOLD) && (shift_z != 0.0f)) {
Vec3d shift(0.0, 0.0, -shift_z); Vec3d shift(0.0, 0.0, -shift_z);
m_selection.translate(i.first, i.second, shift); m_selection.translate(i.first, i.second, shift);
mo->translate_instance(i.second, shift); mo->translate_instance(i.second, shift);
@@ -5985,14 +5973,10 @@ void GLCanvas3D::do_mirror(const std::string& snapshot_type)
for (const std::pair<int, int>& i : done) { for (const std::pair<int, int>& i : done) {
ModelObject* mo = m_model->objects[i.first]; ModelObject* mo = m_model->objects[i.first];
if (!mo->auto_drop) {
continue;
}
//BBS: don't call translate if the z is zero //BBS: don't call translate if the z is zero
double shift_z = mo->get_instance_min_z(i.second); double shift_z = mo->get_instance_min_z(i.second);
// leave sinking instances as sinking // leave sinking instances as sinking
if ((min_zs.empty() || min_zs.find({ i.first, i.second })->second >= SINKING_Z_THRESHOLD || shift_z > SINKING_Z_THRESHOLD)&&(shift_z != 0.0f)) { if (mo->auto_drop && (min_zs.empty() || min_zs.find({ i.first, i.second })->second >= SINKING_Z_THRESHOLD || shift_z > SINKING_Z_THRESHOLD) && (shift_z != 0.0f)) {
Vec3d shift(0.0, 0.0, -shift_z); Vec3d shift(0.0, 0.0, -shift_z);
m_selection.translate(i.first, i.second, shift); m_selection.translate(i.first, i.second, shift);
mo->translate_instance(i.second, shift); mo->translate_instance(i.second, shift);
+6 -2
View File
@@ -1618,7 +1618,9 @@ void ObjectList::list_manipulation(const wxPoint& mouse_pos, bool evt_context_me
get_selected_item_indexes(obj_idx, vol_idx, item); get_selected_item_indexes(obj_idx, vol_idx, item);
if (obj_idx != -1) { if (obj_idx != -1) {
Plater::TakeSnapshot snapshot(plater, _u8L("Shift objects to bed")); Plater::TakeSnapshot snapshot(plater, _u8L("Shift objects to bed"));
(*m_objects)[obj_idx]->ensure_on_bed(); // Shift on request also when auto drop is off, which ensure_on_bed() skips.
ModelObject* mo = (*m_objects)[obj_idx];
mo->translate_instances(-mo->min_z() * Vec3d::UnitZ());
cnv->reload_scene(true, true); cnv->reload_scene(true, true);
update_info_items(obj_idx); update_info_items(obj_idx);
notify_instance_updated(obj_idx); notify_instance_updated(obj_idx);
@@ -6734,7 +6736,9 @@ void ObjectList::OnEditingStarted(wxDataViewEvent &event)
Plater::TakeSnapshot snapshot(plater, _u8L("Shift objects to bed")); Plater::TakeSnapshot snapshot(plater, _u8L("Shift objects to bed"));
int obj_idx, vol_idx; int obj_idx, vol_idx;
get_selected_item_indexes(obj_idx, vol_idx, item); get_selected_item_indexes(obj_idx, vol_idx, item);
(*m_objects)[obj_idx]->ensure_on_bed(); // Shift on request also when auto drop is off, which ensure_on_bed() skips.
ModelObject* mo = (*m_objects)[obj_idx];
mo->translate_instances(-mo->min_z() * Vec3d::UnitZ());
cnv->reload_scene(true, true); cnv->reload_scene(true, true);
update_info_items(obj_idx); update_info_items(obj_idx);
notify_instance_updated(obj_idx); notify_instance_updated(obj_idx);
+6 -23
View File
@@ -579,29 +579,12 @@ void Selection::drop()
wxGetApp().plater()->take_snapshot(L("Move object")); wxGetApp().plater()->take_snapshot(L("Move object"));
this->move_to_center(Vec3d(0, 0, -min_z)); // do_move() writes every volume back to the model and refreshes the view.
setup_cache();
for (unsigned int i : m_list) { TransformationType transformation_type;
GLVolume& volume = *(*m_volumes)[i]; transformation_type.set_relative();
// Skip the wipe tower: its synthetic id (>= 1000) is not an index into m_model->objects. translate(Vec3d(0, 0, -min_z), transformation_type);
if (volume.object_idx() >= 1000) wxGetApp().plater()->get_view3D_canvas3D()->do_move("");
continue;
ModelObject* model_object = m_model->objects[volume.object_idx()];
if (model_object != nullptr) {
if (m_mode == Volume) {
ModelVolume* cur_mv = model_object->volumes[volume.volume_idx()];
cur_mv->set_transformation(volume.get_volume_transformation());
} else if (m_mode == Instance) {
model_object->instances[volume.instance_idx()]->set_transformation(volume.get_instance_transformation());
}
model_object->invalidate_bounding_box();
this->notify_instance_update(volume.object_idx(), volume.instance_idx());
}
}
wxGetApp().plater()->get_view3D_canvas3D()->post_event(SimpleEvent(EVT_GLCANVAS_INSTANCE_MOVED));
} }
void Selection::center_plate(const int plate_idx) { void Selection::center_plate(const int plate_idx) {
+7 -3
View File
@@ -350,9 +350,13 @@ bool SimplyPrint::do_temp_upload(const boost::filesystem::path& file_path,
if (should_open_in_external_browser()) { if (should_open_in_external_browser()) {
wxLaunchDefaultBrowser(url); wxLaunchDefaultBrowser(url);
} else { } else {
const auto mainframe = GUI::wxGetApp().mainframe; // The tab switch queues a reload of the printer's home page, so switch synchronously
mainframe->request_select_tab(TAB_ID_MONITOR); // first and queue the import page after it.
mainframe->load_printer_url(url); GUI::wxGetApp().CallAfter([url = wxString::FromUTF8(url)] {
const auto mainframe = GUI::wxGetApp().mainframe;
mainframe->select_tab(TAB_ID_MONITOR);
mainframe->load_printer_url(url);
});
} }
return true; return true;
+93 -74
View File
@@ -183,52 +183,47 @@ static const char* category_name(AuditEventCategory category)
// Path safety // Path safety
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
namespace { bool is_inside_allowed_root(const boost::filesystem::path& candidate, const boost::filesystem::path& allowed_root)
// Canonicalize a path for containment comparison. weakly_canonical resolves symlinks but does
// NOT require the path to exist — it canonicalizes the prefix that exists and appends the
// non-existing tail lexically.
boost::filesystem::path canonical_for_comparison(const boost::filesystem::path& path)
{ {
namespace fs = boost::filesystem; namespace fs = boost::filesystem;
boost::system::error_code ec; boost::system::error_code ec;
fs::path canon = fs::weakly_canonical(path, ec);
// Canonicalize both paths. weakly_canonical resolves symlinks but does
// NOT require the path to exist — it canonicalizes the prefix that exists
// and appends the non-existing tail lexically.
fs::path canon_candidate = fs::weakly_canonical(candidate, ec);
if (ec) { if (ec) {
// Fall back to lexically_normal + absolute // Fall back to lexically_normal + absolute
canon = fs::absolute(path, ec).lexically_normal(); canon_candidate = fs::absolute(candidate, ec).lexically_normal();
if (ec) if (ec)
canon = path; canon_candidate = candidate;
} }
return canon;
}
bool same_path_component(const boost::filesystem::path& lhs, const boost::filesystem::path& rhs) fs::path canon_root = fs::weakly_canonical(allowed_root, ec);
{ if (ec) {
canon_root = fs::absolute(allowed_root, ec).lexically_normal();
if (ec)
canon_root = allowed_root;
}
// Component-wise comparison: the root must be a prefix of candidate,
// and the next component must not be ".." or missing.
auto cand_it = canon_candidate.begin();
auto cand_end = canon_candidate.end();
auto root_it = canon_root.begin();
auto root_end = canon_root.end();
const auto same_component = [](const fs::path& lhs, const fs::path& rhs) {
#ifdef _WIN32 #ifdef _WIN32
return boost::algorithm::iequals(lhs.native(), rhs.native()); return boost::algorithm::iequals(lhs.native(), rhs.native());
#else #else
return lhs == rhs; return lhs == rhs;
#endif #endif
} };
// Component-wise containment: the whole of canonical_root must be a prefix of
// canonical_candidate, and the remaining components must not traverse upward. Both arguments are
// expected to come from canonical_for_comparison(). On a match, suffix receives the candidate's
// components below the root (empty when the two paths are equal).
bool suffix_inside_root(const boost::filesystem::path& canonical_candidate,
const boost::filesystem::path& canonical_root,
boost::filesystem::path& suffix)
{
suffix.clear();
auto cand_it = canonical_candidate.begin();
auto cand_end = canonical_candidate.end();
auto root_it = canonical_root.begin();
auto root_end = canonical_root.end();
// Consume matching components // Consume matching components
while (root_it != root_end && cand_it != cand_end && same_path_component(*root_it, *cand_it)) { while (root_it != root_end && cand_it != cand_end && same_component(*root_it, *cand_it)) {
++root_it; ++root_it;
++cand_it; ++cand_it;
} }
@@ -243,19 +238,9 @@ bool suffix_inside_root(const boost::filesystem::path& canonical_candidate,
return false; return false;
} }
for (; cand_it != cand_end; ++cand_it)
suffix /= *cand_it;
return true; return true;
} }
} // namespace
bool is_inside_allowed_root(const boost::filesystem::path& candidate, const boost::filesystem::path& allowed_root)
{
boost::filesystem::path suffix;
return suffix_inside_root(canonical_for_comparison(candidate), canonical_for_comparison(allowed_root), suffix);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ScopedPluginAuditContext // ScopedPluginAuditContext
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -368,16 +353,18 @@ void PluginAuditManager::add_denied_filename(const std::string& filename)
std::vector<std::string> PluginAuditManager::default_denied_filenames() std::vector<std::string> PluginAuditManager::default_denied_filenames()
{ {
// AppConfig::config_path() picks .conf vs .ini on the USE_JSON_CONFIG ifdef and the app key // AppConfig::config_path() picks .conf vs .ini on the USE_JSON_CONFIG ifdef and the app key
// on its mode, and is a non-static member we cannot call without an instance. Denying all // on its mode, and is a non-static member we cannot call without an instance. Denying all
// four names is cheaper and more robust than replicating that; the two unused names cost one // four names is cheaper and more robust than replicating that; the two unused names cost one
// string comparison each. Single-sourced here so install_hook() and the tests seed from the // string comparison each. Keep the plugin install-state sidecar here too: it controls which
// same list and cannot drift apart. // packages load on startup and must remain host-managed. Single-sourced so install_hook() and
// the tests seed from the same list and cannot drift apart.
return { return {
SLIC3R_APP_KEY ".conf", SLIC3R_APP_KEY ".conf",
GCODEVIEWER_APP_KEY ".conf", GCODEVIEWER_APP_KEY ".conf",
SLIC3R_APP_KEY ".ini", SLIC3R_APP_KEY ".ini",
GCODEVIEWER_APP_KEY ".ini", GCODEVIEWER_APP_KEY ".ini",
secret_constants::USER_SECRET_FILENAME, secret_constants::USER_SECRET_FILENAME,
".install_state.json",
}; };
} }
@@ -425,8 +412,7 @@ std::vector<std::string> PluginAuditManager::default_denied_path_keywords()
// it happens to live inside an otherwise-allowed root (e.g. the bundled TLS client cert at // it happens to live inside an otherwise-allowed root (e.g. the bundled TLS client cert at
// resources_dir()/cert/..., which would become reachable the moment resources_dir() is // resources_dir()/cert/..., which would become reachable the moment resources_dir() is
// granted as a read-only allowed root). Match as whole path components, not substrings, so // granted as a read-only allowed root). Match as whole path components, not substrings, so
// imports such as numpy/__config__.py and stdlib configparser.py remain usable. Inside // imports such as numpy/__config__.py and stdlib configparser.py remain usable.
// data_dir() they match below that root only; see is_denied_path_keyword().
return {"secret", "secrets", "cert", "certs", "certificate", "certificates", "conf", "config"}; return {"secret", "secrets", "cert", "certs", "certificate", "certificates", "conf", "config"};
} }
@@ -445,29 +431,19 @@ bool PluginAuditManager::is_denied_path_keyword(const boost::filesystem::path& c
{ {
namespace fs = boost::filesystem; namespace fs = boost::filesystem;
const fs::path canon = canonical_for_comparison(candidate); boost::system::error_code ec;
fs::path canon = fs::weakly_canonical(candidate, ec);
// The app's data folder can itself sit below a component named like one of the keywords if (ec) {
// without being one: Flatpak keeps it under <home>/.var/app/<app-id>/config/OrcaSlicer, and canon = fs::absolute(candidate, ec).lexically_normal();
// the sandbox's home layout is not ours to change. A keyword above data_dir() therefore says if (ec)
// nothing about what a plugin may reach inside it, so inside data_dir() the keywords apply to canon = candidate;
// the suffix below data_dir() only. Everywhere else -- resources_dir() with the bundled }
// certificates, a plugin's own folders, arbitrary files -- the whole path is still checked,
// and so is a keyword under data_dir() itself.
fs::path canonical_data_dir;
if (!data_dir().empty())
canonical_data_dir = canonical_for_comparison(data_dir());
fs::path scope = canon;
fs::path suffix;
if (!canonical_data_dir.empty() && suffix_inside_root(canon, canonical_data_dir, suffix))
scope = suffix;
std::lock_guard<std::mutex> lock(m_mutex); std::lock_guard<std::mutex> lock(m_mutex);
if (m_denied_path_keywords.empty()) if (m_denied_path_keywords.empty())
return false; return false;
for (const auto& component : scope) { for (const auto& component : canon) {
std::string name = component.string(); std::string name = component.string();
if (name.empty()) if (name.empty())
continue; continue;
@@ -524,6 +500,12 @@ AuditDecision PluginAuditManager::check_path_access(const boost::filesystem::pat
candidate = absolute_candidate; candidate = absolute_candidate;
} }
// Importing Python modules reads their source and may inspect package directories. These
// reads are normal interpreter work, not plugin data access; keep the exemption read-only
// and exclude third-party packages from the stdlib roots.
if (!is_write && is_stdlib_path(candidate))
return {true, ""};
// A root that doesn't allow writes only matches a read-shaped request; a write/create/ // A root that doesn't allow writes only matches a read-shaped request; a write/create/
// delete-shaped one falls through to "outside allowed root" for that root even though the // delete-shaped one falls through to "outside allowed root" for that root even though the
// path is physically inside it. // path is physically inside it.
@@ -1041,6 +1023,22 @@ void persist_grant(const std::string& plugin_key, AuditEventCategory category, b
static PluginAuditManager::PermissionPrompt s_permission_prompt_for_testing; static PluginAuditManager::PermissionPrompt s_permission_prompt_for_testing;
static const char* audit_category_name(AuditEventCategory category)
{
switch (category) {
case AuditEventCategory::FsRead: return "FsRead";
case AuditEventCategory::FsReadWrite: return "FsReadWrite";
case AuditEventCategory::FsCreate: return "FsCreate";
case AuditEventCategory::FsDelete: return "FsDelete";
case AuditEventCategory::Http: return "Http";
case AuditEventCategory::Socket: return "Socket";
case AuditEventCategory::ProcessCreate: return "ProcessCreate";
case AuditEventCategory::ProcessReplace: return "ProcessReplace";
case AuditEventCategory::Threading: return "Threading";
default: return "None";
}
}
// Asks the user to grant an audited event, returning true when they approve an inline prompt. // 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 // `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. // no target (by_event), which the prompt does not show as a target.
@@ -1058,6 +1056,16 @@ bool request_permission(AuditEventCategory category, const std::string& plugin_k
const std::string& event_name, bool by_event, const std::vector<std::string>& keys) 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; const std::vector<std::string> shown = by_event ? std::vector<std::string>{} : keys;
std::string targets_text = by_event ? "<none>" : "";
for (const auto& target : shown) {
if (!targets_text.empty())
targets_text += ", ";
targets_text += target;
}
BOOST_LOG_TRIVIAL(info) << "[AUDIT PROMPT] plugin=" << plugin_key << " event=" << event_name
<< " category=" << audit_category_name(category) << " targets=[" << targets_text << "]"
<< (by_event && !keys.empty() ? " grant=" + keys.front() : std::string{});
if (s_permission_prompt_for_testing) if (s_permission_prompt_for_testing)
return s_permission_prompt_for_testing(event_name, shown); return s_permission_prompt_for_testing(event_name, shown);
@@ -1224,6 +1232,18 @@ bool PluginAuditManager::is_stdlib_file(const std::string& filename)
return it->second; return it->second;
} }
bool PluginAuditManager::is_stdlib_path(const boost::filesystem::path& path)
{
if (path.empty())
return false;
const auto inside_any = [&path](const std::vector<boost::filesystem::path>& dirs) {
return std::any_of(dirs.begin(), dirs.end(),
[&path](const boost::filesystem::path& dir) { return is_inside_allowed_root(path, dir); });
};
return inside_any(PluginAuditDetail::s_stdlib_dirs) && !inside_any(PluginAuditDetail::s_site_package_dirs);
}
void PluginAuditManager::release_frames(std::vector<PyObject*>& frames) void PluginAuditManager::release_frames(std::vector<PyObject*>& frames)
{ {
if (frames.empty()) if (frames.empty())
@@ -1292,7 +1312,7 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user
void PluginAuditManager::install_hook() void PluginAuditManager::install_hook()
{ {
// Tells the stdlib's own frames apart, for the operation-root frames (see record_root_frame). // Identifies stdlib frames for operation roots and stdlib paths for read-only access.
PluginAuditDetail::collect_stdlib_dirs(); 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
@@ -1309,8 +1329,9 @@ void PluginAuditManager::install_hook()
// "cert" denied-path keyword seeded below. // "cert" denied-path keyword seeded below.
add_global_allowed_root(resources_dir(), /*allow_write=*/false); add_global_allowed_root(resources_dir(), /*allow_write=*/false);
// The user's app config and cloud credentials live directly inside data_dir(), so the // The user's app config and cloud credentials live directly inside data_dir(), and plugin
// root just granted would otherwise expose them to any plugin. Deny them by name. // install state lives beneath it, so the root just granted would otherwise expose them to
// plugins. Deny them by name.
// //
// Seeded here rather than by each secret's owner because install_hook() runs during lazy // Seeded here rather than by each secret's owner because install_hook() runs during lazy
// interpreter init and therefore provably precedes any plugin bytecode, whereas // interpreter init and therefore provably precedes any plugin bytecode, whereas
@@ -1321,12 +1342,10 @@ void PluginAuditManager::install_hook()
for (const auto& name : default_denied_filenames()) for (const auto& name : default_denied_filenames())
add_denied_filename(name); add_denied_filename(name);
// Categorical denies on top of the exact-name list above: a path a plugin reaches must not // Categorical denies on top of the exact-name list above: no path a plugin can reach may
// have a "secret", "cert"(ificate), or "conf"(ig) component below data_dir() -- or anywhere // contain a "secret", "cert"(ificate), or "conf"(ig) path component, regardless of which
// in the path outside data_dir(), whose own location may legitimately contain one (Flatpak // allowed root it happens to sit inside. default_denied_path_keywords() is the single
// keeps it under .../config/OrcaSlicer). This holds regardless of which allowed root the // source of this list; the tests seed from it too.
// path happens to sit inside. default_denied_path_keywords() is the single source of this
// list; the tests seed from it too.
for (const auto& keyword : default_denied_path_keywords()) for (const auto& keyword : default_denied_path_keywords())
add_denied_path_keyword(keyword); add_denied_path_keyword(keyword);
+10 -9
View File
@@ -102,13 +102,12 @@ public:
bool is_denied_filename(const boost::filesystem::path& candidate) const; bool is_denied_filename(const boost::filesystem::path& candidate) const;
// --- denied-path-keyword registry --- // --- denied-path-keyword registry ---
// Keywords that categorically deny a path when a component matches one case-insensitively -- // Keywords that categorically deny a path if ANY component matches one case-insensitively --
// e.g. a "secrets" subfolder, a "certificates" folder, a "conf"/"config" directory, or a // e.g. a "secrets" subfolder, a "certificates" folder, a "conf"/"config" directory, or a
// .conf/.ini file anywhere the plugin can otherwise reach, including inside an allowed root. // .conf/.ini file anywhere the plugin can otherwise reach, including inside an allowed root.
// The components above the app's data_dir() are not considered (see below). This is broader // This is broader than the exact-name is_denied_filename registry, but it is not a substring
// than the exact-name is_denied_filename registry, but it is not a substring match: // match: importable modules such as numpy/__config__.py, configparser.py, sysconfig.py, or
// importable modules such as numpy/__config__.py, configparser.py, sysconfig.py, or user // user folders such as "Conference" and "Concert" are unrelated names and must stay promptable.
// folders such as "Conference" and "Concert" are unrelated names and must stay promptable.
void add_denied_path_keyword(const std::string& keyword); void add_denied_path_keyword(const std::string& keyword);
// The list install_hook() seeds into the keyword registry. Exposed so tests seed the exact // The list install_hook() seeds into the keyword registry. Exposed so tests seed the exact
@@ -116,10 +115,7 @@ public:
static std::vector<std::string> default_denied_path_keywords(); static std::vector<std::string> default_denied_path_keywords();
// True when any component of candidate's (canonicalized) path matches a registered keyword, // True when any component of candidate's (canonicalized) path matches a registered keyword,
// case-insensitively. Inside data_dir(), only the components below data_dir() are checked: // case-insensitively. A registered "conf" keyword also blocks .conf/.ini file components.
// the app's own data folder may itself sit under a component named like a keyword (Flatpak
// keeps it under .../config/OrcaSlicer), which must not deny everything inside it. A
// registered "conf" keyword also blocks .conf/.ini file components.
bool is_denied_path_keyword(const boost::filesystem::path& candidate) const; bool is_denied_path_keyword(const boost::filesystem::path& candidate) const;
// is_denied_filename(candidate) || is_denied_path_keyword(candidate). Convenience for // is_denied_filename(candidate) || is_denied_path_keyword(candidate). Convenience for
@@ -177,6 +173,11 @@ 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);
// True for paths inside the interpreter's stdlib, excluding site-packages. Unlike
// is_stdlib_file(), this query is not cached because filesystem paths may resolve through
// symlinks that can change.
static bool is_stdlib_path(const boost::filesystem::path& path);
// Drops the references in `frames`, keeping the caller's error and denial state intact. // Drops the references in `frames`, keeping the caller's error and denial state intact.
void release_frames(std::vector<PyObject*>& frames); void release_frames(std::vector<PyObject*>& frames);
+48 -62
View File
@@ -69,7 +69,7 @@ void seed_denied_keywords()
} // namespace } // namespace
TEST_CASE("Plugin audit denies app config and token filenames anywhere", "[audit]") TEST_CASE("Plugin audit denies host-managed filenames anywhere", "[audit]")
{ {
seed_denied_names(); seed_denied_names();
const PluginAuditManager& mgr = PluginAuditManager::instance(); const PluginAuditManager& mgr = PluginAuditManager::instance();
@@ -81,6 +81,7 @@ TEST_CASE("Plugin audit denies app config and token filenames anywhere", "[audit
CHECK(mgr.is_denied_filename(fs::path(SLIC3R_APP_KEY ".ini"))); CHECK(mgr.is_denied_filename(fs::path(SLIC3R_APP_KEY ".ini")));
CHECK(mgr.is_denied_filename(fs::path(GCODEVIEWER_APP_KEY ".ini"))); CHECK(mgr.is_denied_filename(fs::path(GCODEVIEWER_APP_KEY ".ini")));
CHECK(mgr.is_denied_filename(fs::path(secret_constants::USER_SECRET_FILENAME))); CHECK(mgr.is_denied_filename(fs::path(secret_constants::USER_SECRET_FILENAME)));
CHECK(mgr.is_denied_filename(fs::path(".install_state.json")));
} }
SECTION("companions holding the same secrets are denied by the prefix rule") SECTION("companions holding the same secrets are denied by the prefix rule")
@@ -136,6 +137,7 @@ TEST_CASE("Plugin audit deny beats allowed roots", "[audit]")
const fs::path conf = fs::path(data_dir()) / (SLIC3R_APP_KEY ".conf"); const fs::path conf = fs::path(data_dir()) / (SLIC3R_APP_KEY ".conf");
const fs::path token = fs::path(data_dir()) / secret_constants::USER_SECRET_FILENAME; const fs::path token = fs::path(data_dir()) / secret_constants::USER_SECRET_FILENAME;
const fs::path install_state = fs::path(data_dir()) / "plugins" / "example" / ".install_state.json";
SECTION("a non-denied file inside the allowed root is writable (root really grants writes)") SECTION("a non-denied file inside the allowed root is writable (root really grants writes)")
{ {
@@ -176,6 +178,17 @@ TEST_CASE("Plugin audit deny beats allowed roots", "[audit]")
CHECK_FALSE(decision.allowed); CHECK_FALSE(decision.allowed);
} }
SECTION("plugin install state is blocked for reads and writes despite the allowed root")
{
AuditDecision read_decision = mgr.check_open(install_state.string(), "r");
CHECK_FALSE(read_decision.allowed);
CHECK(read_decision.reason == "denied filename");
AuditDecision write_decision = mgr.check_open(install_state.string(), "w");
CHECK_FALSE(write_decision.allowed);
CHECK(write_decision.reason == "denied filename");
}
SECTION("a traversal path resolving to the config is blocked") SECTION("a traversal path resolving to the config is blocked")
{ {
const fs::path traversal = fs::path(data_dir()) / "plugins" / ".." / (SLIC3R_APP_KEY ".conf"); const fs::path traversal = fs::path(data_dir()) / "plugins" / ".." / (SLIC3R_APP_KEY ".conf");
@@ -288,67 +301,6 @@ TEST_CASE("Plugin audit denies secret/certificate/config-like paths by keyword",
} }
} }
TEST_CASE("Plugin audit checks denied keywords below data_dir, not in the directories above it", "[PluginAudit]")
{
// Flatpak keeps the app's data folder under <home>/.var/app/<app-id>/config/OrcaSlicer, so
// data_dir() itself has a "config" component. That ancestor describes the sandbox's layout,
// not what the folder holds, and must not hard-deny every path inside it.
ScopedDataDir data_dir_guard("plugin-audit-data-dir-keyword");
const fs::path flatpak_data_dir = data_dir_guard.dir / "config" / "OrcaSlicer";
set_data_dir(flatpak_data_dir.string());
seed_denied_names();
seed_denied_keywords();
PluginAuditManager& mgr = PluginAuditManager::instance();
// Reproduce install_hook()'s grant: data_dir() is a global allowed root.
mgr.add_global_allowed_root(data_dir());
ScopedPluginAuditContext ctx("test_plugin", "");
SECTION("the root and the paths below it that carry no keyword are allowed")
{
CHECK_FALSE(mgr.is_denied_path_keyword(flatpak_data_dir / "orca_plugins" / "state.json"));
CHECK(mgr.check_open(data_dir(), "r").allowed);
CHECK(mgr.check_open((flatpak_data_dir / "orca_plugins" / "test_plugin" / "state.json").string(), "r").allowed);
CHECK(mgr.check_open((flatpak_data_dir / "orca_plugins" / "test_plugin" / "state.json").string(), "w").allowed);
CHECK(mgr.check_open((flatpak_data_dir / "system" / "profiles" / "vendor.json").string(), "r").allowed);
}
SECTION("a 'secrets' component below data_dir() is still denied")
{
AuditDecision decision = mgr.check_open((flatpak_data_dir / "secrets" / "token.json").string(), "r");
CHECK_FALSE(decision.allowed);
CHECK(decision.reason == "denied path keyword");
}
SECTION("a 'config' component below data_dir() is still denied")
{
AuditDecision decision = mgr.check_open((flatpak_data_dir / "config" / "settings.json").string(), "r");
CHECK_FALSE(decision.allowed);
CHECK(decision.reason == "denied path keyword");
}
SECTION("the app config below data_dir() is still denied by name")
{
AuditDecision decision = mgr.check_open((flatpak_data_dir / (SLIC3R_APP_KEY ".conf")).string(), "r");
CHECK_FALSE(decision.allowed);
CHECK(decision.reason == "denied filename");
}
SECTION("an unrelated resources root keeps the full-path keyword check")
{
ScopedResourcesDir resources_dir_guard("plugin-audit-data-dir-keyword-resources");
mgr.add_global_allowed_root(resources_dir(), /*allow_write=*/false);
CHECK(mgr.check_open((fs::path(resources_dir()) / "profiles" / "vendor.json").string(), "r").allowed);
AuditDecision decision = mgr.check_open((fs::path(resources_dir()) / "cert" / "slicer_base64.cer").string(), "r");
CHECK_FALSE(decision.allowed);
CHECK(decision.reason == "denied path keyword");
}
}
TEST_CASE("Plugin audit is_denied_path combines the filename and keyword registries", "[audit]") TEST_CASE("Plugin audit is_denied_path combines the filename and keyword registries", "[audit]")
{ {
seed_denied_names(); seed_denied_names();
@@ -1180,3 +1132,37 @@ TEST_CASE("Stdlib code is recognized through a symlinked Python home", "[audit][
CHECK_FALSE(PluginAuditManager::is_stdlib_file((harness.outside.path() / "plugin.py").string())); CHECK_FALSE(PluginAuditManager::is_stdlib_file((harness.outside.path() / "plugin.py").string()));
CHECK_FALSE(PluginAuditManager::is_stdlib_file("<string>")); CHECK_FALSE(PluginAuditManager::is_stdlib_file("<string>"));
} }
TEST_CASE("A plugin may read the CPython stdlib without a prompt, but not site-packages or for writing", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
py::gil_scoped_acquire gil;
PythonSide python(harness);
const py::dict paths = py::module_::import("sysconfig").attr("get_paths")();
const fs::path stdlib = py::str(paths["stdlib"]).cast<std::string>();
const fs::path purelib = py::str(paths["purelib"]).cast<std::string>();
// os.py ships with every CPython installation. A site-packages path is third-party code even
// when the directory is empty: the prompt must come before the missing file is noticed.
const fs::path stdlib_file = stdlib / "os.py";
REQUIRE(fs::exists(stdlib_file));
python.globals["STDLIB_FILE"] = stdlib_file.string();
python.globals["SITE_PACKAGE_FILE"] = (purelib / "regression_probe.py").string();
python.run_as(PLUGIN, R"PY(
read_outcome = attempt(lambda: open(STDLIB_FILE).read())
write_outcome = attempt(lambda: open(STDLIB_FILE, "r+").close())
site_packages_outcome = attempt(lambda: open(SITE_PACKAGE_FILE).close())
)PY");
CHECK(python.str("read_outcome") == "ok");
CHECK(python.str("write_outcome") == "denied");
CHECK(python.str("site_packages_outcome") == "denied");
// Only the write and the site-packages read are asked about; the stdlib read is implicit.
const std::string write_prompt = "open: " + python.str("STDLIB_FILE") + "\n";
const std::string site_packages_prompt = "open: " + python.str("SITE_PACKAGE_FILE") + "\n";
CHECK(describe(harness.prompts) == write_prompt + site_packages_prompt);
}