mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-20 14:08:20 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fd5a3c445 | ||
|
|
069bd402d9 |
No files matched your search
+25
-3
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -425,8 +410,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 +429,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;
|
||||
@@ -554,81 +528,6 @@ AuditDecision PluginAuditManager::check_open(const std::string& path_str, const
|
||||
return check_path_access(boost::filesystem::path(path_str), is_write);
|
||||
}
|
||||
|
||||
bool PluginAuditManager::request_filesystem_read_permissions(const std::string& plugin_key,
|
||||
const std::vector<std::string>& paths)
|
||||
{
|
||||
if (plugin_key.empty() || paths.empty())
|
||||
return true;
|
||||
|
||||
PluginDescriptor descriptor;
|
||||
if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty())
|
||||
return false;
|
||||
|
||||
PluginInstallState state;
|
||||
read_install_state(boost::filesystem::path(descriptor.plugin_root), state);
|
||||
|
||||
std::vector<std::string> missing;
|
||||
for (const std::string& path : paths) {
|
||||
if (std::find(state.permissions.fs_read.begin(), state.permissions.fs_read.end(), path) == state.permissions.fs_read.end())
|
||||
missing.push_back(path);
|
||||
}
|
||||
if (missing.empty())
|
||||
return true;
|
||||
|
||||
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
|
||||
return false;
|
||||
|
||||
auto show_dialog = [&descriptor, &missing]() {
|
||||
wxString requested_paths;
|
||||
for (const std::string& path : missing)
|
||||
requested_paths += wxString::FromUTF8(path.c_str()) + "\n";
|
||||
|
||||
wxMessageDialog dialog(
|
||||
nullptr,
|
||||
wxString::Format(_L("Plugin \"%s\" requests filesystem read access to:\n%s"),
|
||||
wxString::FromUTF8(descriptor.name.c_str()), requested_paths),
|
||||
_L("Plugin permissions"),
|
||||
wxYES_NO | wxICON_WARNING);
|
||||
return dialog.ShowModal() == wxID_YES;
|
||||
};
|
||||
|
||||
bool granted = false;
|
||||
if (wxIsMainThread()) {
|
||||
granted = show_dialog();
|
||||
} else {
|
||||
auto result = std::make_shared<std::promise<bool>>();
|
||||
auto future = result->get_future();
|
||||
GUI::wxGetApp().CallAfter([result, descriptor_name = descriptor.name, missing]() {
|
||||
wxString requested_paths;
|
||||
for (const std::string& path : missing)
|
||||
requested_paths += wxString::FromUTF8(path.c_str()) + "\n";
|
||||
|
||||
wxMessageDialog dialog(
|
||||
nullptr,
|
||||
wxString::Format(_L("Plugin \"%s\" requests filesystem read access to:\n%s"),
|
||||
wxString::FromUTF8(descriptor_name.c_str()), requested_paths),
|
||||
_L("Plugin permissions"),
|
||||
wxYES_NO | wxICON_WARNING);
|
||||
result->set_value(dialog.ShowModal() == wxID_YES);
|
||||
});
|
||||
granted = future.get();
|
||||
}
|
||||
|
||||
if (!granted)
|
||||
return false;
|
||||
|
||||
if (state.plugin_name.empty()) {
|
||||
state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local";
|
||||
state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version;
|
||||
state.plugin_name = descriptor.name;
|
||||
state.cloud_uuid = descriptor.cloud_uuid();
|
||||
state.enabled = true;
|
||||
}
|
||||
|
||||
state.permissions.fs_read.insert(state.permissions.fs_read.end(), missing.begin(), missing.end());
|
||||
return write_install_state(boost::filesystem::path(descriptor.plugin_root), state);
|
||||
}
|
||||
|
||||
void PluginAuditManager::report_violation(const AuditViolation& violation)
|
||||
{
|
||||
m_last_violation = violation;
|
||||
@@ -864,7 +763,7 @@ std::vector<std::string> audit_targets(const std::string& event_name, AuditEvent
|
||||
// Thread creation exposes no user-supplied target. Use a fixed sentinel so the grant
|
||||
// persists per plugin: the permission list matches targets by exact string, and the
|
||||
// started function's repr embeds an address that changes every run.
|
||||
targets.emplace_back("thread");
|
||||
targets.emplace_back(kThreadPermissionTarget);
|
||||
return targets;
|
||||
default:
|
||||
break;
|
||||
@@ -920,6 +819,20 @@ bool has_permission(const std::vector<std::string>& granted, const std::string&
|
||||
return std::find(granted.begin(), granted.end(), target) != granted.end();
|
||||
}
|
||||
|
||||
// A sandbox plugin may not have a sidecar yet. Seed the small amount of install metadata the
|
||||
// writer needs so clicking Yes still creates the JSON file.
|
||||
void seed_install_metadata(const PluginDescriptor& descriptor, PluginInstallState& state)
|
||||
{
|
||||
if (!state.plugin_name.empty())
|
||||
return;
|
||||
|
||||
state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local";
|
||||
state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version;
|
||||
state.plugin_name = descriptor.name;
|
||||
state.cloud_uuid = descriptor.cloud_uuid();
|
||||
state.enabled = true;
|
||||
}
|
||||
|
||||
bool persist_permission(const std::string& plugin_key,
|
||||
PluginInstallState& state,
|
||||
std::vector<std::string>& permission_list,
|
||||
@@ -929,15 +842,7 @@ bool persist_permission(const std::string& plugin_key,
|
||||
if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty())
|
||||
return false;
|
||||
|
||||
// A sandbox plugin may not have a sidecar yet. Seed the small amount of install metadata
|
||||
// needed by the writer so clicking Yes still creates the JSON file.
|
||||
if (state.plugin_name.empty()) {
|
||||
state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local";
|
||||
state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version;
|
||||
state.plugin_name = descriptor.name;
|
||||
state.cloud_uuid = descriptor.cloud_uuid();
|
||||
state.enabled = true;
|
||||
}
|
||||
seed_install_metadata(descriptor, state);
|
||||
|
||||
if (!has_permission(permission_list, target))
|
||||
permission_list.push_back(target);
|
||||
@@ -1023,6 +928,13 @@ bool prompt_for_targets(AuditEventCategory category, const std::string& plugin_n
|
||||
return dialog.ShowModal() == wxID_YES;
|
||||
}
|
||||
|
||||
// Shows a prompt whose body may cover several permission buckets. Must run on the GUI thread.
|
||||
bool prompt_for_permission_message(const wxString& message)
|
||||
{
|
||||
wxMessageDialog dialog(nullptr, message, _L("Plugin permissions"), wxYES_NO | wxICON_WARNING);
|
||||
return dialog.ShowModal() == wxID_YES;
|
||||
}
|
||||
|
||||
// Records a grant in the plugin's sidecar so it is not asked again. Reads the install state
|
||||
// freshly because the async prompt outlives the caller's stack copy of it.
|
||||
void persist_grant(const std::string& plugin_key, AuditEventCategory category, bool by_event, const std::vector<std::string>& keys)
|
||||
@@ -1049,7 +961,7 @@ static PluginAuditManager::PermissionPrompt s_permission_prompt_for_testing;
|
||||
// SlicingPipeline hook runs on the slicing worker thread (see PluginHooks.cpp), and
|
||||
// BackgroundSlicingProcess::stop()/stop_internal() park the UI thread until that worker stops.
|
||||
// Blocking the worker on a marshaled modal -- which is safe for the plugin-load worker that
|
||||
// request_filesystem_read_permissions runs on -- would therefore deadlock the application (the
|
||||
// request_permissions runs on -- would therefore deadlock the application (the
|
||||
// invariant PluginHostUi.cpp documents for slicing-hook UI calls). Off the main thread the prompt
|
||||
// is therefore posted asynchronously and the current event denied (fail closed, like an unanswered
|
||||
// prompt); the grant is persisted once the user accepts, so a later attempt succeeds without
|
||||
@@ -1173,6 +1085,105 @@ int audit_plugin_event(PluginAuditManager& mgr, const std::string& event_name, A
|
||||
|
||||
} // namespace PluginAuditDetail
|
||||
|
||||
bool PluginAuditManager::request_permissions(const std::string& plugin_key, const PluginPermissions& requested)
|
||||
{
|
||||
if (plugin_key.empty())
|
||||
return true;
|
||||
|
||||
PluginDescriptor descriptor;
|
||||
if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty())
|
||||
return false;
|
||||
|
||||
PluginInstallState state;
|
||||
read_install_state(boost::filesystem::path(descriptor.plugin_root), state);
|
||||
|
||||
// Per bucket, keep only the targets that no earlier grant already covers. Dropping these
|
||||
// before prompting keeps a repeated declaration from asking again for what is on disk.
|
||||
struct MissingBucket {
|
||||
AuditEventCategory category;
|
||||
std::vector<std::string> targets;
|
||||
};
|
||||
std::vector<MissingBucket> missing_buckets;
|
||||
|
||||
const auto collect_missing = [&missing_buckets](AuditEventCategory category,
|
||||
const std::vector<std::string>& requested_targets,
|
||||
const std::vector<std::string>& granted_targets) {
|
||||
MissingBucket bucket{category, {}};
|
||||
for (const std::string& target : requested_targets) {
|
||||
if (target.empty())
|
||||
continue;
|
||||
if (std::find(granted_targets.begin(), granted_targets.end(), target) == granted_targets.end() &&
|
||||
std::find(bucket.targets.begin(), bucket.targets.end(), target) == bucket.targets.end())
|
||||
bucket.targets.push_back(target);
|
||||
}
|
||||
if (!bucket.targets.empty())
|
||||
missing_buckets.push_back(std::move(bucket));
|
||||
};
|
||||
|
||||
collect_missing(AuditEventCategory::FsRead, requested.fs_read, state.permissions.fs_read);
|
||||
collect_missing(AuditEventCategory::FsReadWrite, requested.fs_readwrite, state.permissions.fs_readwrite);
|
||||
collect_missing(AuditEventCategory::Http, requested.network_http, state.permissions.network_http);
|
||||
collect_missing(AuditEventCategory::Socket, requested.network_socket, state.permissions.network_socket);
|
||||
collect_missing(AuditEventCategory::ProcessCreate, requested.process, state.permissions.process);
|
||||
collect_missing(AuditEventCategory::Threading, requested.threading, state.permissions.threading);
|
||||
|
||||
if (missing_buckets.empty())
|
||||
return true;
|
||||
|
||||
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
|
||||
return false;
|
||||
|
||||
const auto build_message = [](const std::string& plugin_name, const std::vector<MissingBucket>& buckets) {
|
||||
wxString message;
|
||||
for (const MissingBucket& bucket : buckets) {
|
||||
wxString targets;
|
||||
for (const std::string& target : bucket.targets)
|
||||
targets += wxString::FromUTF8(target.c_str()) + "\n";
|
||||
|
||||
if (!message.IsEmpty())
|
||||
message += "\n\n";
|
||||
message += PluginAuditDetail::audit_message(bucket.category, wxString::FromUTF8(plugin_name.c_str()),
|
||||
wxEmptyString, targets, bucket.targets.size());
|
||||
}
|
||||
return message;
|
||||
};
|
||||
|
||||
bool granted = false;
|
||||
if (wxIsMainThread()) {
|
||||
granted = PluginAuditDetail::prompt_for_permission_message(build_message(descriptor.name, missing_buckets));
|
||||
} else {
|
||||
auto result = std::make_shared<std::promise<bool>>();
|
||||
auto future = result->get_future();
|
||||
GUI::wxGetApp().CallAfter([result, plugin_name = descriptor.name, missing_buckets, build_message]() {
|
||||
result->set_value(PluginAuditDetail::prompt_for_permission_message(build_message(plugin_name, missing_buckets)));
|
||||
});
|
||||
granted = future.get();
|
||||
}
|
||||
|
||||
if (!granted)
|
||||
return false;
|
||||
|
||||
// The dialog can stay open while another grant is persisted; re-read before saving so this
|
||||
// request adds to the current state instead of replacing it.
|
||||
PluginInstallState latest_state;
|
||||
if (read_install_state(boost::filesystem::path(descriptor.plugin_root), latest_state))
|
||||
state = std::move(latest_state);
|
||||
|
||||
PluginAuditDetail::seed_install_metadata(descriptor, state);
|
||||
|
||||
for (const MissingBucket& bucket : missing_buckets) {
|
||||
std::vector<std::string>* permission_list =
|
||||
PluginAuditDetail::permission_list_for(bucket.category, /*by_event=*/false, state.permissions);
|
||||
if (!permission_list)
|
||||
continue;
|
||||
for (const std::string& target : bucket.targets)
|
||||
if (std::find(permission_list->begin(), permission_list->end(), target) == permission_list->end())
|
||||
permission_list->push_back(target);
|
||||
}
|
||||
|
||||
return write_install_state(boost::filesystem::path(descriptor.plugin_root), state);
|
||||
}
|
||||
|
||||
void PluginAuditManager::record_root_frame()
|
||||
{
|
||||
PyObject* frame = reinterpret_cast<PyObject*>(PyEval_GetFrame()); // borrowed reference
|
||||
@@ -1321,12 +1332,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);
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
struct PluginPermissions;
|
||||
|
||||
// Thread creation exposes no user-supplied target, so both the runtime audit event and a
|
||||
// request_permissions(threading=True) declaration use this fixed target.
|
||||
inline constexpr const char* kThreadPermissionTarget = "thread";
|
||||
|
||||
struct AuditDecision {
|
||||
bool allowed = true;
|
||||
std::string reason;
|
||||
@@ -102,13 +108,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 +121,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
|
||||
@@ -134,12 +136,12 @@ public:
|
||||
AuditDecision check_path_access(const boost::filesystem::path& candidate, bool is_write);
|
||||
AuditDecision check_open(const std::string& path, const std::string& mode);
|
||||
|
||||
// Ask the user to grant the requested filesystem-read paths. The request may originate on a
|
||||
// plugin load worker, so the implementation marshals the modal dialog to the wx main thread.
|
||||
// Returns true only when every missing path was granted and persisted; denial aborts the plugin
|
||||
// load without adding a permission.
|
||||
bool request_filesystem_read_permissions(const std::string& plugin_key,
|
||||
const std::vector<std::string>& paths);
|
||||
// Ask the user to grant the requested permissions. The request may originate on a plugin
|
||||
// load worker, so the implementation marshals the modal dialog to the wx main thread.
|
||||
// Targets already persisted for the plugin are not shown or asked for again. Returns true
|
||||
// only when every missing target was granted and persisted; denial aborts the plugin load
|
||||
// without adding a permission.
|
||||
bool request_permissions(const std::string& plugin_key, const PluginPermissions& requested);
|
||||
|
||||
void report_violation(const AuditViolation& violation);
|
||||
bool audit_denial_pending() const;
|
||||
|
||||
@@ -49,7 +49,7 @@ thread_local std::string g_active_plugin_key;
|
||||
std::mutex g_registry_mutex;
|
||||
std::unordered_map<std::string, std::vector<py::object>> g_pending_capabilities;
|
||||
std::unordered_map<std::string, py::object> g_pending_package;
|
||||
std::unordered_map<std::string, std::vector<std::string>> g_pending_fs_read_permissions;
|
||||
std::unordered_map<std::string, PluginPermissions> g_pending_permissions;
|
||||
struct PluginInstanceHandle
|
||||
{
|
||||
// The C++ plugin interface points into a Python object. Keep both alive through one
|
||||
@@ -88,7 +88,7 @@ void discard_pending_capture_without_python(const std::string& plugin_key)
|
||||
g_pending_package.erase(package);
|
||||
}
|
||||
|
||||
g_pending_fs_read_permissions.erase(plugin_key);
|
||||
g_pending_permissions.erase(plugin_key);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -113,7 +113,7 @@ void PythonPluginBridge::begin_plugin_capture(const std::string& plugin_key)
|
||||
// for this same entry path.
|
||||
g_pending_capabilities.erase(plugin_key);
|
||||
g_pending_package.erase(plugin_key);
|
||||
g_pending_fs_read_permissions.erase(plugin_key);
|
||||
g_pending_permissions.erase(plugin_key);
|
||||
}
|
||||
// From now until finalize/cancel, @orca.plugin and register_capability() calls made by
|
||||
// Python code on this thread are attributed to this plugin.
|
||||
@@ -144,7 +144,7 @@ std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(cons
|
||||
std::lock_guard<std::mutex> lock(g_registry_mutex);
|
||||
g_pending_capabilities.erase(capture_key);
|
||||
g_pending_package.erase(capture_key);
|
||||
g_pending_fs_read_permissions.erase(capture_key);
|
||||
g_pending_permissions.erase(capture_key);
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -177,23 +177,27 @@ std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(cons
|
||||
// Permission declarations are collected while register_capabilities() runs so the
|
||||
// plugin can describe its needs without touching wx from the Python load worker. Ask
|
||||
// only after registration returns, before capability instances are materialized.
|
||||
std::vector<std::string> fs_read_permissions;
|
||||
PluginPermissions requested_permissions;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_registry_mutex);
|
||||
auto it = g_pending_fs_read_permissions.find(capture_key);
|
||||
if (it != g_pending_fs_read_permissions.end()) {
|
||||
fs_read_permissions = std::move(it->second);
|
||||
g_pending_fs_read_permissions.erase(it);
|
||||
auto it = g_pending_permissions.find(capture_key);
|
||||
if (it != g_pending_permissions.end()) {
|
||||
requested_permissions = std::move(it->second);
|
||||
g_pending_permissions.erase(it);
|
||||
}
|
||||
}
|
||||
if (!fs_read_permissions.empty()) {
|
||||
const bool has_permission_request =
|
||||
!requested_permissions.fs_read.empty() || !requested_permissions.fs_readwrite.empty() ||
|
||||
!requested_permissions.network_http.empty() || !requested_permissions.network_socket.empty() ||
|
||||
!requested_permissions.process.empty() || !requested_permissions.threading.empty();
|
||||
if (has_permission_request) {
|
||||
bool granted = false;
|
||||
{
|
||||
py::gil_scoped_release release;
|
||||
granted = PluginAuditManager::instance().request_filesystem_read_permissions(plugin_key, fs_read_permissions);
|
||||
granted = PluginAuditManager::instance().request_permissions(plugin_key, requested_permissions);
|
||||
}
|
||||
if (!granted) {
|
||||
error = _u8L("Plugin filesystem read permission request denied");
|
||||
error = _u8L("Plugin permission request denied");
|
||||
BOOST_LOG_TRIVIAL(warning) << error << " for key " << plugin_key;
|
||||
discard_pending_for_key();
|
||||
clear_active_key();
|
||||
@@ -322,7 +326,7 @@ void PythonPluginBridge::cancel_plugin_capture(const std::string& plugin_key)
|
||||
// may already have registered under this key.
|
||||
g_pending_capabilities.erase(plugin_key);
|
||||
g_pending_package.erase(plugin_key);
|
||||
g_pending_fs_read_permissions.erase(plugin_key);
|
||||
g_pending_permissions.erase(plugin_key);
|
||||
}
|
||||
|
||||
if (g_active_plugin_key == plugin_key)
|
||||
@@ -349,7 +353,7 @@ void PythonPluginBridge::clear_pending_captures()
|
||||
(void) pkg.release();
|
||||
}
|
||||
g_pending_package.clear();
|
||||
g_pending_fs_read_permissions.clear();
|
||||
g_pending_permissions.clear();
|
||||
g_active_plugin_key.clear();
|
||||
return;
|
||||
}
|
||||
@@ -358,7 +362,7 @@ void PythonPluginBridge::clear_pending_captures()
|
||||
BOOST_LOG_TRIVIAL(info) << "Clearing " << g_pending_capabilities.size() << " pending Python plugin capture(s)";
|
||||
g_pending_capabilities.clear();
|
||||
g_pending_package.clear();
|
||||
g_pending_fs_read_permissions.clear();
|
||||
g_pending_permissions.clear();
|
||||
g_active_plugin_key.clear();
|
||||
}
|
||||
|
||||
@@ -558,24 +562,54 @@ void bind_python_api(pybind11::module_& m)
|
||||
|
||||
m.def(
|
||||
"request_permissions",
|
||||
[](const std::vector<std::string>& fs_read) {
|
||||
[](const std::vector<std::string>& fs_read,
|
||||
const std::vector<std::string>& fs_readwrite,
|
||||
const std::vector<std::string>& network_http,
|
||||
const std::vector<std::string>& network_socket,
|
||||
const std::vector<std::string>& process,
|
||||
bool threading) {
|
||||
if (g_active_plugin_key.empty())
|
||||
throw py::value_error("request_permissions() called outside plugin discovery context");
|
||||
|
||||
std::lock_guard<std::mutex> lock(g_registry_mutex);
|
||||
auto& requested = g_pending_fs_read_permissions[g_active_plugin_key];
|
||||
for (const std::string& path : fs_read) {
|
||||
if (path.empty())
|
||||
throw py::value_error("request_permissions(fs_read=...) does not accept empty paths");
|
||||
if (std::find(requested.begin(), requested.end(), path) == requested.end())
|
||||
requested.push_back(path);
|
||||
}
|
||||
PluginPermissions& requested = g_pending_permissions[g_active_plugin_key];
|
||||
|
||||
const auto add_targets = [](std::vector<std::string>& bucket, const char* name,
|
||||
const std::vector<std::string>& targets) {
|
||||
for (const std::string& target : targets) {
|
||||
if (target.empty())
|
||||
throw py::value_error(std::string("request_permissions(") + name +
|
||||
"=...) does not accept empty values");
|
||||
if (std::find(bucket.begin(), bucket.end(), target) == bucket.end())
|
||||
bucket.push_back(target);
|
||||
}
|
||||
};
|
||||
|
||||
add_targets(requested.fs_read, "fs_read", fs_read);
|
||||
add_targets(requested.fs_readwrite, "fs_readwrite", fs_readwrite);
|
||||
add_targets(requested.network_http, "network_http", network_http);
|
||||
add_targets(requested.network_socket, "network_socket", network_socket);
|
||||
add_targets(requested.process, "process", process);
|
||||
|
||||
if (threading && std::find(requested.threading.begin(), requested.threading.end(),
|
||||
kThreadPermissionTarget) == requested.threading.end())
|
||||
requested.threading.emplace_back(kThreadPermissionTarget);
|
||||
},
|
||||
py::arg("fs_read") = std::vector<std::string>{},
|
||||
R"pbdoc(Request filesystem read permissions while OrcaSlicer loads your module.
|
||||
py::arg("fs_readwrite") = std::vector<std::string>{},
|
||||
py::arg("network_http") = std::vector<std::string>{},
|
||||
py::arg("network_socket") = std::vector<std::string>{},
|
||||
py::arg("process") = std::vector<std::string>{},
|
||||
py::arg("threading") = false,
|
||||
R"pbdoc(Request permissions while OrcaSlicer loads your module.
|
||||
|
||||
The host presents the request to the user after register_capabilities() returns. Denying the
|
||||
request aborts the plugin load.)pbdoc");
|
||||
Each bucket takes the target strings the plugin expects the corresponding audit event to
|
||||
report: filesystem paths for fs_read and fs_readwrite, URLs for network_http, socket addresses
|
||||
for network_socket, and commands for process. threading=True requests permission to start
|
||||
threads. Targets already granted to the plugin are not asked for again.
|
||||
|
||||
The host presents one request to the user after register_capabilities() returns and before any
|
||||
capability instance is created. Denying the request aborts the plugin load.)pbdoc");
|
||||
|
||||
m.def("plugin", [](py::object cls) {
|
||||
if (g_active_plugin_key.empty())
|
||||
|
||||
@@ -288,67 +288,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();
|
||||
|
||||
Reference in new issue
Block a user