Compare commits

..
Author SHA1 Message Date
Hanif Koh 7b05bc5ea1 Keep Layer Pauses on Their Own Layer in the Preview
The G-code processor remembers where a pause, color change or custom
G-code vertex sits in the move list, so it can move that vertex to the
first extrusion after it. A pause flushes the time planner first, which
inserts actual speed moves ahead of the vertex, so the remembered index
pointed at an earlier move: the previous layer's last extrusion was
moved instead, a real move was erased, and the time blocks queued after
the vertex were left one move off.

Shift the remembered index by the moves each flush inserts before it,
and shift the queued blocks after the erase. The Preview numbers layers
from the move order, so the stray move showed up as extra layers and
listed pauses on the wrong layer.
2026-10-21 13:50:40 +08:00
10 changed files with 183 additions and 555 deletions

No files matched your search

+10 -1
View File
@@ -5234,7 +5234,15 @@ void GCodeProcessor::process_G1(const std::array<std::optional<double>, 4>& axes
if (origin == G1DiscretizationOrigin::G1) if (origin == G1DiscretizationOrigin::G1)
m_extruded_last_z = m_end_position[Z]; m_extruded_last_z = m_end_position[Z];
m_options_z_corrector.update(m_height); // The queued blocks of the moves after the erased one follow them down the list.
if (const std::optional<size_t> erased = m_options_z_corrector.update(m_height)) {
for (TimeMachine& machine : m_time_processor.machines) {
for (TimeBlock& block : machine.blocks) {
if (block.move_id > *erased)
--block.move_id;
}
}
}
if (m_forced_width > 0.0f) if (m_forced_width > 0.0f)
m_width = m_forced_width; m_width = m_forced_width;
@@ -7958,6 +7966,7 @@ void GCodeProcessor::calculate_time(GCodeProcessorResult& result, size_t keep_la
it->second : block.move_id + (unsigned int)inserted_actual_speed_moves_count; it->second : block.move_id + (unsigned int)inserted_actual_speed_moves_count;
} }
} }
m_options_z_corrector.on_moves_inserted(m_actual_speed_id_map);
} }
void GCodeProcessor::simulate_st_synchronize(float additional_time, EMoveType target_move_type) void GCodeProcessor::simulate_st_synchronize(float additional_time, EMoveType target_move_type)
+22 -5
View File
@@ -18,6 +18,7 @@
#include <cstddef> #include <cstddef>
#include <cassert> #include <cassert>
#include <cstdint> #include <cstdint>
#include <algorithm>
#include <array> #include <array>
#include <unordered_map> #include <unordered_map>
#include <utility> #include <utility>
@@ -1076,18 +1077,34 @@ inline constexpr float DEFAULT_FILAMENT_DENSITY = 1.245f;
m_custom_gcode_per_print_z_id = m_result.custom_gcode_per_print_z.size() - 1; m_custom_gcode_per_print_z_id = m_result.custom_gcode_per_print_z.size() - 1;
} }
void update(float height) { // The time pass inserts actual speed moves in front of the moves of its blocks, and the stored
if (!m_move_id.has_value() || !m_custom_gcode_per_print_z_id.has_value()) // vertex has no block of its own, so it shifts with every insertion made before it.
void on_moves_inserted(const std::vector<std::pair<unsigned int, unsigned int>>& id_map) {
if (!m_move_id.has_value())
return; return;
auto it = std::lower_bound(id_map.begin(), id_map.end(), *m_move_id,
[](const std::pair<unsigned int, unsigned int>& entry, size_t id) { return entry.first < id; });
if (it != id_map.begin()) {
--it;
*m_move_id += it->second - it->first;
}
}
const Vec3f position = m_result.moves.back().position; // Returns the index of the move erased from the list, if any.
std::optional<size_t> update(float height) {
if (!m_move_id.has_value() || !m_custom_gcode_per_print_z_id.has_value())
return std::nullopt;
GCodeProcessorResult::MoveVertex& move = m_result.moves.emplace_back(m_result.moves[*m_move_id]); const Vec3f position = m_result.moves.back().position;
const size_t erased = *m_move_id;
GCodeProcessorResult::MoveVertex& move = m_result.moves.emplace_back(m_result.moves[erased]);
move.position = position; move.position = position;
move.height = height; move.height = height;
m_result.moves.erase(m_result.moves.begin() + *m_move_id); m_result.moves.erase(m_result.moves.begin() + erased);
m_result.custom_gcode_per_print_z[*m_custom_gcode_per_print_z_id].print_z = position.z(); m_result.custom_gcode_per_print_z[*m_custom_gcode_per_print_z_id].print_z = position.z();
reset(); reset();
return erased;
} }
void reset() { void reset() {
+79 -336
View File
@@ -5,13 +5,10 @@
#include "libslic3r/libslic3r.h" // GCODEVIEWER_APP_KEY, and SLIC3R_APP_KEY via libslic3r_version.h #include "libslic3r/libslic3r.h" // GCODEVIEWER_APP_KEY, and SLIC3R_APP_KEY via libslic3r_version.h
#include "libslic3r_version.h" #include "libslic3r_version.h"
#include <boost/algorithm/string/case_conv.hpp>
#include <boost/algorithm/string/predicate.hpp> #include <boost/algorithm/string/predicate.hpp>
#include <boost/algorithm/string/trim.hpp>
#include <boost/filesystem/path.hpp> #include <boost/filesystem/path.hpp>
#include <boost/filesystem/operations.hpp> #include <boost/filesystem/operations.hpp>
#include <boost/log/trivial.hpp> #include <boost/log/trivial.hpp>
#include <boost/system/error_code.hpp>
#include <algorithm> #include <algorithm>
#include <cassert> #include <cassert>
@@ -164,180 +161,6 @@ static bool is_fs_category(AuditEventCategory category)
} }
} }
// HTTP and raw-socket events are governed by the same network permission: a declared host may be
// reached through either layer, and the layers nest for one plugin action (urllib -> http.client
// -> socket).
static bool is_network_category(AuditEventCategory category)
{
return category == AuditEventCategory::Http || category == AuditEventCategory::Socket;
}
// ---------------------------------------------------------------------------
// Network permission identity
// ---------------------------------------------------------------------------
static std::string ascii_lower(std::string value)
{
boost::algorithm::to_lower(value);
return value;
}
static std::string trim_ascii_space(std::string value)
{
boost::algorithm::trim(value);
return value;
}
// Canonical storage keeps IPv6 literals bracketed (https://[::1]), while a socket address
// exposes the bare literal (::1). Compare them without the brackets.
static std::string without_ipv6_brackets(std::string host)
{
if (host.size() >= 2 && host.front() == '[' && host.back() == ']')
host = host.substr(1, host.size() - 2);
return host;
}
// Returns the host part of a URL authority, dropping userinfo and port. IPv6 literals keep their
// brackets so that a URL and a bare address compare consistently.
static std::string host_from_authority(std::string authority)
{
const size_t at = authority.rfind('@');
if (at != std::string::npos)
authority.erase(0, at + 1);
if (authority.empty())
return {};
if (authority.front() == '[') {
const size_t close = authority.find(']');
if (close == std::string::npos)
return {};
authority.erase(close + 1);
return ascii_lower(authority);
}
const size_t colon = authority.find(':');
if (colon != std::string::npos)
authority.erase(colon);
if (authority.find_first_of(" \t\r\n/?#@") != std::string::npos)
return {};
return ascii_lower(authority);
}
// Extracts a host from a target that is not a URL: "host", "host:443", "[::1]:443", "('host',
// 443)" (the form older releases persisted for socket address events), or "'host'".
static std::string network_host_from_target(const std::string& target)
{
std::string value = trim_ascii_space(target);
if (value.empty())
return {};
if (value.front() == '(') {
// Legacy persisted address tuple: ("host", 443) or ("host", 443, 0, 0).
const size_t quote = value.find_first_of("'\"");
if (quote != std::string::npos) {
const char delimiter = value[quote];
const size_t end = value.find(delimiter, quote + 1);
if (end == std::string::npos)
return {};
value = value.substr(quote + 1, end - quote - 1);
} else {
value = value.substr(1);
const size_t comma = value.find(',');
if (comma != std::string::npos)
value.erase(comma);
}
} else if (value.front() == '[') {
const size_t close = value.find(']');
if (close == std::string::npos)
return {};
value.erase(close + 1);
} else {
// Strip a ":port" suffix, but leave a bare IPv6 literal ("::1") intact.
const size_t colon = value.find(':');
if (colon != std::string::npos && value.find(':', colon + 1) == std::string::npos)
value.erase(colon);
}
value = trim_ascii_space(value);
if (value.empty() || value.find_first_of(" \t\r\n/?#@") != std::string::npos)
return {};
return ascii_lower(value);
}
std::string PluginAuditManager::normalize_network_url(const std::string& url)
{
const size_t scheme_end = url.find("://");
if (scheme_end == std::string::npos || scheme_end == 0)
return {};
const std::string scheme = ascii_lower(url.substr(0, scheme_end));
if (scheme != "http" && scheme != "https")
return {};
const size_t authority_start = scheme_end + 3;
const size_t authority_end = url.find_first_of("/?#", authority_start);
const std::string authority = url.substr(authority_start, authority_end == std::string::npos
? std::string::npos
: authority_end - authority_start);
const std::string host = host_from_authority(authority);
if (host.empty())
return {};
return scheme + "://" + host;
}
bool PluginAuditManager::network_permission_matches(const std::vector<std::string>& granted,
const std::string& target)
{
if (target.empty())
return false;
const bool target_is_url = target.find("://") != std::string::npos;
const std::string target_url = normalize_network_url(target);
if (target_is_url && target_url.empty()) {
// A non-http(s) URL has no network identity we can generalize; only an exact grant
// recorded by an earlier approval stays valid, so an unrelated scheme never matches.
return std::find(granted.begin(), granted.end(), target) != granted.end();
}
const std::string target_host =
target_is_url ? std::string{} : without_ipv6_brackets(network_host_from_target(target));
if (!target_is_url && target_host.empty())
return false;
for (const std::string& entry : granted) {
// Preserve an exact grant recorded by an older release, e.g. a full URL with a query.
if (entry == target)
return true;
const std::string entry_url = normalize_network_url(entry);
if (target_is_url) {
// Canonical scheme://host grants are host-wide. Older grants that persisted a full
// URL remain scoped to that exact URL, preserving the operation-root URL check.
if (!entry_url.empty() && entry_url == entry && entry_url == target_url)
return true;
continue;
}
if (!entry_url.empty()) {
// Bare hosts carry no scheme: match the host part of a scheme://host grant.
const std::string entry_host = entry_url.substr(entry_url.find("://") + 3);
if (without_ipv6_brackets(entry_host) == target_host)
return true;
} else if (without_ipv6_brackets(network_host_from_target(entry)) == target_host) {
// Legacy persisted address tuples, e.g. "('host', 443)".
return true;
}
}
return false;
}
// The category's name in a saved event grant, e.g. "FsRead" in "FsRead:open". // The category's name in a saved event grant, e.g. "FsRead" in "FsRead:open".
static const char* category_name(AuditEventCategory category) static const char* category_name(AuditEventCategory category)
{ {
@@ -705,39 +528,6 @@ AuditDecision PluginAuditManager::check_open(const std::string& path_str, const
return check_path_access(boost::filesystem::path(path_str), is_write); return check_path_access(boost::filesystem::path(path_str), is_write);
} }
namespace {
// Builds and shows the modal confirmation. Must run on the GUI thread.
bool show_permission_dialog(const wxString& message, const wxString& title)
{
wxMessageDialog dialog(nullptr, message, title, wxYES_NO | wxICON_WARNING);
return dialog.ShowModal() == wxID_YES;
}
// Shows a permission prompt on the GUI thread and waits for the answer. The caller may be the
// plugin-load worker, so off the main thread the dialog is marshaled with CallAfter.
bool ask_permission(const wxString& message)
{
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
return false;
if (wxIsMainThread())
return show_permission_dialog(message, _L("Plugin permissions"));
auto result = std::make_shared<std::promise<bool>>();
auto future = result->get_future();
GUI::wxGetApp().CallAfter([result, message]() {
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing()) {
result->set_value(false);
return;
}
result->set_value(show_permission_dialog(message, _L("Plugin permissions")));
});
return future.get();
}
} // namespace
bool PluginAuditManager::request_filesystem_read_permissions(const std::string& plugin_key, bool PluginAuditManager::request_filesystem_read_permissions(const std::string& plugin_key,
const std::vector<std::string>& paths) const std::vector<std::string>& paths)
{ {
@@ -759,80 +549,60 @@ bool PluginAuditManager::request_filesystem_read_permissions(const std::string&
if (missing.empty()) if (missing.empty())
return true; return true;
wxString requested_paths; if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
for (const std::string& path : missing)
requested_paths += wxString::FromUTF8(path.c_str()) + "\n";
if (!ask_permission(wxString::Format(_L("Plugin \"%s\" requests filesystem read access to:\n%s"),
wxString::FromUTF8(descriptor.name.c_str()), requested_paths)))
return false; return false;
seed_install_metadata(state, descriptor); 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()); 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); return write_install_state(boost::filesystem::path(descriptor.plugin_root), state);
} }
bool PluginAuditManager::request_network_permissions(const std::string& plugin_key,
const std::vector<std::string>& urls)
{
if (plugin_key.empty() || urls.empty())
return true;
PluginDescriptor descriptor;
if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty())
return false;
// Reduce every request to scheme://host before comparing or persisting it, so a URL with a
// path or query cannot create a second grant for the same host.
std::vector<std::string> requested;
for (const std::string& url : urls) {
const std::string normalized = normalize_network_url(url);
if (normalized.empty()) {
BOOST_LOG_TRIVIAL(warning) << "[AUDIT] Invalid network permission request for plugin " << plugin_key << ": " << url;
return false;
}
if (std::find(requested.begin(), requested.end(), normalized) == requested.end())
requested.push_back(normalized);
}
PluginInstallState state;
read_install_state(boost::filesystem::path(descriptor.plugin_root), state);
std::vector<std::string> missing;
for (const std::string& entry : requested) {
if (!network_permission_matches(state.permissions.network_http, entry) &&
!network_permission_matches(state.permissions.network_socket, entry))
missing.push_back(entry);
}
if (missing.empty())
return true;
wxString requested_hosts;
for (const std::string& entry : missing)
requested_hosts += wxString::FromUTF8(entry.c_str()) + "\n";
if (!ask_permission(wxString::Format(_L("Plugin \"%s\" requests network access to:\n%s"),
wxString::FromUTF8(descriptor.name.c_str()), requested_hosts)))
return false;
seed_install_metadata(state, descriptor);
// A declared host is reachable through both HTTP and raw socket events. Recording the grant in
// both existing lists keeps the sidecar self-describing and means either event layer matches
// it without relying on the other list.
for (const std::string& entry : missing) {
if (std::find(state.permissions.network_http.begin(), state.permissions.network_http.end(), entry) ==
state.permissions.network_http.end())
state.permissions.network_http.push_back(entry);
if (std::find(state.permissions.network_socket.begin(), state.permissions.network_socket.end(), entry) ==
state.permissions.network_socket.end())
state.permissions.network_socket.push_back(entry);
}
return write_install_state(boost::filesystem::path(descriptor.plugin_root), state);
}
void PluginAuditManager::report_violation(const AuditViolation& violation) void PluginAuditManager::report_violation(const AuditViolation& violation)
{ {
m_last_violation = violation; m_last_violation = violation;
@@ -875,39 +645,6 @@ PyObject* tuple_item(PyObject* args, Py_ssize_t index)
return PyTuple_GET_ITEM(args, index); return PyTuple_GET_ITEM(args, index);
} }
std::string python_unicode(PyObject* object)
{
if (!object || !PyUnicode_Check(object))
return {};
const char* text = PyUnicode_AsUTF8(object);
if (!text) {
PyErr_Clear();
return {};
}
return text;
}
// Converts a str/bytes leaf to text, clearing a failed conversion. Returns false when `object` is
// neither str nor bytes, so callers can apply their own fallback.
bool python_text(PyObject* object, std::string& out)
{
if (PyUnicode_Check(object)) {
out = python_unicode(object);
return true;
}
if (PyBytes_Check(object)) {
const char* value = PyBytes_AsString(object);
if (!value) {
PyErr_Clear();
out.clear();
return true;
}
out = value;
return true;
}
return false;
}
std::string python_path(PyObject* object) std::string python_path(PyObject* object)
{ {
if (!object) if (!object)
@@ -919,12 +656,26 @@ std::string python_path(PyObject* object)
return {}; return {};
} }
std::string result; const char* path = PyUnicode_Check(path_object) ? PyUnicode_AsUTF8(path_object) : PyBytes_AsString(path_object);
python_text(path_object, result); std::string result = path ? path : "";
Py_DECREF(path_object); Py_DECREF(path_object);
if (!path)
PyErr_Clear();
return result; return result;
} }
std::string python_unicode(PyObject* object)
{
if (!object || !PyUnicode_Check(object))
return {};
const char* text = PyUnicode_AsUTF8(object);
if (!text) {
PyErr_Clear();
return {};
}
return text;
}
std::string python_str(PyObject* object) std::string python_str(PyObject* object)
{ {
if (!object) if (!object)
@@ -1143,20 +894,6 @@ bool has_permission(const std::vector<std::string>& granted, const std::string&
return std::find(granted.begin(), granted.end(), target) != granted.end(); return std::find(granted.begin(), granted.end(), target) != granted.end();
} }
// A declared or previously approved network grant may live in either network list: an HTTP grant
// covers the sockets the same request opens, and a raw socket grant covers a later HTTP call to
// the same host.
bool has_network_permission(const PluginPermissions& permissions, const std::string& target)
{
return PluginAuditManager::network_permission_matches(permissions.network_http, target) ||
PluginAuditManager::network_permission_matches(permissions.network_socket, target);
}
bool has_any_network_permission(const PluginPermissions& permissions)
{
return !permissions.network_http.empty() || !permissions.network_socket.empty();
}
bool persist_permission(const std::string& plugin_key, bool persist_permission(const std::string& plugin_key,
PluginInstallState& state, PluginInstallState& state,
std::vector<std::string>& permission_list, std::vector<std::string>& permission_list,
@@ -1166,7 +903,15 @@ bool persist_permission(const std::string& plugin_key,
if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty()) if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty())
return false; return false;
seed_install_metadata(state, descriptor); // 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;
}
if (!has_permission(permission_list, target)) if (!has_permission(permission_list, target))
permission_list.push_back(target); permission_list.push_back(target);
@@ -1245,10 +990,11 @@ bool prompt_for_targets(AuditEventCategory category, const std::string& plugin_n
for (const auto& target : unresolved) for (const auto& target : unresolved)
target_list += wxString::FromUTF8(target.c_str()) + "\n"; target_list += wxString::FromUTF8(target.c_str()) + "\n";
return show_permission_dialog( wxMessageDialog dialog(nullptr,
audit_message(category, wxString::FromUTF8(plugin_name.c_str()), wxString::FromUTF8(event_name.c_str()), audit_message(category, wxString::FromUTF8(plugin_name.c_str()),
target_list, unresolved.size()), wxString::FromUTF8(event_name.c_str()), target_list, unresolved.size()),
_L("Plugin permission request")); _L("Plugin permission request"), 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 // Records a grant in the plugin's sidecar so it is not asked again. Reads the install state
@@ -1320,13 +1066,10 @@ int decide_audited_event(PluginAuditManager& mgr,
std::vector<std::string>* permission_list = permission_list_for(category, by_event, state.permissions); std::vector<std::string>* permission_list = permission_list_for(category, by_event, state.permissions);
std::vector<std::string> unresolved = keys; std::vector<std::string> unresolved = keys;
if (permission_list || is_network_category(category)) { if (permission_list) {
unresolved.clear(); unresolved.clear();
for (const auto& key : keys) for (const auto& key : keys)
if (!(permission_list && has_permission(*permission_list, key)) && if (!has_permission(*permission_list, key))
!(is_network_category(category) &&
(by_event ? has_any_network_permission(state.permissions)
: has_network_permission(state.permissions, key))))
unresolved.push_back(key); unresolved.push_back(key);
if (unresolved.empty()) if (unresolved.empty())
return 0; return 0;
-24
View File
@@ -137,29 +137,6 @@ public:
bool request_filesystem_read_permissions(const std::string& plugin_key, bool request_filesystem_read_permissions(const std::string& plugin_key,
const std::vector<std::string>& paths); const std::vector<std::string>& paths);
// Ask the user to grant network access to the given http(s) URLs. Each URL is reduced to its
// scheme and host before prompting and persisting, so one approval covers every path, query
// and port on that host. The grant is written to both network_http and network_socket: the
// same declared host can surface through HTTP events (urllib/http.client) and raw socket
// events. Like request_filesystem_read_permissions, the request may originate on a plugin
// load worker; denial aborts the plugin load without adding a permission.
bool request_network_permissions(const std::string& plugin_key,
const std::vector<std::string>& urls);
// Canonical scheme://host form used to store and match network permissions. Returns an empty
// string unless `url` is an absolute http(s) URL with a host. The path, query, fragment,
// userinfo and port are discarded; scheme and host are lowercased.
static std::string normalize_network_url(const std::string& url);
// True when a granted scheme://host entry covers `target`. `target` is a URL (urllib.Request),
// a bare host (http.client.connect, socket.getaddrinfo) or an address as exposed by the audit
// event (a hostname, a host:port pair, or a legacy persisted tuple such as "('host', 443)").
// URL targets must match both scheme and host. Canonical scheme://host grants cover all paths;
// older full-URL grants remain exact. A bare host carries no scheme, so it matches the host
// part of any grant; it never matches a different host.
static bool network_permission_matches(const std::vector<std::string>& granted,
const std::string& target);
void report_violation(const AuditViolation& violation); void report_violation(const AuditViolation& violation);
bool audit_denial_pending() const; bool audit_denial_pending() const;
void clear_audit_denial(); void clear_audit_denial();
@@ -210,7 +187,6 @@ private:
// mutable: is_denied_filename() is a const query that must lock. // mutable: is_denied_filename() is a const query that must lock.
mutable std::mutex m_mutex; mutable std::mutex m_mutex;
std::vector<AllowedRoot> m_global_allowed_roots; std::vector<AllowedRoot> m_global_allowed_roots;
std::vector<std::string> m_denied_filenames; std::vector<std::string> m_denied_filenames;
std::vector<std::string> m_denied_path_keywords; std::vector<std::string> m_denied_path_keywords;
+15 -27
View File
@@ -833,29 +833,6 @@ bool read_install_state(const boost::filesystem::path& plugin_dir, PluginInstall
} }
} }
namespace {
// The single descriptor -> sidecar metadata mapping. Prefer installed_version (fetched at install
// time and preserved across rewrites) over a possibly stale manifest version.
void apply_install_metadata(PluginInstallState& state, const PluginDescriptor& entry)
{
state.installed_from = entry.is_cloud_plugin() ? "cloud" : "local";
state.installed_version = !entry.installed_version.empty() ? entry.installed_version : entry.version;
state.plugin_name = entry.name;
state.cloud_uuid = entry.cloud_uuid();
}
} // namespace
void seed_install_metadata(PluginInstallState& state, const PluginDescriptor& entry)
{
if (!state.plugin_name.empty())
return;
apply_install_metadata(state, entry);
state.enabled = true;
}
bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginInstallState& state) bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginInstallState& state)
{ {
namespace fs = boost::filesystem; namespace fs = boost::filesystem;
@@ -899,9 +876,16 @@ bool write_install_state(const boost::filesystem::path& plugin_dir, const Plugin
// Loading a plugin updates its lifecycle/capability state, but must retain permissions granted // Loading a plugin updates its lifecycle/capability state, but must retain permissions granted
// during register_capabilities() or by a previous runtime audit prompt. // during register_capabilities() or by a previous runtime audit prompt.
read_install_state(plugin_dir, state); read_install_state(plugin_dir, state);
apply_install_metadata(state, entry); state.installed_from = entry.is_cloud_plugin() ? "cloud" : "local";
state.enabled = enabled; // Prefer the descriptor's recorded installed_version (the version fetched from the cloud
state.capabilities = capabilities; // at install time, preserved across sidecar re-writes) so a stale manifest/PEP723 header
// never overwrites the source-of-truth version. Fall back to the manifest version for
// first-time/local installs where installed_version is not yet populated.
state.installed_version = !entry.installed_version.empty() ? entry.installed_version : entry.version;
state.plugin_name = entry.name;
state.cloud_uuid = entry.cloud_uuid();
state.enabled = enabled;
state.capabilities = capabilities;
return write_install_state(plugin_dir, state); return write_install_state(plugin_dir, state);
} }
@@ -912,7 +896,11 @@ bool write_install_state(const boost::filesystem::path& plugin_dir, const Plugin
// permissions on a fresh install/reinstall. PluginManager writes the real per-capability flags // permissions on a fresh install/reinstall. PluginManager writes the real per-capability flags
// while preserving permissions once the package is loaded, via the overload above. // while preserving permissions once the package is loaded, via the overload above.
PluginInstallState state; PluginInstallState state;
seed_install_metadata(state, entry); state.installed_from = entry.is_cloud_plugin() ? "cloud" : "local";
state.installed_version = !entry.installed_version.empty() ? entry.installed_version : entry.version;
state.plugin_name = entry.name;
state.cloud_uuid = entry.cloud_uuid();
state.enabled = true;
return write_install_state(plugin_dir, state); return write_install_state(plugin_dir, state);
} }
-7
View File
@@ -186,19 +186,12 @@ std::vector<std::string> collect_resource_texture_files(const std::string& dir,
// Canonical writer: emits the .install_state.json schema for the given state. // Canonical writer: emits the .install_state.json schema for the given state.
bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginInstallState& state); bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginInstallState& state);
// Builds a PluginInstallState from the descriptor and delegates to the canonical writer. // Builds a PluginInstallState from the descriptor and delegates to the canonical writer.
bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry, bool enabled, bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry, bool enabled,
const std::vector<std::pair<std::string, bool>>& capabilities); const std::vector<std::pair<std::string, bool>>& capabilities);
// Convenience overload: write(dir, entry, /*enabled=*/true, /*capabilities=*/{}). // Convenience overload: write(dir, entry, /*enabled=*/true, /*capabilities=*/{}).
bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry); bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry);
// Fill the descriptor-derived install metadata into a sidecar state that does not describe an
// installed plugin yet, and mark it enabled. Leaves permissions and capabilities untouched.
// Single source for the metadata mapping used by the install writer and by the audit prompt path
// that creates a sidecar on demand.
void seed_install_metadata(PluginInstallState& state, const PluginDescriptor& entry);
// Reads install state back into the descriptor; plugin_key is always derived. Returns whether the // Reads install state back into the descriptor; plugin_key is always derived. Returns whether the
// sidecar was present and valid. // sidecar was present and valid.
bool read_install_state(const boost::filesystem::path& plugin_dir, PluginDescriptor& entry); bool read_install_state(const boost::filesystem::path& plugin_dir, PluginDescriptor& entry);
+6 -2
View File
@@ -1180,8 +1180,12 @@ void PluginManager::mark_plugin_install_state_disabled(const std::string& plugin
const boost::filesystem::path root(descriptor.plugin_root); const boost::filesystem::path root(descriptor.plugin_root);
PluginInstallState state; PluginInstallState state;
if (!read_install_state(root, state)) if (!read_install_state(root, state)) {
seed_install_metadata(state, descriptor); 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 = false; state.enabled = false;
if (!write_install_state(root, state)) if (!write_install_state(root, state))
return; return;
+9 -66
View File
@@ -50,7 +50,6 @@ std::mutex g_registry_mutex;
std::unordered_map<std::string, std::vector<py::object>> g_pending_capabilities; 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, py::object> g_pending_package;
std::unordered_map<std::string, std::vector<std::string>> g_pending_fs_read_permissions; std::unordered_map<std::string, std::vector<std::string>> g_pending_fs_read_permissions;
std::unordered_map<std::string, std::vector<std::string>> g_pending_network_permissions;
struct PluginInstanceHandle struct PluginInstanceHandle
{ {
// The C++ plugin interface points into a Python object. Keep both alive through one // The C++ plugin interface points into a Python object. Keep both alive through one
@@ -90,7 +89,6 @@ void discard_pending_capture_without_python(const std::string& plugin_key)
} }
g_pending_fs_read_permissions.erase(plugin_key); g_pending_fs_read_permissions.erase(plugin_key);
g_pending_network_permissions.erase(plugin_key);
} }
} // namespace } // namespace
@@ -116,7 +114,6 @@ void PythonPluginBridge::begin_plugin_capture(const std::string& plugin_key)
g_pending_capabilities.erase(plugin_key); g_pending_capabilities.erase(plugin_key);
g_pending_package.erase(plugin_key); g_pending_package.erase(plugin_key);
g_pending_fs_read_permissions.erase(plugin_key); g_pending_fs_read_permissions.erase(plugin_key);
g_pending_network_permissions.erase(plugin_key);
} }
// From now until finalize/cancel, @orca.plugin and register_capability() calls made by // From now until finalize/cancel, @orca.plugin and register_capability() calls made by
// Python code on this thread are attributed to this plugin. // Python code on this thread are attributed to this plugin.
@@ -148,7 +145,6 @@ std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(cons
g_pending_capabilities.erase(capture_key); g_pending_capabilities.erase(capture_key);
g_pending_package.erase(capture_key); g_pending_package.erase(capture_key);
g_pending_fs_read_permissions.erase(capture_key); g_pending_fs_read_permissions.erase(capture_key);
g_pending_network_permissions.erase(capture_key);
}; };
try { try {
@@ -204,30 +200,6 @@ std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(cons
return {}; return {};
} }
} }
std::vector<std::string> network_permissions;
{
std::lock_guard<std::mutex> lock(g_registry_mutex);
auto it = g_pending_network_permissions.find(capture_key);
if (it != g_pending_network_permissions.end()) {
network_permissions = std::move(it->second);
g_pending_network_permissions.erase(it);
}
}
if (!network_permissions.empty()) {
bool granted = false;
{
py::gil_scoped_release release;
granted = PluginAuditManager::instance().request_network_permissions(plugin_key, network_permissions);
}
if (!granted) {
error = _u8L("Plugin network permission request denied");
BOOST_LOG_TRIVIAL(warning) << error << " for key " << plugin_key;
discard_pending_for_key();
clear_active_key();
return {};
}
}
} catch (py::error_already_set& err) { } catch (py::error_already_set& err) {
log_python_exception_keep(err); log_python_exception_keep(err);
error = err.what(); error = err.what();
@@ -351,7 +323,6 @@ void PythonPluginBridge::cancel_plugin_capture(const std::string& plugin_key)
g_pending_capabilities.erase(plugin_key); g_pending_capabilities.erase(plugin_key);
g_pending_package.erase(plugin_key); g_pending_package.erase(plugin_key);
g_pending_fs_read_permissions.erase(plugin_key); g_pending_fs_read_permissions.erase(plugin_key);
g_pending_network_permissions.erase(plugin_key);
} }
if (g_active_plugin_key == plugin_key) if (g_active_plugin_key == plugin_key)
@@ -379,7 +350,6 @@ void PythonPluginBridge::clear_pending_captures()
} }
g_pending_package.clear(); g_pending_package.clear();
g_pending_fs_read_permissions.clear(); g_pending_fs_read_permissions.clear();
g_pending_network_permissions.clear();
g_active_plugin_key.clear(); g_active_plugin_key.clear();
return; return;
} }
@@ -389,7 +359,6 @@ void PythonPluginBridge::clear_pending_captures()
g_pending_capabilities.clear(); g_pending_capabilities.clear();
g_pending_package.clear(); g_pending_package.clear();
g_pending_fs_read_permissions.clear(); g_pending_fs_read_permissions.clear();
g_pending_network_permissions.clear();
g_active_plugin_key.clear(); g_active_plugin_key.clear();
} }
@@ -589,50 +558,24 @@ void bind_python_api(pybind11::module_& m)
m.def( m.def(
"request_permissions", "request_permissions",
[](const std::vector<std::string>& fs_read, const std::vector<std::string>& network) { [](const std::vector<std::string>& fs_read) {
if (g_active_plugin_key.empty()) if (g_active_plugin_key.empty())
throw py::value_error("request_permissions() called outside plugin discovery context"); throw py::value_error("request_permissions() called outside plugin discovery context");
// Validate and canonicalize everything before touching the pending registry, so a std::lock_guard<std::mutex> lock(g_registry_mutex);
// partially invalid call cannot leave a half-recorded request behind. auto& requested = g_pending_fs_read_permissions[g_active_plugin_key];
std::vector<std::string> normalized_network;
normalized_network.reserve(network.size());
for (const std::string& url : network) {
const std::string normalized = PluginAuditManager::normalize_network_url(url);
if (normalized.empty())
throw py::value_error(
"request_permissions(network=...) requires an http:// or https:// URL with a host");
if (std::find(normalized_network.begin(), normalized_network.end(), normalized) == normalized_network.end())
normalized_network.push_back(normalized);
}
for (const std::string& path : fs_read) { for (const std::string& path : fs_read) {
if (path.empty()) if (path.empty())
throw py::value_error("request_permissions(fs_read=...) does not accept empty paths"); 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);
std::lock_guard<std::mutex> lock(g_registry_mutex);
auto& requested_fs = g_pending_fs_read_permissions[g_active_plugin_key];
for (const std::string& path : fs_read) {
if (std::find(requested_fs.begin(), requested_fs.end(), path) == requested_fs.end())
requested_fs.push_back(path);
}
if (!normalized_network.empty()) {
auto& requested_network = g_pending_network_permissions[g_active_plugin_key];
for (const std::string& entry : normalized_network) {
if (std::find(requested_network.begin(), requested_network.end(), entry) == requested_network.end())
requested_network.push_back(entry);
}
} }
}, },
py::arg("fs_read") = std::vector<std::string>{}, py::arg("network") = std::vector<std::string>{}, py::arg("fs_read") = std::vector<std::string>{},
R"pbdoc(Request permissions while OrcaSlicer loads your module. R"pbdoc(Request filesystem read permissions while OrcaSlicer loads your module.
`fs_read` lists filesystem paths to read. `network` lists http(s) URLs; each is reduced to its The host presents the request to the user after register_capabilities() returns. Denying the
scheme and host, so one grant covers every path, query and port on that host. The host presents request aborts the plugin load.)pbdoc");
the requests to the user after register_capabilities() returns. Denying a request aborts the
plugin load.)pbdoc");
m.def("plugin", [](py::object cls) { m.def("plugin", [](py::object cls) {
if (g_active_plugin_key.empty()) if (g_active_plugin_key.empty())
+42
View File
@@ -282,6 +282,48 @@ TEST_CASE("A seam takes the actual speed of the move it follows", "[GCodeProcess
REQUIRE(seams > 0); REQUIRE(seams > 0);
} }
TEST_CASE("A pause keeps the moves around it in order and on their own layers", "[GCodeProcessor][Regression]")
{
// libvgcode numbers layers from the order of the moves, so the moves around a pause must stay in layer and
// line order. The pause vertex itself moves to the first extrusion after it and keeps its own line.
std::ostringstream gcode;
gcode << "M83\nG90\n";
for (int layer = 1; layer <= 3; ++layer) {
gcode << "; CHANGE_LAYER\n; LAYER_HEIGHT: 0.2\n";
if (layer == 2)
gcode << "G1 E-0.8 F3000\n; PAUSE_PRINTING\nM601\n";
gcode << "G1 X10 Y10 F12000\nG1 Z" << 0.2 * layer << "\n";
if (layer == 2)
gcode << "G1 E0.8 F3000\n";
gcode << "; FEATURE: Outer wall\n"
<< "G1 X50 Y10 E2 F3000\nG1 X50 Y50 E2\nG1 X10 Y50 E2\nG1 X10 Y10 E2\n";
}
GCodeProcessorResult result;
process_gcode(gcode.str(), result);
const auto &moves = result.moves;
REQUIRE(result.custom_gcode_per_print_z.size() == 1);
CHECK_THAT(result.custom_gcode_per_print_z.front().print_z, Catch::Matchers::WithinAbs(0.4, 1e-4));
REQUIRE(std::count_if(moves.begin(), moves.end(), [](const auto &move) { return move.internal_only; }) > 0);
const auto pause = std::find_if(moves.begin(), moves.end(), [](const auto &move) { return move.type == EMoveType::Pause_Print; });
REQUIRE(pause != moves.end());
CHECK(pause->layer_id == 1);
CHECK_THAT(pause->position.z(), Catch::Matchers::WithinAbs(0.4f, 1e-4f));
const GCodeProcessorResult::MoveVertex *previous = nullptr;
for (const auto &move : moves) {
if (move.type == EMoveType::Pause_Print)
continue;
if (previous != nullptr) {
CAPTURE(previous->gcode_id, move.gcode_id, static_cast<int>(move.type));
CHECK(move.layer_id >= previous->layer_id);
CHECK(move.gcode_id >= previous->gcode_id);
}
previous = &move;
}
}
TEST_CASE("Line ends of the exported G-code mark every newline in the file", "[GCodeProcessor]") TEST_CASE("Line ends of the exported G-code mark every newline in the file", "[GCodeProcessor]")
{ {
struct Case struct Case
-87
View File
@@ -351,93 +351,6 @@ TEST_CASE("Plugin audit a read-only allowed root blocks writes but not reads", "
} }
} }
TEST_CASE("Plugin audit network permissions match by scheme and host", "[audit]")
{
SECTION("a URL is reduced to its scheme and host")
{
CHECK(PluginAuditManager::normalize_network_url("https://API.Example.com:8443/v1/items?page=2#top") ==
"https://api.example.com");
CHECK(PluginAuditManager::normalize_network_url("http://example.com") == "http://example.com");
CHECK(PluginAuditManager::normalize_network_url("https://[2001:db8::1]:443/path") == "https://[2001:db8::1]");
}
SECTION("non-http schemes and targets without a scheme are not network URLs")
{
CHECK(PluginAuditManager::normalize_network_url("ftp://example.com").empty());
CHECK(PluginAuditManager::normalize_network_url("api.example.com").empty());
CHECK(PluginAuditManager::normalize_network_url("https://").empty());
}
SECTION("a URL target matches the same scheme and host regardless of path and port")
{
const std::vector<std::string> granted{"https://api.example.com"};
CHECK(PluginAuditManager::network_permission_matches(granted, "https://api.example.com/v1/items?page=2"));
CHECK(PluginAuditManager::network_permission_matches(granted, "https://api.example.com:8443/upload"));
}
SECTION("canonical host grants are broad but persisted full-URL grants stay exact")
{
const std::vector<std::string> declared_host{"https://api.example.com"};
CHECK(PluginAuditManager::network_permission_matches(declared_host, "https://api.example.com/v1"));
const std::vector<std::string> saved_url{"https://api.example.com/v1"};
CHECK(PluginAuditManager::network_permission_matches(saved_url, "https://api.example.com/v1"));
CHECK_FALSE(PluginAuditManager::network_permission_matches(saved_url, "https://api.example.com/v2"));
}
SECTION("a URL target does not match a different host or a different scheme")
{
const std::vector<std::string> granted{"https://api.example.com"};
CHECK_FALSE(PluginAuditManager::network_permission_matches(granted, "https://evil.example.com/v1"));
CHECK_FALSE(PluginAuditManager::network_permission_matches(granted, "http://api.example.com/v1"));
}
SECTION("a bare host target matches the host of a grant")
{
const std::vector<std::string> granted{"https://api.example.com"};
CHECK(PluginAuditManager::network_permission_matches(granted, "api.example.com"));
CHECK(PluginAuditManager::network_permission_matches(granted, "api.example.com:443"));
CHECK_FALSE(PluginAuditManager::network_permission_matches(granted, "other.example.com"));
}
SECTION("legacy persisted address tuples keep matching their host")
{
const std::vector<std::string> legacy_granted{"('api.example.com', 443)"};
CHECK(PluginAuditManager::network_permission_matches(legacy_granted, "api.example.com"));
const std::vector<std::string> granted{"https://api.example.com"};
CHECK(PluginAuditManager::network_permission_matches(granted, "('api.example.com', 443)"));
CHECK_FALSE(PluginAuditManager::network_permission_matches(granted, "('93.184.216.34', 443)"));
}
SECTION("an IPv6 literal matches across bracketed and bare forms")
{
const std::vector<std::string> granted{"https://[2001:db8::1]"};
CHECK(PluginAuditManager::network_permission_matches(granted, "2001:db8::1"));
CHECK(PluginAuditManager::network_permission_matches(granted, "[2001:db8::1]:443"));
CHECK_FALSE(PluginAuditManager::network_permission_matches(granted, "2001:db8::2"));
}
SECTION("a non-http URL only matches an exact grant")
{
const std::vector<std::string> granted{"ftp://example.com/file"};
CHECK(PluginAuditManager::network_permission_matches(granted, "ftp://example.com/file"));
CHECK_FALSE(PluginAuditManager::network_permission_matches(granted, "ftp://example.com/other"));
const std::vector<std::string> https{"https://api.example.com"};
CHECK_FALSE(PluginAuditManager::network_permission_matches(https, "ftp://api.example.com/file"));
}
SECTION("an empty grant list or an empty target never matches")
{
const std::vector<std::string> granted{};
CHECK_FALSE(PluginAuditManager::network_permission_matches(granted, "https://api.example.com"));
const std::vector<std::string> any{"https://api.example.com"};
CHECK_FALSE(PluginAuditManager::network_permission_matches(any, ""));
}
}
TEST_CASE("Plugin audit a scoped root can also be registered read-only", "[audit]") TEST_CASE("Plugin audit a scoped root can also be registered read-only", "[audit]")
{ {
ScopedDataDir data_dir_guard("plugin-audit-scoped-readonly"); ScopedDataDir data_dir_guard("plugin-audit-scoped-readonly");