Compare commits

..
Author SHA1 Message Date
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
6 changed files with 185 additions and 153 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 )
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 " -a: Set ARCHITECTURE (arm64 or x86_64 or universal)"
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 " -j: Set the number of parallel build jobs (CMAKE_BUILD_PARALLEL_LEVEL)"
echo " -T: Build and run tests (set ORCA_TESTS_BUILD_ONLY=1 to build without running)"
echo " -- CMAKE_ARGS...: pass everything after '--' to the cmake configure step of the deps (-d) and slicer (-s) builds,"
echo " e.g. -DDESTDIR=<dir> for the deps, -DCMAKE_PREFIX_PATH=<dir>/usr/local -DSLIC3R_PCH=OFF for the slicer, --fresh."
echo " They come last on the cmake command line. Use absolute paths: cmake runs in the build directory."
exit 0
;;
: )
echo "Option -${OPTARG} requires an argument, aborting."
exit 1
;;
* )
echo "Unknown option -${OPTARG}, aborting. CMake arguments must follow '--'."
exit 1
;;
esac
done
# getopts stops at '--'; whatever follows is handed to cmake unchanged.
if [[ ${OPTIND} -le $# ]] ; then
separator_index=$((OPTIND-1))
if [[ "${!separator_index}" != "--" ]] ; then
echo "Unexpected argument '${!OPTIND}': cmake arguments must follow '--', aborting."
exit 1
fi
fi
shift $((OPTIND-1))
EXTRA_CMAKE_ARGS=("$@")
# Set defaults
if [ -z "$ARCH" ]; then
@@ -211,7 +231,8 @@ function build_deps() {
-DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \
-DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \
"${CMAKE_CCACHE_ARGS[@]}" \
${CMAKE_POLICY_COMPAT}
${CMAKE_POLICY_COMPAT} \
"${EXTRA_CMAKE_ARGS[@]}"
fi
cmake --build . --config "$BUILD_CONFIG" --target deps
)
@@ -314,7 +335,8 @@ function build_slicer() {
-DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \
-DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \
"${CMAKE_CCACHE_ARGS[@]}" \
${CMAKE_POLICY_COMPAT}
${CMAKE_POLICY_COMPAT} \
"${EXTRA_CMAKE_ARGS[@]}"
fi
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})
endif ()
# 6.3.0 is the first release whose arm64 assembly avoids x18, which macOS
# reserves and zeroes on every return to user space; 6.2.1 crashes on Apple
# silicon whenever CGAL falls back to exact arithmetic.
ExternalProject_Add(dep_GMP
URL https://github.com/SoftFever/OrcaSlicer_deps/releases/download/gmp-6.2.1/gmp-6.2.1.tar.bz2
URL_HASH SHA256=eae9326beb4158c386e39a356818031bd28f3124cf915f8c5b1dc4c7a36b4d7c
URL https://ftp.gnu.org/gnu/gmp/gmp-6.3.0.tar.xz
https://github.com/SoftFever/OrcaSlicer_deps/releases/download/gmp-6.3.0/gmp-6.3.0.tar.xz
URL_HASH SHA256=a3c2b80201b89e68616f4ad30bc66aee4927c3ce50e33929ca819d5c43538898
DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/GMP
PATCH_COMMAND git apply ${GMP_DIRECTORY_FLAG} --verbose ${CMAKE_CURRENT_LIST_DIR}/0001-GMP_GCC15.patch
BUILD_IN_SOURCE ON
@@ -226,10 +226,10 @@ modules:
sha256: f353e7b99bd0cbfc27ac9082de51acf32a8bc0b3e21ff9661ecca6f205ec1d81
dest: external-packages/OpenVDB
# GMP 6.2.1
# GMP 6.3.0
- type: file
url: https://github.com/SoftFever/OrcaSlicer_deps/releases/download/gmp-6.2.1/gmp-6.2.1.tar.bz2
sha256: eae9326beb4158c386e39a356818031bd28f3124cf915f8c5b1dc4c7a36b4d7c
url: https://ftp.gnu.org/gnu/gmp/gmp-6.3.0.tar.xz
sha256: a3c2b80201b89e68616f4ad30bc66aee4927c3ce50e33929ca819d5c43538898
dest: external-packages/GMP
# MPFR 4.2.2
+93 -74
View File
@@ -183,52 +183,47 @@ static const char* category_name(AuditEventCategory category)
// Path safety
// ---------------------------------------------------------------------------
namespace {
// 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)
bool is_inside_allowed_root(const boost::filesystem::path& candidate, const boost::filesystem::path& allowed_root)
{
namespace fs = boost::filesystem;
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) {
// Fall back to lexically_normal + absolute
canon = fs::absolute(path, ec).lexically_normal();
canon_candidate = fs::absolute(candidate, ec).lexically_normal();
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
return boost::algorithm::iequals(lhs.native(), rhs.native());
return boost::algorithm::iequals(lhs.native(), rhs.native());
#else
return lhs == rhs;
return lhs == rhs;
#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
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;
++cand_it;
}
@@ -243,19 +238,9 @@ bool suffix_inside_root(const boost::filesystem::path& canonical_candidate,
return false;
}
for (; cand_it != cand_end; ++cand_it)
suffix /= *cand_it;
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
// ---------------------------------------------------------------------------
@@ -368,16 +353,18 @@ void PluginAuditManager::add_denied_filename(const std::string& filename)
std::vector<std::string> PluginAuditManager::default_denied_filenames()
{
// AppConfig::config_path() picks .conf vs .ini on the USE_JSON_CONFIG ifdef and the app key
// on its mode, and is a non-static member we cannot call without an instance. Denying all
// on its mode, and is a non-static member we cannot call without an instance. Denying all
// four names is cheaper and more robust than replicating that; the two unused names cost one
// string comparison each. Single-sourced here so install_hook() and the tests seed from the
// same list and cannot drift apart.
// string comparison each. Keep the plugin install-state sidecar here too: it controls which
// packages load on startup and must remain host-managed. Single-sourced so install_hook() and
// the tests seed from the same list and cannot drift apart.
return {
SLIC3R_APP_KEY ".conf",
GCODEVIEWER_APP_KEY ".conf",
SLIC3R_APP_KEY ".ini",
GCODEVIEWER_APP_KEY ".ini",
secret_constants::USER_SECRET_FILENAME,
".install_state.json",
};
}
@@ -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
// 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
// imports such as numpy/__config__.py and stdlib configparser.py remain usable. Inside
// data_dir() they match below that root only; see is_denied_path_keyword().
// imports such as numpy/__config__.py and stdlib configparser.py remain usable.
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;
const fs::path canon = canonical_for_comparison(candidate);
// The app's data folder can itself sit below a component named like one of the keywords
// without being one: Flatpak keeps it under <home>/.var/app/<app-id>/config/OrcaSlicer, and
// the sandbox's home layout is not ours to change. A keyword above data_dir() therefore says
// nothing about what a plugin may reach inside it, so inside data_dir() the keywords apply to
// 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;
boost::system::error_code ec;
fs::path canon = fs::weakly_canonical(candidate, ec);
if (ec) {
canon = fs::absolute(candidate, ec).lexically_normal();
if (ec)
canon = candidate;
}
std::lock_guard<std::mutex> lock(m_mutex);
if (m_denied_path_keywords.empty())
return false;
for (const auto& component : scope) {
for (const auto& component : canon) {
std::string name = component.string();
if (name.empty())
continue;
@@ -524,6 +500,12 @@ AuditDecision PluginAuditManager::check_path_access(const boost::filesystem::pat
candidate = absolute_candidate;
}
// Importing Python modules reads their source and may inspect package directories. These
// reads are normal interpreter work, not plugin data access; keep the exemption read-only
// and exclude third-party packages from the stdlib roots.
if (!is_write && is_stdlib_path(candidate))
return {true, ""};
// A root that doesn't allow writes only matches a read-shaped request; a write/create/
// delete-shaped one falls through to "outside allowed root" for that root even though the
// path is physically inside it.
@@ -1041,6 +1023,22 @@ void persist_grant(const std::string& plugin_key, AuditEventCategory category, b
static PluginAuditManager::PermissionPrompt s_permission_prompt_for_testing;
static const char* audit_category_name(AuditEventCategory category)
{
switch (category) {
case AuditEventCategory::FsRead: return "FsRead";
case AuditEventCategory::FsReadWrite: return "FsReadWrite";
case AuditEventCategory::FsCreate: return "FsCreate";
case AuditEventCategory::FsDelete: return "FsDelete";
case AuditEventCategory::Http: return "Http";
case AuditEventCategory::Socket: return "Socket";
case AuditEventCategory::ProcessCreate: return "ProcessCreate";
case AuditEventCategory::ProcessReplace: return "ProcessReplace";
case AuditEventCategory::Threading: return "Threading";
default: return "None";
}
}
// Asks the user to grant an audited event, returning true when they approve an inline prompt.
// `keys` are what a Yes saves: the unresolved targets, or the event-grant key when the event has
// no target (by_event), which the prompt does not show as a target.
@@ -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::vector<std::string> shown = by_event ? std::vector<std::string>{} : keys;
std::string targets_text = by_event ? "<none>" : "";
for (const auto& target : shown) {
if (!targets_text.empty())
targets_text += ", ";
targets_text += target;
}
BOOST_LOG_TRIVIAL(info) << "[AUDIT PROMPT] plugin=" << plugin_key << " event=" << event_name
<< " category=" << audit_category_name(category) << " targets=[" << targets_text << "]"
<< (by_event && !keys.empty() ? " grant=" + keys.front() : std::string{});
if (s_permission_prompt_for_testing)
return s_permission_prompt_for_testing(event_name, shown);
@@ -1224,6 +1232,18 @@ bool PluginAuditManager::is_stdlib_file(const std::string& filename)
return it->second;
}
bool PluginAuditManager::is_stdlib_path(const boost::filesystem::path& path)
{
if (path.empty())
return false;
const auto inside_any = [&path](const std::vector<boost::filesystem::path>& dirs) {
return std::any_of(dirs.begin(), dirs.end(),
[&path](const boost::filesystem::path& dir) { return is_inside_allowed_root(path, dir); });
};
return inside_any(PluginAuditDetail::s_stdlib_dirs) && !inside_any(PluginAuditDetail::s_site_package_dirs);
}
void PluginAuditManager::release_frames(std::vector<PyObject*>& frames)
{
if (frames.empty())
@@ -1292,7 +1312,7 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user
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();
// 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.
add_global_allowed_root(resources_dir(), /*allow_write=*/false);
// The user's app config and cloud credentials live directly inside data_dir(), so the
// root just granted would otherwise expose them to any plugin. Deny them by name.
// The user's app config and cloud credentials live directly inside data_dir(), and plugin
// install state lives beneath it, so the root just granted would otherwise expose them to
// plugins. Deny them by name.
//
// 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
@@ -1321,12 +1342,10 @@ void PluginAuditManager::install_hook()
for (const auto& name : default_denied_filenames())
add_denied_filename(name);
// Categorical denies on top of the exact-name list above: a path a plugin reaches must not
// have a "secret", "cert"(ificate), or "conf"(ig) component below data_dir() -- or anywhere
// in the path outside data_dir(), whose own location may legitimately contain one (Flatpak
// keeps it under .../config/OrcaSlicer). This holds regardless of which allowed root the
// path happens to sit inside. default_denied_path_keywords() is the single source of this
// list; the tests seed from it too.
// Categorical denies on top of the exact-name list above: no path a plugin can reach may
// contain a "secret", "cert"(ificate), or "conf"(ig) path component, regardless of which
// allowed root it 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())
add_denied_path_keyword(keyword);
+10 -9
View File
@@ -102,13 +102,12 @@ public:
bool is_denied_filename(const boost::filesystem::path& candidate) const;
// --- 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
// .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
// than the exact-name is_denied_filename registry, but it is not a substring match:
// importable modules such as numpy/__config__.py, configparser.py, sysconfig.py, or user
// folders such as "Conference" and "Concert" are unrelated names and must stay promptable.
// This is broader than the exact-name is_denied_filename registry, but it is not a substring
// match: importable modules such as numpy/__config__.py, configparser.py, sysconfig.py, or
// user folders such as "Conference" and "Concert" are unrelated names and must stay promptable.
void add_denied_path_keyword(const std::string& keyword);
// 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();
// 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:
// 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.
// case-insensitively. A registered "conf" keyword also blocks .conf/.ini file components.
bool is_denied_path_keyword(const boost::filesystem::path& candidate) const;
// 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);
// True for paths inside the interpreter's stdlib, excluding site-packages. Unlike
// is_stdlib_file(), this query is not cached because filesystem paths may resolve through
// symlinks that can change.
static bool is_stdlib_path(const boost::filesystem::path& path);
// Drops the references in `frames`, keeping the caller's error and denial state intact.
void release_frames(std::vector<PyObject*>& frames);
+48 -62
View File
@@ -69,7 +69,7 @@ void seed_denied_keywords()
} // 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();
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(GCODEVIEWER_APP_KEY ".ini")));
CHECK(mgr.is_denied_filename(fs::path(secret_constants::USER_SECRET_FILENAME)));
CHECK(mgr.is_denied_filename(fs::path(".install_state.json")));
}
SECTION("companions holding the same secrets are denied by the prefix rule")
@@ -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 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)")
{
@@ -176,6 +178,17 @@ TEST_CASE("Plugin audit deny beats allowed roots", "[audit]")
CHECK_FALSE(decision.allowed);
}
SECTION("plugin install state is blocked for reads and writes despite the allowed root")
{
AuditDecision read_decision = mgr.check_open(install_state.string(), "r");
CHECK_FALSE(read_decision.allowed);
CHECK(read_decision.reason == "denied filename");
AuditDecision write_decision = mgr.check_open(install_state.string(), "w");
CHECK_FALSE(write_decision.allowed);
CHECK(write_decision.reason == "denied filename");
}
SECTION("a traversal path resolving to the config is blocked")
{
const fs::path traversal = fs::path(data_dir()) / "plugins" / ".." / (SLIC3R_APP_KEY ".conf");
@@ -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]")
{
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("<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);
}