mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-20 14:08:20 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4afd257371 | ||
|
|
4a0d5e5edd | ||
|
|
069bd402d9 | ||
|
|
6626377abc | ||
|
|
74d4392dae | ||
|
|
0d5b3a79d7 | ||
|
|
6c791532c6 |
No files matched your search
+25
-3
@@ -47,7 +47,7 @@ while getopts ":dpa:snt:xbc:i:j:Tuh" opt; do
|
|||||||
u )
|
u )
|
||||||
export BUILD_TARGET="universal"
|
export BUILD_TARGET="universal"
|
||||||
;;
|
;;
|
||||||
h ) echo "Usage: ./build_release_macos.sh [-d]"
|
h ) echo "Usage: ./build_release_macos.sh [-d] [-- CMAKE_ARGS...]"
|
||||||
echo " -d: Build deps only"
|
echo " -d: Build deps only"
|
||||||
echo " -a: Set ARCHITECTURE (arm64 or x86_64 or universal)"
|
echo " -a: Set ARCHITECTURE (arm64 or x86_64 or universal)"
|
||||||
echo " -s: Build slicer only"
|
echo " -s: Build slicer only"
|
||||||
@@ -60,13 +60,33 @@ while getopts ":dpa:snt:xbc:i:j:Tuh" opt; do
|
|||||||
echo " -i: Add a prefix to ignore during CMake dependency discovery (repeatable), defaults to /opt/local:/usr/local:/opt/homebrew"
|
echo " -i: Add a prefix to ignore during CMake dependency discovery (repeatable), defaults to /opt/local:/usr/local:/opt/homebrew"
|
||||||
echo " -j: Set the number of parallel build jobs (CMAKE_BUILD_PARALLEL_LEVEL)"
|
echo " -j: Set the number of parallel build jobs (CMAKE_BUILD_PARALLEL_LEVEL)"
|
||||||
echo " -T: Build and run tests (set ORCA_TESTS_BUILD_ONLY=1 to build without running)"
|
echo " -T: Build and run tests (set ORCA_TESTS_BUILD_ONLY=1 to build without running)"
|
||||||
|
echo " -- CMAKE_ARGS...: pass everything after '--' to the cmake configure step of the deps (-d) and slicer (-s) builds,"
|
||||||
|
echo " e.g. -DDESTDIR=<dir> for the deps, -DCMAKE_PREFIX_PATH=<dir>/usr/local -DSLIC3R_PCH=OFF for the slicer, --fresh."
|
||||||
|
echo " They come last on the cmake command line. Use absolute paths: cmake runs in the build directory."
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
|
: )
|
||||||
|
echo "Option -${OPTARG} requires an argument, aborting."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
* )
|
* )
|
||||||
|
echo "Unknown option -${OPTARG}, aborting. CMake arguments must follow '--'."
|
||||||
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# getopts stops at '--'; whatever follows is handed to cmake unchanged.
|
||||||
|
if [[ ${OPTIND} -le $# ]] ; then
|
||||||
|
separator_index=$((OPTIND-1))
|
||||||
|
if [[ "${!separator_index}" != "--" ]] ; then
|
||||||
|
echo "Unexpected argument '${!OPTIND}': cmake arguments must follow '--', aborting."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
shift $((OPTIND-1))
|
||||||
|
EXTRA_CMAKE_ARGS=("$@")
|
||||||
|
|
||||||
# Set defaults
|
# Set defaults
|
||||||
|
|
||||||
if [ -z "$ARCH" ]; then
|
if [ -z "$ARCH" ]; then
|
||||||
@@ -211,7 +231,8 @@ function build_deps() {
|
|||||||
-DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \
|
-DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \
|
||||||
-DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \
|
-DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \
|
||||||
"${CMAKE_CCACHE_ARGS[@]}" \
|
"${CMAKE_CCACHE_ARGS[@]}" \
|
||||||
${CMAKE_POLICY_COMPAT}
|
${CMAKE_POLICY_COMPAT} \
|
||||||
|
"${EXTRA_CMAKE_ARGS[@]}"
|
||||||
fi
|
fi
|
||||||
cmake --build . --config "$BUILD_CONFIG" --target deps
|
cmake --build . --config "$BUILD_CONFIG" --target deps
|
||||||
)
|
)
|
||||||
@@ -314,7 +335,8 @@ function build_slicer() {
|
|||||||
-DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \
|
-DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \
|
||||||
-DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \
|
-DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \
|
||||||
"${CMAKE_CCACHE_ARGS[@]}" \
|
"${CMAKE_CCACHE_ARGS[@]}" \
|
||||||
${CMAKE_POLICY_COMPAT}
|
${CMAKE_POLICY_COMPAT} \
|
||||||
|
"${EXTRA_CMAKE_ARGS[@]}"
|
||||||
fi
|
fi
|
||||||
cmake --build . --config "$BUILD_CONFIG" --target "$SLICER_BUILD_TARGET"
|
cmake --build . --config "$BUILD_CONFIG" --target "$SLICER_BUILD_TARGET"
|
||||||
)
|
)
|
||||||
|
|||||||
+41
-16
@@ -377,7 +377,8 @@ public:
|
|||||||
virtual void set(const ConfigOption* rhs, size_t start, size_t len) = 0;
|
virtual void set(const ConfigOption* rhs, size_t start, size_t len) = 0;
|
||||||
virtual void set_with_restore(const ConfigOptionVectorBase* rhs, std::vector<int>& restore_index, int stride) = 0;
|
virtual void set_with_restore(const ConfigOptionVectorBase* rhs, std::vector<int>& restore_index, int stride) = 0;
|
||||||
virtual void set_with_restore_2(const ConfigOptionVectorBase* rhs, std::vector<int>& restore_index, int start, int len, bool skip_error = false) = 0;
|
virtual void set_with_restore_2(const ConfigOptionVectorBase* rhs, std::vector<int>& restore_index, int start, int len, bool skip_error = false) = 0;
|
||||||
virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector<int>& diff_index, int stride) = 0;
|
// keep_nil: copy a nil column from rhs as well, for options whose nil means "disabled" rather than "inherit".
|
||||||
|
virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector<int>& diff_index, int stride, bool keep_nil = false) = 0;
|
||||||
virtual void set_to_index(const ConfigOptionVectorBase* rhs, std::vector<int>& dest_index, int stride) = 0;
|
virtual void set_to_index(const ConfigOptionVectorBase* rhs, std::vector<int>& dest_index, int stride) = 0;
|
||||||
virtual void set_with_nil(const ConfigOptionVectorBase* rhs, const ConfigOptionVectorBase* inherits, int stride) = 0;
|
virtual void set_with_nil(const ConfigOptionVectorBase* rhs, const ConfigOptionVectorBase* inherits, int stride) = 0;
|
||||||
// Resize the vector of values, copy the newly added values from opt_default if provided.
|
// Resize the vector of values, copy the newly added values from opt_default if provided.
|
||||||
@@ -580,7 +581,7 @@ public:
|
|||||||
//set a item related with extruder variants when loading user config, only set the different value of some extruder
|
//set a item related with extruder variants when loading user config, only set the different value of some extruder
|
||||||
//rhs: item from user config
|
//rhs: item from user config
|
||||||
//diff_index: which index in this vector need to be set
|
//diff_index: which index in this vector need to be set
|
||||||
virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector<int>& diff_index, int stride) override
|
virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector<int>& diff_index, int stride, bool keep_nil) override
|
||||||
{
|
{
|
||||||
if (rhs->type() == this->type()) {
|
if (rhs->type() == this->type()) {
|
||||||
// Assign the first value of the rhs vector.
|
// Assign the first value of the rhs vector.
|
||||||
@@ -592,7 +593,7 @@ public:
|
|||||||
for (size_t i = 0; i < diff_index.size(); i++) {
|
for (size_t i = 0; i < diff_index.size(); i++) {
|
||||||
if (diff_index[i] != -1) {
|
if (diff_index[i] != -1) {
|
||||||
for (size_t j = 0; j < stride; j++) {
|
for (size_t j = 0; j < stride; j++) {
|
||||||
if (!other->is_nil(diff_index[i] * stride))
|
if (keep_nil || !other->is_nil(diff_index[i] * stride))
|
||||||
this->values[i * stride + j] = other->values[diff_index[i] * stride + j];
|
this->values[i * stride + j] = other->values[diff_index[i] * stride + j];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -841,8 +842,12 @@ public:
|
|||||||
{
|
{
|
||||||
UNUSED(append);
|
UNUSED(append);
|
||||||
std::istringstream iss(str);
|
std::istringstream iss(str);
|
||||||
iss >> this->value;
|
double value;
|
||||||
return !iss.fail();
|
iss >> value;
|
||||||
|
if (iss.fail())
|
||||||
|
return false;
|
||||||
|
this->value = value;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool operator==(const ConfigOption &rhs) const override
|
bool operator==(const ConfigOption &rhs) const override
|
||||||
@@ -1033,8 +1038,12 @@ public:
|
|||||||
{
|
{
|
||||||
UNUSED(append);
|
UNUSED(append);
|
||||||
std::istringstream iss(str);
|
std::istringstream iss(str);
|
||||||
iss >> this->value;
|
int value;
|
||||||
return !iss.fail();
|
iss >> value;
|
||||||
|
if (iss.fail())
|
||||||
|
return false;
|
||||||
|
this->value = value;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
ConfigOptionInt& operator=(const ConfigOption *opt)
|
ConfigOptionInt& operator=(const ConfigOption *opt)
|
||||||
@@ -1252,8 +1261,12 @@ public:
|
|||||||
UNUSED(append);
|
UNUSED(append);
|
||||||
// don't try to parse the trailing % since it's optional
|
// don't try to parse the trailing % since it's optional
|
||||||
std::istringstream iss(str);
|
std::istringstream iss(str);
|
||||||
iss >> this->value;
|
double value;
|
||||||
return !iss.fail();
|
iss >> value;
|
||||||
|
if (iss.fail())
|
||||||
|
return false;
|
||||||
|
this->value = value;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
@@ -1365,10 +1378,14 @@ public:
|
|||||||
bool deserialize(const std::string &str, bool append = false) override
|
bool deserialize(const std::string &str, bool append = false) override
|
||||||
{
|
{
|
||||||
UNUSED(append);
|
UNUSED(append);
|
||||||
this->percent = str.find_first_of("%") != std::string::npos;
|
|
||||||
std::istringstream iss(str);
|
std::istringstream iss(str);
|
||||||
iss >> this->value;
|
double value;
|
||||||
return !iss.fail();
|
iss >> value;
|
||||||
|
if (iss.fail())
|
||||||
|
return false;
|
||||||
|
this->value = value;
|
||||||
|
this->percent = str.find_first_of("%") != std::string::npos;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
@@ -1538,8 +1555,12 @@ public:
|
|||||||
{
|
{
|
||||||
UNUSED(append);
|
UNUSED(append);
|
||||||
char dummy;
|
char dummy;
|
||||||
return sscanf(str.data(), " %lf , %lf %c", &this->value(0), &this->value(1), &dummy) == 2 ||
|
Vec2d value;
|
||||||
sscanf(str.data(), " %lf x %lf %c", &this->value(0), &this->value(1), &dummy) == 2;
|
if (sscanf(str.data(), " %lf , %lf %c", &value(0), &value(1), &dummy) != 2 &&
|
||||||
|
sscanf(str.data(), " %lf x %lf %c", &value(0), &value(1), &dummy) != 2)
|
||||||
|
return false;
|
||||||
|
this->value = value;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
@@ -1658,8 +1679,12 @@ public:
|
|||||||
{
|
{
|
||||||
UNUSED(append);
|
UNUSED(append);
|
||||||
char dummy;
|
char dummy;
|
||||||
return sscanf(str.data(), " %lf , %lf , %lf %c", &this->value(0), &this->value(1), &this->value(2), &dummy) == 3 ||
|
Vec3d value;
|
||||||
sscanf(str.data(), " %lf x %lf x %lf %c", &this->value(0), &this->value(1), &this->value(2), &dummy) == 3;
|
if (sscanf(str.data(), " %lf , %lf , %lf %c", &value(0), &value(1), &value(2), &dummy) != 3 &&
|
||||||
|
sscanf(str.data(), " %lf x %lf x %lf %c", &value(0), &value(1), &value(2), &dummy) != 3)
|
||||||
|
return false;
|
||||||
|
this->value = value;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|||||||
@@ -4,10 +4,8 @@
|
|||||||
#include "OrderingStrategies.hpp"
|
#include "OrderingStrategies.hpp"
|
||||||
#include "../Geometry.hpp"
|
#include "../Geometry.hpp"
|
||||||
#include "../ShortestPath.hpp"
|
#include "../ShortestPath.hpp"
|
||||||
#include "libslic3r/KDTreeIndirect.hpp"
|
|
||||||
#include "libslic3r/Point.hpp"
|
#include "libslic3r/Point.hpp"
|
||||||
#include "libslic3r/PrintConfig.hpp"
|
#include "libslic3r/PrintConfig.hpp"
|
||||||
#include "libslic3r/libslic3r.h"
|
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cassert>
|
#include <cassert>
|
||||||
@@ -55,78 +53,47 @@ bool tsp_2opt_improve(std::vector<size_t>& path, const Points& centers, int max_
|
|||||||
};
|
};
|
||||||
std::vector<double> elsq = recompute_edges_sq();
|
std::vector<double> elsq = recompute_edges_sq();
|
||||||
|
|
||||||
// Reversing path[i+1..j] replaces edges (a, b) = (path[i], path[i+1]) and (c, d) = (path[j], path[j+1]) with
|
|
||||||
// (a, c) and (b, d). That can only shorten the cycle if a new edge is shorter than the old one it pairs with:
|
|
||||||
// |ac| < |ab| or |bd| < |cd|. Rather than test every pair, each pass finds those pairs with radius queries
|
|
||||||
// around a and d, which keeps a pass near linear on tours of thousands of nodes. It still applies the move a
|
|
||||||
// full scan would: the largest gain, and the lowest (i, j) among equal gains.
|
|
||||||
auto coordinate_fn = [¢ers](size_t idx, size_t dimension) -> double { return double(centers[idx](dimension)); };
|
|
||||||
KDTreeIndirect<2, double, decltype(coordinate_fn)> kdtree(coordinate_fn, path);
|
|
||||||
// Position of each node in the path.
|
|
||||||
std::vector<size_t> pos(centers.size());
|
|
||||||
for (size_t k = 0; k < pn; ++k)
|
|
||||||
pos[path[k]] = k;
|
|
||||||
|
|
||||||
// Calls fn(node) for the nodes of the tour within sqrt(radius_sq) of center. The search radius is padded so that
|
|
||||||
// rounding cannot leave out a node the gain test accepts; that test makes the exact cut.
|
|
||||||
auto visit_within = [&](const Vec2d& center, double radius_sq, auto&& fn) {
|
|
||||||
const double search_sq = radius_sq * (1. + 1e-9) + 1.;
|
|
||||||
auto visitor = [&](size_t idx, size_t dimension) {
|
|
||||||
if ((centers[idx].cast<double>() - center).squaredNorm() < search_sq)
|
|
||||||
fn(idx);
|
|
||||||
return kdtree.descent_mask(center[dimension], search_sq, idx, dimension);
|
|
||||||
};
|
|
||||||
kdtree.visit(visitor);
|
|
||||||
};
|
|
||||||
|
|
||||||
size_t best_i = pn, best_j = pn;
|
|
||||||
double best_gain = 0;
|
|
||||||
auto consider = [&](size_t i, size_t j) {
|
|
||||||
// Skip the swap that would reverse the entire cycle (removes both
|
|
||||||
// edges (0,1) and (pn-1,0), equivalent to traversing the cycle backwards).
|
|
||||||
if (j < i + 2 || (i == 0 && j == pn - 1)) return;
|
|
||||||
|
|
||||||
const Vec2d& pi = centers[path[i]].cast<double>();
|
|
||||||
const Vec2d& p_in = centers[path[i + 1]].cast<double>();
|
|
||||||
const Vec2d& pj = centers[path[j]].cast<double>();
|
|
||||||
const Vec2d& p_jn = centers[path[(j + 1) % pn]].cast<double>();
|
|
||||||
|
|
||||||
// Early rejection using squared distances (avoids 2 sqrt calls).
|
|
||||||
double new_a_sq = (pj - pi).squaredNorm();
|
|
||||||
double new_b_sq = (p_jn - p_in).squaredNorm();
|
|
||||||
if (new_a_sq >= elsq[i] && new_b_sq >= elsq[j]) return;
|
|
||||||
|
|
||||||
double new_a = std::sqrt(new_a_sq);
|
|
||||||
double new_b = std::sqrt(new_b_sq);
|
|
||||||
double gain = el[i] + el[j] - new_a - new_b;
|
|
||||||
|
|
||||||
if (gain > best_gain || (gain == best_gain && best_i != pn && std::make_pair(i, j) < std::make_pair(best_i, best_j))) {
|
|
||||||
best_gain = gain;
|
|
||||||
best_i = i; best_j = j;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
bool improved = false;
|
bool improved = false;
|
||||||
for (int pass = 0; max_passes <= 0 || pass < max_passes; ++pass) {
|
for (int pass = 0; max_passes <= 0 || pass < max_passes; ++pass) {
|
||||||
best_i = pn; best_j = pn;
|
size_t best_i = pn, best_j = pn;
|
||||||
best_gain = 0;
|
double best_gain = 0;
|
||||||
|
|
||||||
// |ac| < |ab|: c lies within the length of the edge leaving a.
|
for (size_t i = 0; i < pn; ++i) {
|
||||||
for (size_t i = 0; i + 2 < pn; ++i)
|
const Vec2d& pi = centers[path[i]].cast<double>();
|
||||||
visit_within(centers[path[i]].cast<double>(), elsq[i], [&](size_t c) { consider(i, pos[c]); });
|
const Vec2d& p_in = centers[path[(i + 1) % pn]].cast<double>();
|
||||||
// |bd| < |cd|: b lies within the length of the edge entering d.
|
double d_i = el[i];
|
||||||
for (size_t j = 2; j < pn; ++j)
|
double d_i_sq = elsq[i];
|
||||||
visit_within(centers[path[(j + 1) % pn]].cast<double>(), elsq[j], [&](size_t b) {
|
|
||||||
if (pos[b] > 0)
|
for (size_t j = i + 2; j < pn; ++j) {
|
||||||
consider(pos[b] - 1, j);
|
size_t j_next = (j + 1) % pn;
|
||||||
});
|
// Skip the swap that would reverse the entire cycle (removes both
|
||||||
|
// edges (0,1) and (pn-1,0), equivalent to traversing the cycle backwards).
|
||||||
|
if (i == 0 && j_next == 0) continue;
|
||||||
|
|
||||||
|
const Vec2d& pj = centers[path[j]].cast<double>();
|
||||||
|
const Vec2d& p_jn = centers[path[j_next]].cast<double>();
|
||||||
|
double d_j = el[j];
|
||||||
|
|
||||||
|
// Early rejection using squared distances (avoids 2 sqrt calls).
|
||||||
|
double new_a_sq = (pj - pi).squaredNorm();
|
||||||
|
double new_b_sq = (p_jn - p_in).squaredNorm();
|
||||||
|
if (new_a_sq >= d_i_sq && new_b_sq >= elsq[j]) continue;
|
||||||
|
|
||||||
|
double new_a = std::sqrt(new_a_sq);
|
||||||
|
double new_b = std::sqrt(new_b_sq);
|
||||||
|
double gain = d_i + d_j - new_a - new_b;
|
||||||
|
|
||||||
|
if (gain > best_gain) {
|
||||||
|
best_gain = gain;
|
||||||
|
best_i = i; best_j = j;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (best_i == pn) break;
|
if (best_i == pn) break;
|
||||||
improved = true;
|
improved = true;
|
||||||
// Reverse the best swap segment
|
// Reverse the best swap segment
|
||||||
std::reverse(path.begin() + best_i + 1, path.begin() + best_j + 1);
|
std::reverse(path.begin() + best_i + 1, path.begin() + best_j + 1);
|
||||||
for (size_t k = best_i + 1; k <= best_j; ++k)
|
|
||||||
pos[path[k]] = k;
|
|
||||||
|
|
||||||
// Recompute edge lengths after reversal
|
// Recompute edge lengths after reversal
|
||||||
el = recompute_edges();
|
el = recompute_edges();
|
||||||
@@ -153,9 +120,9 @@ bool tsp_remove_crossings(std::vector<size_t>& path, const Points& centers)
|
|||||||
// TSP helpers (2-opt, closing-edge rotation) that operate on the full cycle.
|
// TSP helpers (2-opt, closing-edge rotation) that operate on the full cycle.
|
||||||
size_t n_edges = pn;
|
size_t n_edges = pn;
|
||||||
|
|
||||||
// Scan from edge `from` for the first pair of intersecting edges worth reversing; returns {i, j} or {npos, npos} if none.
|
// Scan for first crossing; returns {i, j} or {npos, npos} if none.
|
||||||
auto find_crossing = [&](size_t from) -> std::pair<size_t, size_t> {
|
auto find_crossing = [&]() -> std::pair<size_t, size_t> {
|
||||||
for (size_t i = from; i < n_edges; ++i) {
|
for (size_t i = 0; i < n_edges; ++i) {
|
||||||
const Point& ai = centers[path[i]];
|
const Point& ai = centers[path[i]];
|
||||||
const Point& bi = centers[path[(i + 1) % pn]];
|
const Point& bi = centers[path[(i + 1) % pn]];
|
||||||
|
|
||||||
@@ -166,40 +133,23 @@ bool tsp_remove_crossings(std::vector<size_t>& path, const Points& centers)
|
|||||||
const Point& aj = centers[path[j]];
|
const Point& aj = centers[path[j]];
|
||||||
const Point& bj = centers[path[(j + 1) % pn]];
|
const Point& bj = centers[path[(j + 1) % pn]];
|
||||||
|
|
||||||
if (!bboxes_overlap(ai, bi, aj, bj) || !Geometry::segments_intersect(ai, bi, aj, bj))
|
if (!bboxes_overlap(ai, bi, aj, bj)) continue;
|
||||||
continue;
|
if (Geometry::segments_intersect(ai, bi, aj, bj))
|
||||||
// The reversal replaces edges (ai, bi) and (aj, bj) with (ai, aj) and (bi, bj). Edges that only touch
|
|
||||||
// or overlap collinearly, as at points sharing a position, may gain nothing from it, and reversing
|
|
||||||
// them would find the same pair again on every scan. A gain within SCALED_EPSILON, which nearly
|
|
||||||
// collinear crossings can have too, is not worth a reversal either.
|
|
||||||
if (ai.distance_to(bi) + aj.distance_to(bj) - ai.distance_to(aj) - bi.distance_to(bj) > SCALED_EPSILON)
|
|
||||||
return {i, j};
|
return {i, j};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {std::numeric_limits<size_t>::max(), std::numeric_limits<size_t>::max()};
|
return {std::numeric_limits<size_t>::max(), std::numeric_limits<size_t>::max()};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Process crossings one at a time. Reversing path[ci+1..cj] replaces only edges ci and cj; the edges between
|
// Process crossings one at a time: find first, reverse it, restart scan.
|
||||||
// them are the same segments traversed backwards. The scan therefore resumes at edge ci rather than at edge 0,
|
// Cap iterations to prevent infinite loops on collinear/overlapping segments.
|
||||||
// so a pass over the tour costs one quadratic scan however many reversals it makes. Pairs before ci that
|
int max_iters = static_cast<int>(pn * pn);
|
||||||
// involve a replaced edge are new too, so a scan that reaches the end starts over from edge 0, and the loop
|
|
||||||
// ends when a scan from edge 0 finds nothing.
|
|
||||||
// Every reversal shortens the cycle, so no tour repeats and the loop ends; the cap is a safety net.
|
|
||||||
size_t max_reversals = pn * pn;
|
|
||||||
bool improved = false;
|
bool improved = false;
|
||||||
for (size_t from = 0;;) {
|
while (max_iters-- > 0) {
|
||||||
auto [ci, cj] = find_crossing(from);
|
auto [ci, cj] = find_crossing();
|
||||||
if (ci == std::numeric_limits<size_t>::max()) {
|
if (ci == std::numeric_limits<size_t>::max()) break;
|
||||||
if (from == 0)
|
|
||||||
break;
|
|
||||||
from = 0;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
improved = true;
|
improved = true;
|
||||||
std::reverse(path.begin() + ci + 1, path.begin() + cj + 1);
|
std::reverse(path.begin() + ci + 1, path.begin() + cj + 1);
|
||||||
if (--max_reversals == 0)
|
|
||||||
break;
|
|
||||||
from = ci;
|
|
||||||
}
|
}
|
||||||
return improved;
|
return improved;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ namespace Slic3r {
|
|||||||
|
|
||||||
// --- Path improvement (operate on index vectors into `centers`) ---
|
// --- Path improvement (operate on index vectors into `centers`) ---
|
||||||
|
|
||||||
// 2-opt improvement: reverses segments that reduce total cycle path length. Each of up to max_passes passes
|
// 2-opt improvement: reverses segments that reduce total cycle path length.
|
||||||
// (unlimited if max_passes <= 0) applies the move that shortens the cycle most. Returns true if any improvement was made.
|
// Returns true if any improvement was made.
|
||||||
bool tsp_2opt_improve(std::vector<size_t>& path, const Points& centers, int max_passes = 10);
|
bool tsp_2opt_improve(std::vector<size_t>& path, const Points& centers, int max_passes = 10);
|
||||||
|
|
||||||
// Crossing removal: reverse each pair of intersecting edges whose reversal shortens the cycle by more
|
// Crossing removal: reverse any segment pair whose edges geometrically cross.
|
||||||
// than SCALED_EPSILON. Returns true if any pair was reversed.
|
// Returns true if any crossing was removed.
|
||||||
bool tsp_remove_crossings(std::vector<size_t>& path, const Points& centers);
|
bool tsp_remove_crossings(std::vector<size_t>& path, const Points& centers);
|
||||||
|
|
||||||
// The shortest cycle through the points, starting at point 0, by dynamic programming over subsets; for a few points only.
|
// The shortest cycle through the points, starting at point 0, by dynamic programming over subsets; for a few points only.
|
||||||
|
|||||||
@@ -797,7 +797,7 @@ bool Preset::save(DynamicPrintConfig* parent_config)
|
|||||||
{
|
{
|
||||||
ConfigOption *opt_src = config.option(option);
|
ConfigOption *opt_src = config.option(option);
|
||||||
ConfigOption *opt_dst = temp_config.option(option, true);
|
ConfigOption *opt_dst = temp_config.option(option, true);
|
||||||
if (opt_dst->is_scalar() || !(opt_dst->nullable()))
|
if (opt_dst->is_scalar() || !(opt_dst->nullable()) || is_filament_extruder_override_key(option))
|
||||||
opt_dst->set(opt_src);
|
opt_dst->set(opt_src);
|
||||||
else {
|
else {
|
||||||
ConfigOptionVectorBase* opt_vec_src = static_cast<ConfigOptionVectorBase*>(opt_src);
|
ConfigOptionVectorBase* opt_vec_src = static_cast<ConfigOptionVectorBase*>(opt_src);
|
||||||
@@ -2132,7 +2132,7 @@ Preset* PresetCollection::get_preset_differed_for_save(Preset& preset)
|
|||||||
{
|
{
|
||||||
ConfigOption *opt_src = preset.config.option(option);
|
ConfigOption *opt_src = preset.config.option(option);
|
||||||
ConfigOption *opt_dst = temp_config.option(option, true);
|
ConfigOption *opt_dst = temp_config.option(option, true);
|
||||||
if (opt_dst->is_scalar() || !(opt_dst->nullable()))
|
if (opt_dst->is_scalar() || !(opt_dst->nullable()) || is_filament_extruder_override_key(option))
|
||||||
opt_dst->set(opt_src);
|
opt_dst->set(opt_src);
|
||||||
else {
|
else {
|
||||||
ConfigOptionVectorBase* opt_vec_src = static_cast<ConfigOptionVectorBase*>(opt_src);
|
ConfigOptionVectorBase* opt_vec_src = static_cast<ConfigOptionVectorBase*>(opt_src);
|
||||||
@@ -2199,9 +2199,12 @@ int PresetCollection::get_differed_values_to_update(Preset& preset, std::map<std
|
|||||||
// option's non-nil default (e.g. filament_retract_before_wipe -> 100%), silently
|
// option's non-nil default (e.g. filament_retract_before_wipe -> 100%), silently
|
||||||
// resurrecting an override the user turned off. See GitHub issue on Retract Before Wipe.
|
// resurrecting an override the user turned off. See GitHub issue on Retract Before Wipe.
|
||||||
if (m_type == Preset::TYPE_FILAMENT) {
|
if (m_type == Preset::TYPE_FILAMENT) {
|
||||||
for (const std::string& opt_key : filament_extruder_override_keys) {
|
for (const std::string& opt_key : preset.config.keys()) {
|
||||||
if (key_values.count(opt_key))
|
if (!is_filament_extruder_override_key(opt_key) || key_values.count(opt_key))
|
||||||
continue; // already carried by the diff
|
continue; // not an override, or already carried by the diff
|
||||||
|
const ConfigOptionDef *def = print_config_def.get(opt_key);
|
||||||
|
if (def == nullptr || !def->default_value || def->default_value->is_nil())
|
||||||
|
continue; // the option's default is nil too, so an absent key cannot come back as a value
|
||||||
const auto* opt_vec = dynamic_cast<const ConfigOptionVectorBase*>(preset.config.option(opt_key));
|
const auto* opt_vec = dynamic_cast<const ConfigOptionVectorBase*>(preset.config.option(opt_key));
|
||||||
if (opt_vec == nullptr)
|
if (opt_vec == nullptr)
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -119,12 +119,18 @@ const std::vector<std::string> filament_extruder_override_keys = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Some filament override parameters are generated from filament_extruder_override_keys,
|
// Some filament override parameters are generated from filament_extruder_override_keys,
|
||||||
// while filament_retract_length_nc is defined separately. Keep the generator list
|
// while filament_retract_length_nc and the ironing overrides are defined separately.
|
||||||
// unchanged and use this helper for behavior checks that need the full override set.
|
// Keep the generator list unchanged and use this helper for behavior checks that need
|
||||||
|
// the full override set. A nil in one of these keys means the override is disabled, so
|
||||||
|
// child presets store and load it verbatim instead of treating nil as "inherit".
|
||||||
bool is_filament_extruder_override_key(const std::string &opt_key)
|
bool is_filament_extruder_override_key(const std::string &opt_key)
|
||||||
{
|
{
|
||||||
return std::find(filament_extruder_override_keys.begin(), filament_extruder_override_keys.end(), opt_key) != filament_extruder_override_keys.end() ||
|
return std::find(filament_extruder_override_keys.begin(), filament_extruder_override_keys.end(), opt_key) != filament_extruder_override_keys.end() ||
|
||||||
opt_key == "filament_retract_length_nc";
|
opt_key == "filament_retract_length_nc" ||
|
||||||
|
opt_key == "filament_ironing_flow" ||
|
||||||
|
opt_key == "filament_ironing_spacing" ||
|
||||||
|
opt_key == "filament_ironing_inset" ||
|
||||||
|
opt_key == "filament_ironing_speed";
|
||||||
}
|
}
|
||||||
|
|
||||||
size_t get_extruder_index(const GCodeConfig& config, unsigned int filament_id)
|
size_t get_extruder_index(const GCodeConfig& config, unsigned int filament_id)
|
||||||
@@ -12272,7 +12278,8 @@ void DynamicPrintConfig::update_diff_values_to_child_config(DynamicPrintConfig&
|
|||||||
opt_src->set(opt_target);
|
opt_src->set(opt_target);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
opt_vec_src->set_only_diff(opt_vec_dest, variant_index, stride);
|
// An override's nil means "disabled" and is the child's value; every other nil column inherits.
|
||||||
|
opt_vec_src->set_only_diff(opt_vec_dest, variant_index, stride, is_filament_extruder_override_key(opt));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -4,10 +4,9 @@
|
|||||||
// Via pybind11 so this file requests the same python3xx.lib as everything else.
|
// Via pybind11 so this file requests the same python3xx.lib as everything else.
|
||||||
#include <boost/filesystem/path.hpp>
|
#include <boost/filesystem/path.hpp>
|
||||||
#include <pybind11/conduit/wrap_include_python_h.h>
|
#include <pybind11/conduit/wrap_include_python_h.h>
|
||||||
|
#include <functional>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_map>
|
|
||||||
#include <unordered_set>
|
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include <boost/filesystem.hpp>
|
#include <boost/filesystem.hpp>
|
||||||
@@ -138,21 +137,55 @@ 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();
|
||||||
void clear_last_violation();
|
void clear_last_violation();
|
||||||
bool last_violation(AuditViolation& violation) const;
|
bool last_violation(AuditViolation& violation) const;
|
||||||
|
|
||||||
// --- call-site cascade cache ---
|
// --- operation-root frames ---
|
||||||
// A single plugin action often fires several nested CPython audit events as it passes
|
// One stdlib operation fires several nested audit events as it passes through lower layers:
|
||||||
// through stdlib layers (urllib.request calling http.client calling socket, for example).
|
// urlopen() raises urllib.Request, then http.client and socket events. When an operation's
|
||||||
// Once the user approves one event, every stdlib frame still on the stack for that call
|
// own root event is granted (by a saved grant or the user), the stdlib frame that raised it
|
||||||
// is recorded here by (filename, function, first line) identity. A later event whose own
|
// is held, and a later event whose stack reaches that frame through stdlib frames only is
|
||||||
// ancestor chain still contains one of those frames is the same logical action seen from
|
// part of the same operation, so it is allowed without a prompt of its own. Plugin or
|
||||||
// a deeper layer, so it is auto-approved instead of prompting again.
|
// library code the operation calls back into is judged on its own. A frame is let go once it
|
||||||
bool has_approved_ancestor(const std::string& plugin_key, const std::vector<std::string>& call_site_ids) const;
|
// returns, a suspended generator's (os.walk) when the audit scope ends. Letting a frame go may
|
||||||
void record_approved_call_sites(const std::string& plugin_key, const std::vector<std::string>& call_site_ids);
|
// run finalizers of objects its locals held; they are judged as the plugin and never prompt.
|
||||||
|
void record_root_frame();
|
||||||
|
bool covered_by_root_frame() const;
|
||||||
|
bool releasing_frames() const;
|
||||||
|
|
||||||
|
// True when code from `filename` (a code object's co_filename) is the interpreter's own
|
||||||
|
// stdlib: the only code a root frame's coverage passes through. Valid after install_hook().
|
||||||
|
static bool is_stdlib_file(const std::string& filename);
|
||||||
|
|
||||||
|
// Answers permission prompts in place of the dialog, for tests. Production never sets it.
|
||||||
|
using PermissionPrompt = std::function<bool(const std::string& event_name, const std::vector<std::string>& targets)>;
|
||||||
|
static void set_permission_prompt_for_testing(PermissionPrompt prompt);
|
||||||
|
|
||||||
bool verbose_events = true;
|
bool verbose_events = true;
|
||||||
|
|
||||||
@@ -163,24 +196,33 @@ private:
|
|||||||
|
|
||||||
static int audit_hook(const char* event, PyObject* args, void* user_data);
|
static int audit_hook(const char* event, PyObject* args, void* user_data);
|
||||||
|
|
||||||
|
// Drops the references in `frames`, keeping the caller's error and denial state intact.
|
||||||
|
void release_frames(std::vector<PyObject*>& frames);
|
||||||
|
|
||||||
static thread_local std::string m_current_plugin_key;
|
static thread_local std::string m_current_plugin_key;
|
||||||
static thread_local std::string m_current_capability_name;
|
static thread_local std::string m_current_capability_name;
|
||||||
static thread_local std::vector<AllowedRoot> m_scoped_allowed_roots;
|
static thread_local std::vector<AllowedRoot> m_scoped_allowed_roots;
|
||||||
|
static thread_local std::vector<PyObject*> m_root_frames; // strong references
|
||||||
|
static thread_local int m_releasing_frames;
|
||||||
static thread_local bool m_audit_denial_pending;
|
static thread_local bool m_audit_denial_pending;
|
||||||
static thread_local bool m_has_last_violation;
|
static thread_local bool m_has_last_violation;
|
||||||
static thread_local AuditViolation m_last_violation;
|
static thread_local AuditViolation m_last_violation;
|
||||||
|
|
||||||
// mutable: is_denied_filename() and has_approved_ancestor() are const queries that must lock.
|
// mutable: is_denied_filename() is a const query that must lock.
|
||||||
mutable std::mutex m_mutex;
|
mutable std::mutex m_mutex;
|
||||||
|
|
||||||
std::vector<AllowedRoot> m_global_allowed_roots;
|
std::vector<AllowedRoot> m_global_allowed_roots;
|
||||||
std::vector<std::string> m_denied_filenames;
|
std::vector<std::string> m_denied_filenames;
|
||||||
std::vector<std::string> m_denied_path_keywords;
|
std::vector<std::string> m_denied_path_keywords;
|
||||||
std::unordered_map<std::string, std::unordered_set<std::string>> m_approved_call_sites; // plugin_key -> call-site ids
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// RAII guard that sets the current plugin key and capability name, restoring the previous
|
// RAII guard that sets the current plugin key and capability name, restoring the previous
|
||||||
// pair on scope exit. `capability_name` may be empty for calls that are not scoped to a
|
// pair on scope exit. `capability_name` may be empty for calls that are not scoped to a
|
||||||
// single capability.
|
// single capability.
|
||||||
|
//
|
||||||
|
// Each scope owns the root frames recorded inside it and lets them go, as its own plugin, before
|
||||||
|
// restoring the outer context. That needs the GIL whenever a frame is still held, so construct
|
||||||
|
// the scope after the GIL guard, to be destroyed before it.
|
||||||
class ScopedPluginAuditContext
|
class ScopedPluginAuditContext
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
@@ -194,9 +236,12 @@ public:
|
|||||||
ScopedPluginAuditContext& operator=(const ScopedPluginAuditContext&) = delete;
|
ScopedPluginAuditContext& operator=(const ScopedPluginAuditContext&) = delete;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
std::string m_plugin_key;
|
||||||
|
std::string m_capability;
|
||||||
std::string m_previous_id;
|
std::string m_previous_id;
|
||||||
std::string m_previous_capability;
|
std::string m_previous_capability;
|
||||||
std::vector<AllowedRoot> m_previous_scoped_roots;
|
std::vector<AllowedRoot> m_previous_scoped_roots;
|
||||||
|
std::vector<PyObject*> m_previous_root_frames;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace Slic3r
|
} // namespace Slic3r
|
||||||
|
|||||||
@@ -808,6 +808,7 @@ bool read_install_state(const boost::filesystem::path& plugin_dir, PluginInstall
|
|||||||
read_string_list("network_socket", parsed.permissions.network_socket);
|
read_string_list("network_socket", parsed.permissions.network_socket);
|
||||||
read_string_list("process", parsed.permissions.process);
|
read_string_list("process", parsed.permissions.process);
|
||||||
read_string_list("threading", parsed.permissions.threading);
|
read_string_list("threading", parsed.permissions.threading);
|
||||||
|
read_string_list("events", parsed.permissions.events);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.contains("enabled") && state["enabled"].is_boolean())
|
if (state.contains("enabled") && state["enabled"].is_boolean())
|
||||||
@@ -832,6 +833,29 @@ 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;
|
||||||
@@ -852,6 +876,7 @@ bool write_install_state(const boost::filesystem::path& plugin_dir, const Plugin
|
|||||||
{"network_socket", state.permissions.network_socket},
|
{"network_socket", state.permissions.network_socket},
|
||||||
{"process", state.permissions.process},
|
{"process", state.permissions.process},
|
||||||
{"threading", state.permissions.threading},
|
{"threading", state.permissions.threading},
|
||||||
|
{"events", state.permissions.events},
|
||||||
};
|
};
|
||||||
|
|
||||||
nlohmann::json capabilities = nlohmann::json::array();
|
nlohmann::json capabilities = nlohmann::json::array();
|
||||||
@@ -874,16 +899,9 @@ 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);
|
||||||
state.installed_from = entry.is_cloud_plugin() ? "cloud" : "local";
|
apply_install_metadata(state, entry);
|
||||||
// Prefer the descriptor's recorded installed_version (the version fetched from the cloud
|
state.enabled = enabled;
|
||||||
// at install time, preserved across sidecar re-writes) so a stale manifest/PEP723 header
|
state.capabilities = capabilities;
|
||||||
// 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -894,11 +912,7 @@ 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;
|
||||||
state.installed_from = entry.is_cloud_plugin() ? "cloud" : "local";
|
seed_install_metadata(state, entry);
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ struct PluginPermissions
|
|||||||
std::vector<std::string> network_socket;
|
std::vector<std::string> network_socket;
|
||||||
std::vector<std::string> process;
|
std::vector<std::string> process;
|
||||||
std::vector<std::string> threading;
|
std::vector<std::string> threading;
|
||||||
|
// Grants for audit events that expose no target, keyed "<category>:<event>" (e.g. "Socket:socket.__new__").
|
||||||
|
std::vector<std::string> events;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct PluginInstallState {
|
struct PluginInstallState {
|
||||||
@@ -184,12 +186,19 @@ 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);
|
||||||
|
|||||||
@@ -1180,12 +1180,8 @@ 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))
|
||||||
state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local";
|
seed_install_metadata(state, descriptor);
|
||||||
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;
|
||||||
|
|||||||
@@ -102,9 +102,9 @@ public:
|
|||||||
// mistake), both fall back to the base's empty object rather than writing `"cap_config": null`.
|
// mistake), both fall back to the base's empty object rather than writing `"cap_config": null`.
|
||||||
nlohmann::json get_default_config() const override
|
nlohmann::json get_default_config() const override
|
||||||
{
|
{
|
||||||
|
pybind11::gil_scoped_acquire gil;
|
||||||
ORCA_PY_AUDIT_SCOPE();
|
ORCA_PY_AUDIT_SCOPE();
|
||||||
try {
|
try {
|
||||||
pybind11::gil_scoped_acquire gil;
|
|
||||||
pybind11::function override = pybind11::get_override(static_cast<const Base*>(this), "get_default_config");
|
pybind11::function override = pybind11::get_override(static_cast<const Base*>(this), "get_default_config");
|
||||||
if (!override)
|
if (!override)
|
||||||
return Base::get_default_config();
|
return Base::get_default_config();
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ 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
|
||||||
@@ -89,6 +90,7 @@ 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
|
||||||
@@ -114,6 +116,7 @@ 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.
|
||||||
@@ -145,6 +148,7 @@ 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 {
|
||||||
@@ -200,6 +204,30 @@ 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();
|
||||||
@@ -323,6 +351,7 @@ 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)
|
||||||
@@ -350,6 +379,7 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -359,6 +389,7 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,24 +589,50 @@ 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>& fs_read, const std::vector<std::string>& network) {
|
||||||
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");
|
||||||
|
|
||||||
std::lock_guard<std::mutex> lock(g_registry_mutex);
|
// Validate and canonicalize everything before touching the pending registry, so a
|
||||||
auto& requested = g_pending_fs_read_permissions[g_active_plugin_key];
|
// partially invalid call cannot leave a half-recorded request behind.
|
||||||
|
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("fs_read") = std::vector<std::string>{}, py::arg("network") = std::vector<std::string>{},
|
||||||
R"pbdoc(Request filesystem read permissions while OrcaSlicer loads your module.
|
R"pbdoc(Request permissions while OrcaSlicer loads your module.
|
||||||
|
|
||||||
The host presents the request to the user after register_capabilities() returns. Denying the
|
`fs_read` lists filesystem paths to read. `network` lists http(s) URLs; each is reduced to its
|
||||||
request aborts the plugin load.)pbdoc");
|
scheme and host, so one grant covers every path, query and port on that host. The host presents
|
||||||
|
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())
|
||||||
|
|||||||
+1
-1
@@ -282,11 +282,11 @@ public:
|
|||||||
int request_bind_ticket(std::string* ticket) override
|
int request_bind_ticket(std::string* ticket) override
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
ORCA_PY_AUDIT_SCOPE();
|
|
||||||
::Slic3r::PluginCapabilityInterface::RefCounter _orca_ref_counter(*this);
|
::Slic3r::PluginCapabilityInterface::RefCounter _orca_ref_counter(*this);
|
||||||
::Slic3r::PythonGILState gil;
|
::Slic3r::PythonGILState gil;
|
||||||
if (!gil)
|
if (!gil)
|
||||||
throw std::runtime_error("Python interpreter is shutting down");
|
throw std::runtime_error("Python interpreter is shutting down");
|
||||||
|
ORCA_PY_AUDIT_SCOPE();
|
||||||
pybind11::function override =
|
pybind11::function override =
|
||||||
pybind11::get_override(static_cast<const PrinterAgentPluginCapability*>(this), "request_bind_ticket");
|
pybind11::get_override(static_cast<const PrinterAgentPluginCapability*>(this), "request_bind_ticket");
|
||||||
if (!override)
|
if (!override)
|
||||||
|
|||||||
@@ -1263,6 +1263,55 @@ TEST_CASE("read_cli rejects an invalid scalar numeric value", "[Config]") {
|
|||||||
REQUIRE_FALSE(config.read_cli(3, argv, &extra, &keys));
|
REQUIRE_FALSE(config.read_cli(3, argv, &extra, &keys));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A scalar option keeps its value when deserializing fails", "[Config]") {
|
||||||
|
using Catch::Matchers::WithinAbs;
|
||||||
|
|
||||||
|
SECTION("float") {
|
||||||
|
ConfigOptionFloat opt(7.5);
|
||||||
|
REQUIRE_FALSE(opt.deserialize("abc"));
|
||||||
|
CHECK_THAT(opt.value, WithinAbs(7.5, 1e-9));
|
||||||
|
}
|
||||||
|
SECTION("int") {
|
||||||
|
ConfigOptionInt opt(3);
|
||||||
|
REQUIRE_FALSE(opt.deserialize("abc"));
|
||||||
|
CHECK(opt.value == 3);
|
||||||
|
}
|
||||||
|
SECTION("percent") {
|
||||||
|
ConfigOptionPercent opt(40.);
|
||||||
|
REQUIRE_FALSE(opt.deserialize("abc%"));
|
||||||
|
CHECK_THAT(opt.value, WithinAbs(40., 1e-9));
|
||||||
|
}
|
||||||
|
SECTION("float or percent") {
|
||||||
|
ConfigOptionFloatOrPercent opt(0.4, false);
|
||||||
|
REQUIRE_FALSE(opt.deserialize("abc%"));
|
||||||
|
CHECK_THAT(opt.value, WithinAbs(0.4, 1e-9));
|
||||||
|
CHECK_FALSE(opt.percent);
|
||||||
|
}
|
||||||
|
SECTION("point") {
|
||||||
|
ConfigOptionPoint opt(Vec2d(1., 2.));
|
||||||
|
REQUIRE_FALSE(opt.deserialize("5,abc"));
|
||||||
|
CHECK(opt.value == Vec2d(1., 2.));
|
||||||
|
}
|
||||||
|
SECTION("point3") {
|
||||||
|
ConfigOptionPoint3 opt(Vec3d(1., 2., 3.));
|
||||||
|
REQUIRE_FALSE(opt.deserialize("5,6,abc"));
|
||||||
|
CHECK(opt.value == Vec3d(1., 2., 3.));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A scalar option still takes a valid value", "[Config]") {
|
||||||
|
using Catch::Matchers::WithinAbs;
|
||||||
|
|
||||||
|
ConfigOptionFloatOrPercent opt(0.4, false);
|
||||||
|
REQUIRE(opt.deserialize("50%"));
|
||||||
|
CHECK_THAT(opt.value, WithinAbs(50., 1e-9));
|
||||||
|
CHECK(opt.percent);
|
||||||
|
|
||||||
|
ConfigOptionPoint point;
|
||||||
|
REQUIRE(point.deserialize("3x4"));
|
||||||
|
CHECK(point.value == Vec2d(3., 4.));
|
||||||
|
}
|
||||||
|
|
||||||
TEST_CASE("read_cli appends values when a vector option is repeated", "[Config]") {
|
TEST_CASE("read_cli appends values when a vector option is repeated", "[Config]") {
|
||||||
Slic3r::DynamicPrintConfig config;
|
Slic3r::DynamicPrintConfig config;
|
||||||
t_config_option_keys extra, keys;
|
t_config_option_keys extra, keys;
|
||||||
@@ -1678,3 +1727,75 @@ TEST_CASE("Default options of enum lists get their definition's keys map", "[Con
|
|||||||
}
|
}
|
||||||
CHECK(enum_lists > 0);
|
CHECK(enum_lists > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A disabled filament override survives the merge with a parent that sets it", "[Config][Variant]")
|
||||||
|
{
|
||||||
|
std::set<std::string> no_keys;
|
||||||
|
Slic3r::DynamicPrintConfig parent;
|
||||||
|
parent.set_deserialize_strict("filament_retract_before_wipe", "100%");
|
||||||
|
parent.set_deserialize_strict("filament_ironing_flow", "15%");
|
||||||
|
Slic3r::DynamicPrintConfig child;
|
||||||
|
child.set_deserialize_strict("filament_retract_before_wipe", "nil");
|
||||||
|
child.set_deserialize_strict("filament_ironing_flow", "nil");
|
||||||
|
parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant",
|
||||||
|
Slic3r::filament_options_with_variant, no_keys);
|
||||||
|
CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil");
|
||||||
|
CHECK(parent.opt_serialize("filament_ironing_flow") == "nil");
|
||||||
|
}
|
||||||
|
|
||||||
|
SCENARIO("A two-variant child's disabled override column is matched to the parent's variant by name", "[Config][Variant]")
|
||||||
|
{
|
||||||
|
std::set<std::string> no_keys;
|
||||||
|
GIVEN("A two-variant filament parent") {
|
||||||
|
Slic3r::DynamicPrintConfig parent;
|
||||||
|
parent.set_deserialize_strict("filament_extruder_variant", "Direct Drive Standard;Direct Drive High Flow");
|
||||||
|
parent.set_deserialize_strict("filament_retract_before_wipe", "100%,100%");
|
||||||
|
parent.set_deserialize_strict("filament_flow_ratio", "1,1");
|
||||||
|
WHEN("the child disables the override on one variant and leaves a plain option nil on it") {
|
||||||
|
Slic3r::DynamicPrintConfig child;
|
||||||
|
child.set_deserialize_strict("filament_extruder_variant", "Direct Drive Standard;Direct Drive High Flow");
|
||||||
|
child.set_deserialize_strict("filament_retract_before_wipe", "nil,100%");
|
||||||
|
child.set_deserialize_strict("filament_flow_ratio", "nil,1.1");
|
||||||
|
parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant",
|
||||||
|
Slic3r::filament_options_with_variant, no_keys);
|
||||||
|
THEN("the override column is disabled while the plain option's nil column inherits") {
|
||||||
|
CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil,100%");
|
||||||
|
CHECK(parent.opt_serialize("filament_flow_ratio") == "1,1.1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WHEN("the child lists the same variants in the other order") {
|
||||||
|
Slic3r::DynamicPrintConfig child;
|
||||||
|
child.set_deserialize_strict("filament_extruder_variant", "Direct Drive High Flow;Direct Drive Standard");
|
||||||
|
child.set_deserialize_strict("filament_retract_before_wipe", "70%,nil");
|
||||||
|
child.set_deserialize_strict("filament_flow_ratio", "1.1,nil");
|
||||||
|
parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant",
|
||||||
|
Slic3r::filament_options_with_variant, no_keys);
|
||||||
|
THEN("columns are matched by variant name, the disabled one included") {
|
||||||
|
CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil,70%");
|
||||||
|
CHECK(parent.opt_serialize("filament_flow_ratio") == "1,1.1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WHEN("the child was saved with a single variant") {
|
||||||
|
Slic3r::DynamicPrintConfig child;
|
||||||
|
child.set_deserialize_strict("filament_extruder_variant", "Direct Drive Standard");
|
||||||
|
child.set_deserialize_strict("filament_retract_before_wipe", "nil");
|
||||||
|
parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant",
|
||||||
|
Slic3r::filament_options_with_variant, no_keys);
|
||||||
|
THEN("its one value applies to every variant of that extruder, as slicing does") {
|
||||||
|
CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil,nil");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Every listed filament override is nullable", "[Config]")
|
||||||
|
{
|
||||||
|
std::set<std::string> overrides(Slic3r::filament_extruder_override_keys.begin(), Slic3r::filament_extruder_override_keys.end());
|
||||||
|
overrides.insert({ "filament_retract_length_nc", "filament_ironing_flow", "filament_ironing_spacing", "filament_ironing_inset", "filament_ironing_speed" });
|
||||||
|
for (const auto &[key, def] : Slic3r::print_config_def.options) {
|
||||||
|
INFO(key);
|
||||||
|
CHECK(Slic3r::is_filament_extruder_override_key(key) == (overrides.count(key) == 1));
|
||||||
|
if (overrides.count(key) == 1)
|
||||||
|
CHECK(def.nullable);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
#include <catch2/catch_test_macros.hpp>
|
#include <catch2/catch_test_macros.hpp>
|
||||||
#include <catch2/generators/catch_generators.hpp>
|
#include <catch2/generators/catch_generators.hpp>
|
||||||
#include <catch2/catch_all.hpp>
|
#include <catch2/catch_all.hpp>
|
||||||
#include <catch2/generators/catch_generators.hpp>
|
|
||||||
#include <catch2/matchers/catch_matchers.hpp>
|
|
||||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <math.h>
|
#include <math.h>
|
||||||
@@ -18,11 +15,8 @@
|
|||||||
#include "libslic3r/GCode/OrderingStrategies.hpp"
|
#include "libslic3r/GCode/OrderingStrategies.hpp"
|
||||||
#include "libslic3r/ShortestPath.hpp"
|
#include "libslic3r/ShortestPath.hpp"
|
||||||
#include "libslic3r/Geometry.hpp"
|
#include "libslic3r/Geometry.hpp"
|
||||||
#include "libslic3r/ShortestPath.hpp"
|
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <numeric>
|
|
||||||
#include <random>
|
|
||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
#include <numeric>
|
#include <numeric>
|
||||||
#include <random>
|
#include <random>
|
||||||
@@ -57,73 +51,6 @@ static bool has_crossings(const std::vector<size_t>& path, const Points& centers
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Intersecting edge pairs whose reversal would shorten the cycle by more than SCALED_EPSILON, which
|
|
||||||
// tsp_remove_crossings must leave none of.
|
|
||||||
static size_t count_shortening_crossings(const std::vector<size_t>& path, const Points& centers)
|
|
||||||
{
|
|
||||||
size_t pn = path.size();
|
|
||||||
size_t count = 0;
|
|
||||||
for (size_t i = 0; i < pn; ++i) {
|
|
||||||
for (size_t j = i + 2; j < pn; ++j) {
|
|
||||||
if (i == 0 && j == pn - 1) continue;
|
|
||||||
const Point& ai = centers[path[i]];
|
|
||||||
const Point& bi = centers[path[(i + 1) % pn]];
|
|
||||||
const Point& aj = centers[path[j]];
|
|
||||||
const Point& bj = centers[path[(j + 1) % pn]];
|
|
||||||
if (Geometry::segments_intersect(ai, bi, aj, bj) &&
|
|
||||||
ai.distance_to(bi) + aj.distance_to(bj) - ai.distance_to(aj) - bi.distance_to(bj) > SCALED_EPSILON)
|
|
||||||
++count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exhaustive best-improvement 2-opt: each pass tests every pair of edges and applies the move with the largest
|
|
||||||
// gain, the first (i, j) on a tie. tsp_2opt_improve must make the same moves.
|
|
||||||
static bool exhaustive_2opt(std::vector<size_t>& path, const Points& centers, int max_passes)
|
|
||||||
{
|
|
||||||
size_t pn = path.size();
|
|
||||||
if (pn <= 2) return false;
|
|
||||||
std::vector<double> el(pn), elsq(pn);
|
|
||||||
auto recompute = [&]() {
|
|
||||||
for (size_t i = 0; i < pn; ++i) {
|
|
||||||
el[i] = (centers[path[i]].cast<double>() - centers[path[(i + 1) % pn]].cast<double>()).norm();
|
|
||||||
elsq[i] = (centers[path[i]].cast<double>() - centers[path[(i + 1) % pn]].cast<double>()).squaredNorm();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
recompute();
|
|
||||||
bool improved = false;
|
|
||||||
for (int pass = 0; max_passes <= 0 || pass < max_passes; ++pass) {
|
|
||||||
size_t best_i = pn, best_j = pn;
|
|
||||||
double best_gain = 0;
|
|
||||||
for (size_t i = 0; i < pn; ++i) {
|
|
||||||
const Vec2d pi = centers[path[i]].cast<double>();
|
|
||||||
const Vec2d p_in = centers[path[(i + 1) % pn]].cast<double>();
|
|
||||||
for (size_t j = i + 2; j < pn; ++j) {
|
|
||||||
size_t j_next = (j + 1) % pn;
|
|
||||||
if (i == 0 && j_next == 0) continue;
|
|
||||||
const Vec2d pj = centers[path[j]].cast<double>();
|
|
||||||
const Vec2d p_jn = centers[path[j_next]].cast<double>();
|
|
||||||
double new_a_sq = (pj - pi).squaredNorm();
|
|
||||||
double new_b_sq = (p_jn - p_in).squaredNorm();
|
|
||||||
if (new_a_sq >= elsq[i] && new_b_sq >= elsq[j]) continue;
|
|
||||||
double new_a = std::sqrt(new_a_sq);
|
|
||||||
double new_b = std::sqrt(new_b_sq);
|
|
||||||
double gain = el[i] + el[j] - new_a - new_b;
|
|
||||||
if (gain > best_gain) {
|
|
||||||
best_gain = gain;
|
|
||||||
best_i = i; best_j = j;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (best_i == pn) break;
|
|
||||||
improved = true;
|
|
||||||
std::reverse(path.begin() + best_i + 1, path.begin() + best_j + 1);
|
|
||||||
recompute();
|
|
||||||
}
|
|
||||||
return improved;
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool is_permutation(const std::vector<size_t>& path, size_t n)
|
static bool is_permutation(const std::vector<size_t>& path, size_t n)
|
||||||
{
|
{
|
||||||
if (path.size() != n) return false;
|
if (path.size() != n) return false;
|
||||||
@@ -195,42 +122,6 @@ TEST_CASE("tsp_2opt_improve reduces path length", "[TSPPostProcessing]") {
|
|||||||
CHECK(after <= before);
|
CHECK(after <= before);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("tsp_2opt_improve makes the same moves as an exhaustive best-improvement scan", "[TSPPostProcessing]") {
|
|
||||||
// 300 points in a 170000-unit square: random on a 1000-unit grid like island centroids snapped to 1 mm (many
|
|
||||||
// share a position or a line), random at full resolution, or a 20 x 15 lattice with a 1000-unit pitch, whose
|
|
||||||
// congruent cells give many moves exactly the same gain. The coordinates are small enough that every squared
|
|
||||||
// distance is an exact double, so both implementations compute bit-identical gains even where the compiler
|
|
||||||
// fuses multiply-adds. The tour is either the nearest-neighbour tour the island ordering starts from, or the
|
|
||||||
// input order, whose long edges make every radius query large. Both the default pass cap and an uncapped run
|
|
||||||
// to a 2-opt local optimum are compared.
|
|
||||||
enum class Layout { Snapped, Fine, Lattice };
|
|
||||||
const Layout layout = GENERATE(Layout::Snapped, Layout::Fine, Layout::Lattice);
|
|
||||||
const bool nn_tour = GENERATE(true, false);
|
|
||||||
const int max_passes = GENERATE(10, 0);
|
|
||||||
std::mt19937 rng(GENERATE(1u, 2u));
|
|
||||||
Points centers;
|
|
||||||
for (int i = 0; i < 300; ++i) {
|
|
||||||
if (layout == Layout::Lattice) {
|
|
||||||
centers.emplace_back(coord_t(i % 20) * 1000, coord_t(i / 20) * 1000);
|
|
||||||
} else {
|
|
||||||
const coord_t x = layout == Layout::Snapped ? coord_t(rng() % 171) * 1000 : coord_t(rng() % 170001);
|
|
||||||
const coord_t y = layout == Layout::Snapped ? coord_t(rng() % 171) * 1000 : coord_t(rng() % 170001);
|
|
||||||
centers.emplace_back(x, y);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
std::vector<size_t> path(centers.size());
|
|
||||||
if (nn_tour)
|
|
||||||
path = chain_points(centers);
|
|
||||||
else
|
|
||||||
std::iota(path.begin(), path.end(), size_t(0));
|
|
||||||
|
|
||||||
std::vector<size_t> expected = path;
|
|
||||||
REQUIRE(exhaustive_2opt(expected, centers, max_passes));
|
|
||||||
|
|
||||||
CHECK(tsp_2opt_improve(path, centers, max_passes));
|
|
||||||
CHECK(path == expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("tsp_remove_crossings eliminates crossings", "[TSPPostProcessing]") {
|
TEST_CASE("tsp_remove_crossings eliminates crossings", "[TSPPostProcessing]") {
|
||||||
Points centers = make_random_16();
|
Points centers = make_random_16();
|
||||||
std::vector<size_t> path(centers.size());
|
std::vector<size_t> path(centers.size());
|
||||||
@@ -245,76 +136,6 @@ TEST_CASE("tsp_remove_crossings eliminates crossings", "[TSPPostProcessing]") {
|
|||||||
REQUIRE(is_permutation(path, centers.size()));
|
REQUIRE(is_permutation(path, centers.size()));
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("tsp_remove_crossings leaves a tour alone when no reversal shortens it", "[TSPPostProcessing]") {
|
|
||||||
// Three points on a diagonal, the middle one doubled as island centroids snapped to the same 1 mm cell are,
|
|
||||||
// and a corner closing the tour. Edges 0-1 and 2-3 touch at the doubled point, and reversing between them
|
|
||||||
// only swaps the two copies.
|
|
||||||
Points centers;
|
|
||||||
centers.emplace_back(0, 0);
|
|
||||||
centers.emplace_back(3000000, 3000000);
|
|
||||||
centers.emplace_back(3000000, 3000000);
|
|
||||||
centers.emplace_back(6000000, 6000000);
|
|
||||||
centers.emplace_back(6000000, 0);
|
|
||||||
std::vector<size_t> path = {0, 1, 2, 3, 4};
|
|
||||||
REQUIRE(Geometry::segments_intersect(centers[0], centers[1], centers[2], centers[3]));
|
|
||||||
|
|
||||||
CHECK(!tsp_remove_crossings(path, centers));
|
|
||||||
CHECK(path == std::vector<size_t>{0, 1, 2, 3, 4});
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("tsp_remove_crossings keeps a crossing whose reversal gains less than SCALED_EPSILON", "[TSPPostProcessing]") {
|
|
||||||
// Two 100 mm edges crossing at a 10 nm offset: uncrossing them saves about 1e-6 scaled units of travel.
|
|
||||||
Points centers;
|
|
||||||
centers.emplace_back(0, 0);
|
|
||||||
centers.emplace_back(100000000, 10);
|
|
||||||
centers.emplace_back(100000000, 0);
|
|
||||||
centers.emplace_back(0, 10);
|
|
||||||
std::vector<size_t> path = {0, 1, 2, 3};
|
|
||||||
REQUIRE(Geometry::segments_intersect(centers[0], centers[1], centers[2], centers[3]));
|
|
||||||
|
|
||||||
CHECK(!tsp_remove_crossings(path, centers));
|
|
||||||
CHECK(path == std::vector<size_t>{0, 1, 2, 3});
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("tsp_remove_crossings reverses overlapping collinear edges when that shortens the tour", "[TSPPostProcessing]") {
|
|
||||||
// Points at x = 0, 3, 1 and 2 mm on one line. Tour 0-3-1-2 is 8 mm long, and its edges [0,3] and [1,2] overlap.
|
|
||||||
// The shortest tour through points on a line is twice their 3 mm span.
|
|
||||||
Points centers;
|
|
||||||
centers.emplace_back(0, 0);
|
|
||||||
centers.emplace_back(3000000, 0);
|
|
||||||
centers.emplace_back(1000000, 0);
|
|
||||||
centers.emplace_back(2000000, 0);
|
|
||||||
std::vector<size_t> path = {0, 1, 2, 3};
|
|
||||||
REQUIRE_THAT(tsp_cycle_path_length(path, centers), Catch::Matchers::WithinAbs(8000000., 1.));
|
|
||||||
|
|
||||||
CHECK(tsp_remove_crossings(path, centers));
|
|
||||||
REQUIRE(is_permutation(path, centers.size()));
|
|
||||||
CHECK_THAT(tsp_cycle_path_length(path, centers), Catch::Matchers::WithinAbs(6000000., 1.));
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("tsp_remove_crossings leaves no shortening crossing in a tour of thousands of grid-snapped points", "[TSPPostProcessing]") {
|
|
||||||
// 4000 island centroids on a 170 mm plate, snapped to 1 mm as GCode::process_layer does, so many of them share
|
|
||||||
// a position or a line. The nearest-neighbour tour the island ordering starts from has crossings to remove.
|
|
||||||
std::mt19937 rng(GENERATE(1u, 2u, 3u));
|
|
||||||
Points centers;
|
|
||||||
for (int i = 0; i < 4000; ++i) {
|
|
||||||
const double x = double(rng() % 171);
|
|
||||||
const double y = double(rng() % 171);
|
|
||||||
centers.push_back(Point::new_scale(x, y));
|
|
||||||
}
|
|
||||||
std::vector<size_t> path = chain_points(centers);
|
|
||||||
const size_t first = path.front();
|
|
||||||
const double length_before = tsp_cycle_path_length(path, centers);
|
|
||||||
REQUIRE(count_shortening_crossings(path, centers) > 0);
|
|
||||||
|
|
||||||
CHECK(tsp_remove_crossings(path, centers));
|
|
||||||
REQUIRE(is_permutation(path, centers.size()));
|
|
||||||
CHECK(count_shortening_crossings(path, centers) == 0);
|
|
||||||
CHECK(tsp_cycle_path_length(path, centers) < length_before);
|
|
||||||
// Callers that start the tour near a given point rely on the first node staying in place.
|
|
||||||
CHECK(path.front() == first);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
TEST_CASE("tsp_rotate_minimize_closing shortens closing edge", "[TSPPostProcessing]") {
|
TEST_CASE("tsp_rotate_minimize_closing shortens closing edge", "[TSPPostProcessing]") {
|
||||||
|
|||||||
@@ -8417,3 +8417,58 @@ TEST_CASE("An empty choice list in a G-code's settings keeps the value it had be
|
|||||||
CHECK(config.opt_serialize("nozzle_volume_type") == "High Flow");
|
CHECK(config.opt_serialize("nozzle_volume_type") == "High Flow");
|
||||||
CHECK(config.option<ConfigOptionStrings>("filament_multi_colour")->values.empty());
|
CHECK(config.option<ConfigOptionStrings>("filament_multi_colour")->values.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A disabled filament override is saved and reloaded column by column", "[Preset][Bundle]")
|
||||||
|
{
|
||||||
|
ScopedPresetDataDir data_dir;
|
||||||
|
PresetBundle bundle;
|
||||||
|
|
||||||
|
Preset &parent = add_inmemory_preset(bundle.filaments, "Parent PLA @System");
|
||||||
|
parent.config.option<ConfigOptionStrings>("filament_extruder_variant", true)->values = { "Direct Drive Standard", "Direct Drive High Flow" };
|
||||||
|
parent.config.option<ConfigOptionPercentsNullable>("filament_retract_before_wipe", true)->values = { 100., 100. };
|
||||||
|
parent.config.option<ConfigOptionFloatsNullable>("filament_flow_ratio", true)->values = { 1., 1. };
|
||||||
|
|
||||||
|
// The child turns the override off on the first variant and changes a plain option on the second.
|
||||||
|
const fs::path file = data_dir.tmp.path() / PRESET_FILAMENT_NAME / "Child PLA.json";
|
||||||
|
Preset child(Preset::TYPE_FILAMENT, "Child PLA");
|
||||||
|
child.file = file.string();
|
||||||
|
child.version = *Semver::parse("1.0.0");
|
||||||
|
child.config = parent.config;
|
||||||
|
child.config.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = parent.name;
|
||||||
|
child.config.option<ConfigOptionPercentsNullable>("filament_retract_before_wipe")->set_at_to_nil(0);
|
||||||
|
child.config.option<ConfigOptionFloatsNullable>("filament_flow_ratio")->values = { 1., 1.1 };
|
||||||
|
REQUIRE(child.save(&parent.config));
|
||||||
|
|
||||||
|
// On disk the override keeps every column, so its nil can only mean "off"; a plain
|
||||||
|
// option still stores nil for the columns that match the parent.
|
||||||
|
DynamicPrintConfig saved;
|
||||||
|
std::map<std::string, std::string> key_values;
|
||||||
|
std::string reason;
|
||||||
|
saved.load_from_json(file.string(), ForwardCompatibilitySubstitutionRule::EnableSilent, key_values, reason);
|
||||||
|
REQUIRE(reason.empty());
|
||||||
|
CHECK(saved.opt_serialize("filament_retract_before_wipe") == "nil,100%");
|
||||||
|
CHECK(saved.opt_serialize("filament_flow_ratio") == "nil,1.1");
|
||||||
|
|
||||||
|
PresetsConfigSubstitutions substitutions;
|
||||||
|
bundle.filaments.load_presets(data_dir.tmp.path().string(), PRESET_FILAMENT_NAME, substitutions,
|
||||||
|
ForwardCompatibilitySubstitutionRule::EnableSilent);
|
||||||
|
const Preset *loaded = bundle.filaments.find_preset("Child PLA");
|
||||||
|
REQUIRE(loaded != nullptr);
|
||||||
|
CHECK(loaded->config.opt_serialize("filament_retract_before_wipe") == "nil,100%");
|
||||||
|
CHECK(loaded->config.opt_serialize("filament_flow_ratio") == "1,1.1");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("The cloud diff force-emits a disabled override only where the cloud would fill a value in", "[Preset][Bundle]")
|
||||||
|
{
|
||||||
|
PresetBundle bundle;
|
||||||
|
add_inmemory_preset(bundle.filaments, "Parent PLA @System");
|
||||||
|
Preset &child = add_inmemory_preset(bundle.filaments, "Child PLA", "Parent PLA @System");
|
||||||
|
// Every override is nil in both parent and child, so none is in the plain diff.
|
||||||
|
std::map<std::string, std::string> key_values;
|
||||||
|
REQUIRE(bundle.filaments.get_differed_values_to_update(child, key_values) >= 0);
|
||||||
|
// Retract Before Wipe defaults to 100%: an absent key would come back as 100%, so nil is sent.
|
||||||
|
CHECK(key_values.count("filament_retract_before_wipe") == 1);
|
||||||
|
CHECK(key_values["filament_retract_before_wipe"] == "nil");
|
||||||
|
// An ironing override defaults to nil: an absent key stays nil, so it is not sent.
|
||||||
|
CHECK(key_values.count("filament_ironing_flow") == 0);
|
||||||
|
}
|
||||||
@@ -1,22 +1,48 @@
|
|||||||
#include <boost/filesystem/path.hpp>
|
#include <boost/filesystem/path.hpp>
|
||||||
#include <boost/filesystem/operations.hpp>
|
#include <boost/filesystem/operations.hpp>
|
||||||
|
#include <boost/system/error_code.hpp>
|
||||||
#include <catch2/catch_all.hpp>
|
#include <catch2/catch_all.hpp>
|
||||||
|
|
||||||
#include <libslic3r/Utils.hpp>
|
#include <libslic3r/Utils.hpp>
|
||||||
#include <libslic3r/libslic3r.h> // GCODEVIEWER_APP_KEY, SLIC3R_APP_KEY (via libslic3r_version.h)
|
#include <libslic3r/libslic3r.h> // GCODEVIEWER_APP_KEY, SLIC3R_APP_KEY (via libslic3r_version.h)
|
||||||
#include <slic3r/plugin/PluginAuditManager.hpp>
|
#include <slic3r/plugin/PluginAuditManager.hpp>
|
||||||
|
#include <slic3r/plugin/PluginFsUtils.hpp>
|
||||||
|
#include <slic3r/plugin/PluginManager.hpp>
|
||||||
|
#include <slic3r/plugin/PythonInterpreter.hpp>
|
||||||
|
#include <slic3r/plugin/PythonPluginBridge.hpp>
|
||||||
|
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||||
|
#include <slic3r/Utils/IPrinterAgent.hpp>
|
||||||
#include <slic3r/Utils/OrcaCloudServiceAgent.hpp> // secret_constants::USER_SECRET_FILENAME
|
#include <slic3r/Utils/OrcaCloudServiceAgent.hpp> // secret_constants::USER_SECRET_FILENAME
|
||||||
|
|
||||||
|
#include <catch2/catch_message.hpp>
|
||||||
#include <catch2/catch_test_macros.hpp>
|
#include <catch2/catch_test_macros.hpp>
|
||||||
|
#include <catch2/matchers/catch_matchers.hpp>
|
||||||
|
#include <catch2/matchers/catch_matchers_string.hpp>
|
||||||
#include "libslic3r_version.h"
|
#include "libslic3r_version.h"
|
||||||
#include "plugin_test_utils.hpp"
|
#include "plugin_test_utils.hpp"
|
||||||
|
#include "test_utils.hpp"
|
||||||
|
|
||||||
#include <boost/filesystem.hpp>
|
#include <boost/filesystem.hpp>
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
#include <pybind11/cast.h>
|
||||||
|
#include <pybind11/embed.h>
|
||||||
|
#include <pybind11/eval.h>
|
||||||
|
#include <pybind11/gil.h>
|
||||||
|
#include <pybind11/pybind11.h>
|
||||||
|
#include <pybind11/pytypes.h>
|
||||||
|
#include <pybind11/stl.h>
|
||||||
|
|
||||||
|
#include <fstream>
|
||||||
|
#include <functional>
|
||||||
|
#include <ios>
|
||||||
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
using namespace Slic3r;
|
using namespace Slic3r;
|
||||||
namespace fs = boost::filesystem;
|
namespace fs = boost::filesystem;
|
||||||
|
namespace py = pybind11;
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
@@ -325,6 +351,93 @@ 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");
|
||||||
@@ -349,3 +462,747 @@ TEST_CASE("Plugin audit a scoped root can also be registered read-only", "[audit
|
|||||||
CHECK_FALSE(mgr.check_open((readonly_dir / "vendor.json").string(), "w").allowed);
|
CHECK_FALSE(mgr.check_open((readonly_dir / "vendor.json").string(), "w").allowed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The audit hook, run in the embedded interpreter
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Each TEST_CASE runs in its own process (catch_discover_tests), so each brings the plugin system
|
||||||
|
// up with the audit hook installed, once, and shuts it down at the end. The tests loop over their
|
||||||
|
// cases instead of using SECTIONs, which would restart the interpreter for every section.
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Discovered, never loaded: the hook only needs the plugin's root and its sidecar.
|
||||||
|
const char* const AUDIT_PLUGIN_SOURCE = R"PY(# /// script
|
||||||
|
# requires-python = ">=3.12"
|
||||||
|
#
|
||||||
|
# [tool.orcaslicer.plugin]
|
||||||
|
# name = "Audit Plugin"
|
||||||
|
# description = "Plugin audit hook fixture"
|
||||||
|
# author = "OrcaSlicer"
|
||||||
|
# version = "1.0"
|
||||||
|
# type = "script"
|
||||||
|
# ///
|
||||||
|
)PY";
|
||||||
|
|
||||||
|
// A third-party library, installed outside the stdlib and outside the plugin.
|
||||||
|
const char* const LIBRARY_SOURCE = R"PY(
|
||||||
|
import socket
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
def read_all(paths):
|
||||||
|
for path in paths:
|
||||||
|
with open(path) as f:
|
||||||
|
f.read()
|
||||||
|
|
||||||
|
def connect_from_host(port):
|
||||||
|
socket.gethostname()
|
||||||
|
socket.create_connection(("127.0.0.1", port)).close()
|
||||||
|
|
||||||
|
def fetch(url):
|
||||||
|
with urllib.request.urlopen(url) as response:
|
||||||
|
return response.read()
|
||||||
|
|
||||||
|
def fetch_each(urls):
|
||||||
|
for url in urls:
|
||||||
|
yield fetch(url)
|
||||||
|
|
||||||
|
class ReadingHandler(urllib.request.BaseHandler):
|
||||||
|
"""Reads a file while each response is processed."""
|
||||||
|
def __init__(self, path):
|
||||||
|
self.path = path
|
||||||
|
self.outcome = None
|
||||||
|
|
||||||
|
def http_response(self, request, response):
|
||||||
|
try:
|
||||||
|
open(self.path).close()
|
||||||
|
self.outcome = "ok"
|
||||||
|
except PermissionError:
|
||||||
|
self.outcome = "denied"
|
||||||
|
return response
|
||||||
|
)PY";
|
||||||
|
|
||||||
|
// Test-side Python, run outside any plugin context: files outside every allowed root, a local
|
||||||
|
// HTTP server, the library, and every module the tests use imported up front, so that no import
|
||||||
|
// happens inside a plugin context. Code from exec() runs as "<string>", which is not stdlib, so
|
||||||
|
// attempt() and fetch() stand in for plugin code.
|
||||||
|
const char* const AUDIT_SETUP = R"PY(
|
||||||
|
import http.client, http.server, os, pathlib, shutil, socket, subprocess, sys, tempfile, threading
|
||||||
|
import urllib.request, zipfile
|
||||||
|
if os.name != "nt":
|
||||||
|
import pty
|
||||||
|
|
||||||
|
APPROVED = os.path.join(OUTSIDE, "approved.txt")
|
||||||
|
PRIVATE = os.path.join(OUTSIDE, "private.txt")
|
||||||
|
MISSING = os.path.join(OUTSIDE, "missing")
|
||||||
|
TREE = os.path.join(OUTSIDE, "tree")
|
||||||
|
TRUE_COMMAND = ["cmd", "/c", "exit 0"] if os.name == "nt" else ["true"]
|
||||||
|
for path in (APPROVED, PRIVATE):
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write("x")
|
||||||
|
for sub in ("one", "two"):
|
||||||
|
os.makedirs(os.path.join(TREE, sub))
|
||||||
|
with open(os.path.join(TREE, sub, "f.txt"), "w") as f:
|
||||||
|
f.write("x")
|
||||||
|
|
||||||
|
LIBRARY = os.path.join(OUTSIDE, "library")
|
||||||
|
os.makedirs(LIBRARY)
|
||||||
|
with open(os.path.join(LIBRARY, "netlib.py"), "w") as f:
|
||||||
|
f.write(LIBRARY_SOURCE)
|
||||||
|
sys.path.insert(0, LIBRARY)
|
||||||
|
import netlib
|
||||||
|
|
||||||
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path == "/redirect":
|
||||||
|
self.send_response(302)
|
||||||
|
self.send_header("Location", URL_B)
|
||||||
|
body = b""
|
||||||
|
else:
|
||||||
|
self.send_response(200)
|
||||||
|
body = b"ok"
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def log_message(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||||
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||||
|
PORT = server.server_address[1]
|
||||||
|
BASE = "http://127.0.0.1:%d" % PORT
|
||||||
|
URL_A, URL_B, URL_REDIRECT = BASE + "/a", BASE + "/b", BASE + "/redirect"
|
||||||
|
urllib.request.install_opener(urllib.request.build_opener(urllib.request.ProxyHandler({})))
|
||||||
|
|
||||||
|
def fetch(url):
|
||||||
|
with urllib.request.urlopen(url) as response:
|
||||||
|
return response.read()
|
||||||
|
|
||||||
|
fetch(URL_A) # urllib imports some modules on its first request
|
||||||
|
|
||||||
|
def attempt(action):
|
||||||
|
try:
|
||||||
|
action()
|
||||||
|
return "ok"
|
||||||
|
except PermissionError:
|
||||||
|
return "denied"
|
||||||
|
|
||||||
|
def spawn_checked(argv):
|
||||||
|
parent = os.getpid()
|
||||||
|
try:
|
||||||
|
return pty.spawn(argv)
|
||||||
|
finally:
|
||||||
|
if os.getpid() != parent:
|
||||||
|
os._exit(1) # a child whose exec was refused must not go on running the tests
|
||||||
|
|
||||||
|
finalizer_outcomes = []
|
||||||
|
|
||||||
|
class TrackedRequest(urllib.request.Request):
|
||||||
|
def __del__(self):
|
||||||
|
finalizer_outcomes.append(attempt(lambda: open(PRIVATE).close()))
|
||||||
|
)PY";
|
||||||
|
|
||||||
|
struct Prompt
|
||||||
|
{
|
||||||
|
std::string plugin;
|
||||||
|
std::string event;
|
||||||
|
std::vector<std::string> targets;
|
||||||
|
};
|
||||||
|
|
||||||
|
// One line per prompt, "event: target ...", so a failed check shows every prompt.
|
||||||
|
std::string describe(const std::vector<Prompt>& prompts)
|
||||||
|
{
|
||||||
|
std::string text;
|
||||||
|
for (const Prompt& prompt : prompts) {
|
||||||
|
text += prompt.event + ":";
|
||||||
|
for (const std::string& target : prompt.targets)
|
||||||
|
text += " " + target;
|
||||||
|
text += "\n";
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The plugin system with the audit hook installed, and the permission dialog replaced by a
|
||||||
|
// recorder that answers with `approve`. Declare it first in a test, so it is destroyed last.
|
||||||
|
struct AuditHarness
|
||||||
|
{
|
||||||
|
// Before initialize(): the interpreter creates {data_dir}/python/packages and {data_dir}/log.
|
||||||
|
// data_dir() is a global allowed root from then on, so the test files live in `outside`.
|
||||||
|
ScopedDataDir data{"plugin-audit-hook"};
|
||||||
|
bool initialized = PluginManager::instance().initialize();
|
||||||
|
ScopedTemporaryDir outside{"orca-plugin-audit-outside"};
|
||||||
|
std::vector<Prompt> prompts;
|
||||||
|
std::function<bool(const Prompt&)> approve = [](const Prompt&) { return false; };
|
||||||
|
|
||||||
|
AuditHarness()
|
||||||
|
{
|
||||||
|
PluginAuditManager::set_permission_prompt_for_testing(
|
||||||
|
[this](const std::string& event, const std::vector<std::string>& targets) {
|
||||||
|
prompts.push_back({PluginAuditManager::instance().current_plugin(), event, targets});
|
||||||
|
return approve(prompts.back());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
~AuditHarness()
|
||||||
|
{
|
||||||
|
PluginAuditManager::set_permission_prompt_for_testing(nullptr);
|
||||||
|
PluginManager::instance().shutdown();
|
||||||
|
PythonInterpreter::instance().shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call before taking the GIL: discovery is not Python work.
|
||||||
|
void add_plugin(const std::string& key)
|
||||||
|
{
|
||||||
|
const fs::path root = data.plugins_dir() / key;
|
||||||
|
fs::create_directories(root);
|
||||||
|
std::ofstream((root / (key + ".py")).string(), std::ios::binary) << AUDIT_PLUGIN_SOURCE;
|
||||||
|
save_permissions(key, {});
|
||||||
|
PluginManager::instance().discover_plugins(/*async=*/false, /*clear=*/true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grants as if saved in an earlier session.
|
||||||
|
void save_permissions(const std::string& key, const PluginPermissions& permissions)
|
||||||
|
{
|
||||||
|
PluginInstallState state;
|
||||||
|
state.installed_from = "local";
|
||||||
|
state.installed_version = "1.0";
|
||||||
|
state.plugin_name = key;
|
||||||
|
state.permissions = permissions;
|
||||||
|
REQUIRE(write_install_state(data.plugins_dir() / key, state));
|
||||||
|
}
|
||||||
|
|
||||||
|
PluginPermissions saved_permissions(const std::string& key) const
|
||||||
|
{
|
||||||
|
PluginInstallState state;
|
||||||
|
read_install_state(data.plugins_dir() / key, state);
|
||||||
|
return state.permissions;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The test-side Python globals. Construct and destroy it with the GIL held; it stops the HTTP
|
||||||
|
// server before the interpreter shuts down.
|
||||||
|
struct PythonSide
|
||||||
|
{
|
||||||
|
py::dict globals;
|
||||||
|
|
||||||
|
explicit PythonSide(const AuditHarness& harness)
|
||||||
|
{
|
||||||
|
globals["OUTSIDE"] = harness.outside.path().string();
|
||||||
|
globals["LIBRARY_SOURCE"] = LIBRARY_SOURCE;
|
||||||
|
exec(AUDIT_SETUP);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Python error fails the test here, as text: an error_already_set left to propagate would
|
||||||
|
// outlive the interpreter the harness shuts down.
|
||||||
|
void exec(const char* code)
|
||||||
|
{
|
||||||
|
std::string error;
|
||||||
|
try {
|
||||||
|
py::exec(code, globals);
|
||||||
|
} catch (py::error_already_set& raised) {
|
||||||
|
error = raised.what();
|
||||||
|
}
|
||||||
|
if (!error.empty())
|
||||||
|
FAIL(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
~PythonSide()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
globals["server"].attr("shutdown")();
|
||||||
|
globals["server"].attr("server_close")();
|
||||||
|
} catch (...) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string str(const char* name) const { return py::str(globals[name]).cast<std::string>(); }
|
||||||
|
std::string eval(const char* expression) const { return py::str(py::eval(expression, globals)).cast<std::string>(); }
|
||||||
|
std::vector<std::string> strings(const char* name) const { return globals[name].cast<std::vector<std::string>>(); }
|
||||||
|
|
||||||
|
// Runs `code` as `plugin_key`, as a trampoline call does: inside the caller's GIL, in an
|
||||||
|
// audit scope of its own.
|
||||||
|
void run_as(const std::string& plugin_key, const char* code)
|
||||||
|
{
|
||||||
|
ScopedPluginAuditContext audit(plugin_key);
|
||||||
|
exec(code);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
PluginPermissions http_grants(std::vector<std::string> urls)
|
||||||
|
{
|
||||||
|
PluginPermissions permissions;
|
||||||
|
permissions.network_http = std::move(urls);
|
||||||
|
return permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* const PLUGIN = "AuditPlugin";
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_CASE("A saved request grant covers the request's own connection in a later session", "[audit][Python]")
|
||||||
|
{
|
||||||
|
AuditHarness harness;
|
||||||
|
if (!harness.initialized)
|
||||||
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||||
|
harness.add_plugin(PLUGIN);
|
||||||
|
py::gil_scoped_acquire gil;
|
||||||
|
PythonSide python(harness);
|
||||||
|
harness.save_permissions(PLUGIN, http_grants({python.str("URL_A")}));
|
||||||
|
|
||||||
|
python.run_as(PLUGIN, "outcome = attempt(lambda: fetch(URL_A))");
|
||||||
|
|
||||||
|
CHECK(python.str("outcome") == "ok");
|
||||||
|
CHECK(describe(harness.prompts).empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A saved request grant does not cover another URL or a redirect", "[audit][Python]")
|
||||||
|
{
|
||||||
|
AuditHarness harness;
|
||||||
|
if (!harness.initialized)
|
||||||
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||||
|
harness.add_plugin(PLUGIN);
|
||||||
|
py::gil_scoped_acquire gil;
|
||||||
|
PythonSide python(harness);
|
||||||
|
harness.save_permissions(PLUGIN, http_grants({python.str("URL_A"), python.str("URL_REDIRECT")}));
|
||||||
|
|
||||||
|
python.run_as(PLUGIN, R"PY(
|
||||||
|
outcomes = [attempt(lambda: fetch(url)) for url in (URL_A, URL_B, URL_REDIRECT)]
|
||||||
|
outcomes += [attempt(lambda: netlib.fetch(url)) for url in (URL_A, URL_B)]
|
||||||
|
outcomes.append(attempt(lambda: list(netlib.fetch_each([URL_A, URL_B]))))
|
||||||
|
)PY");
|
||||||
|
|
||||||
|
// The redirect target is asked for inside the approved redirecting request, and a library's
|
||||||
|
// wrapper or generator asks for the second URL as plain code does.
|
||||||
|
CHECK(python.strings("outcomes") == std::vector<std::string>{"ok", "denied", "denied", "ok", "denied", "denied"});
|
||||||
|
const std::string url_b = "urllib.Request: " + python.str("URL_B") + "\n";
|
||||||
|
CHECK(describe(harness.prompts) == url_b + url_b + url_b + url_b);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Code an approved operation calls back into is judged on its own", "[audit][Python]")
|
||||||
|
{
|
||||||
|
AuditHarness harness;
|
||||||
|
if (!harness.initialized)
|
||||||
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||||
|
harness.add_plugin(PLUGIN);
|
||||||
|
py::gil_scoped_acquire gil;
|
||||||
|
PythonSide python(harness);
|
||||||
|
python.exec(R"PY(
|
||||||
|
CALLBACK_COPY = os.path.join(OUTSIDE, "callback-copy")
|
||||||
|
handler = netlib.ReadingHandler(PRIVATE)
|
||||||
|
handler_opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), handler)
|
||||||
|
walk_outcomes = []
|
||||||
|
copy_outcomes = []
|
||||||
|
|
||||||
|
def on_walk_error(error):
|
||||||
|
walk_outcomes.append(attempt(lambda: open(PRIVATE).close()))
|
||||||
|
|
||||||
|
def copy_file(source, destination):
|
||||||
|
copy_outcomes.append(attempt(lambda: open(PRIVATE).close()))
|
||||||
|
)PY");
|
||||||
|
PluginPermissions permissions = http_grants({python.str("URL_A")});
|
||||||
|
permissions.fs_read = {python.str("APPROVED"), python.str("MISSING")};
|
||||||
|
permissions.fs_readwrite = {python.str("TREE"), python.str("CALLBACK_COPY")};
|
||||||
|
harness.save_permissions(PLUGIN, permissions);
|
||||||
|
|
||||||
|
// A library handler that urllib calls while the approved request runs, the plugin's own
|
||||||
|
// os.walk error callback and copytree copy function (called once per file), and a library
|
||||||
|
// loop over a saved file and an unsaved one.
|
||||||
|
python.run_as(PLUGIN, R"PY(
|
||||||
|
request_outcome = attempt(lambda: handler_opener.open(URL_A).read())
|
||||||
|
for _ in os.walk(MISSING, onerror=on_walk_error):
|
||||||
|
pass
|
||||||
|
shutil.copytree(TREE, CALLBACK_COPY, copy_function=copy_file)
|
||||||
|
loop_outcome = attempt(lambda: netlib.read_all([APPROVED, PRIVATE]))
|
||||||
|
)PY");
|
||||||
|
|
||||||
|
CHECK(python.str("request_outcome") == "ok");
|
||||||
|
CHECK(python.eval("handler.outcome") == "denied");
|
||||||
|
CHECK(python.strings("walk_outcomes") == std::vector<std::string>{"denied"});
|
||||||
|
CHECK(python.strings("copy_outcomes") == std::vector<std::string>{"denied", "denied"});
|
||||||
|
CHECK(python.str("loop_outcome") == "denied");
|
||||||
|
const std::string private_file = "open: " + python.str("PRIVATE") + "\n";
|
||||||
|
CHECK(describe(harness.prompts) == private_file + private_file + private_file + private_file + private_file);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A saved directory walk covers each directory it lists", "[audit][Python]")
|
||||||
|
{
|
||||||
|
AuditHarness harness;
|
||||||
|
if (!harness.initialized)
|
||||||
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||||
|
harness.add_plugin(PLUGIN);
|
||||||
|
py::gil_scoped_acquire gil;
|
||||||
|
PythonSide python(harness);
|
||||||
|
PluginPermissions permissions;
|
||||||
|
permissions.fs_read = {python.str("TREE"), python.str("APPROVED")};
|
||||||
|
harness.save_permissions(PLUGIN, permissions);
|
||||||
|
|
||||||
|
// The walk lists the tree's two subdirectories on later resumes, after the plugin's own
|
||||||
|
// audited reads in between. Abandoned walks leave frames whose generator is gone, which the
|
||||||
|
// next audited event lets go.
|
||||||
|
python.run_as(PLUGIN, R"PY(
|
||||||
|
walked = []
|
||||||
|
for root, dirs, files in os.walk(TREE):
|
||||||
|
walked.append(root)
|
||||||
|
open(APPROVED).close()
|
||||||
|
globbed = list(pathlib.Path(TREE).glob("**/*"))
|
||||||
|
|
||||||
|
abandoned = os.walk(TREE)
|
||||||
|
next(abandoned)
|
||||||
|
del abandoned
|
||||||
|
open(APPROVED).close()
|
||||||
|
for _ in pathlib.Path(TREE).glob("*"):
|
||||||
|
break
|
||||||
|
open(APPROVED).close()
|
||||||
|
)PY");
|
||||||
|
|
||||||
|
CHECK(py::len(python.globals["walked"]) == 3);
|
||||||
|
CHECK(py::len(python.globals["globbed"]) == 4);
|
||||||
|
CHECK(describe(harness.prompts).empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A freshly approved operation covers its nested events", "[audit][Python]")
|
||||||
|
{
|
||||||
|
AuditHarness harness;
|
||||||
|
if (!harness.initialized)
|
||||||
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||||
|
harness.add_plugin(PLUGIN);
|
||||||
|
py::gil_scoped_acquire gil;
|
||||||
|
PythonSide python(harness);
|
||||||
|
|
||||||
|
struct Case
|
||||||
|
{
|
||||||
|
const char* prompts;
|
||||||
|
const char* code;
|
||||||
|
};
|
||||||
|
// Each operation names targets no earlier one saved, so each is asked once. FsCreate and
|
||||||
|
// FsDelete grants are never saved, so those operations are asked every time. Only the
|
||||||
|
// expected prompts are approved, so a nested event left uncovered fails the operation.
|
||||||
|
std::vector<Case> cases = {
|
||||||
|
{"shutil.copytree", "shutil.copytree(TREE, os.path.join(OUTSIDE, 'copy'))"},
|
||||||
|
{"shutil.copyfile", "shutil.copyfile(APPROVED, os.path.join(OUTSIDE, 'copied.txt'))"},
|
||||||
|
{"shutil.move", "shutil.move(os.path.join(OUTSIDE, 'copied.txt'), os.path.join(OUTSIDE, 'moved.txt'))"},
|
||||||
|
{"shutil.copystat", "shutil.copystat(os.path.join(TREE, 'one', 'f.txt'), PRIVATE)"},
|
||||||
|
{"shutil.make_archive", "shutil.make_archive(os.path.join(OUTSIDE, 'archive'), 'zip', root_dir=TREE)"},
|
||||||
|
{"shutil.unpack_archive", "shutil.unpack_archive(os.path.join(OUTSIDE, 'archive.zip'), os.path.join(OUTSIDE, 'unpacked'))"},
|
||||||
|
{"pathlib.Path.glob", "list(pathlib.Path(TREE).glob('**/*'))"},
|
||||||
|
{"subprocess.Popen", "subprocess.run(TRUE_COMMAND, check=True)"},
|
||||||
|
{"tempfile.mkdtemp", "tempfile.mkdtemp(dir=OUTSIDE)"},
|
||||||
|
{"tempfile.mkstemp", "os.close(tempfile.mkstemp(dir=OUTSIDE)[0])"},
|
||||||
|
{"shutil.rmtree", "shutil.rmtree(os.path.join(OUTSIDE, 'copy'))"},
|
||||||
|
// send() runs after connect() has returned, so its own grant is asked once.
|
||||||
|
{"http.client.connect\nhttp.client.send",
|
||||||
|
"connection = http.client.HTTPConnection('127.0.0.1', PORT)\n"
|
||||||
|
"connection.request('GET', '/a')\n"
|
||||||
|
"connection.getresponse().read()\n"
|
||||||
|
"connection.close()"},
|
||||||
|
};
|
||||||
|
#ifndef _WIN32
|
||||||
|
cases.push_back({"os.fwalk", "list(os.fwalk(os.path.join(OUTSIDE, 'unpacked')))"});
|
||||||
|
// The forked child's exec is covered by the spawn too: had it prompted, the child's own
|
||||||
|
// denial would end it with status 1.
|
||||||
|
cases.push_back({"pty.spawn", "spawn_status = spawn_checked(TRUE_COMMAND + ['spawned'])"});
|
||||||
|
#endif
|
||||||
|
|
||||||
|
for (const Case& operation : cases) {
|
||||||
|
INFO(operation.code);
|
||||||
|
const std::string expected = std::string(operation.prompts) + "\n";
|
||||||
|
harness.approve = [&expected](const Prompt& prompt) {
|
||||||
|
return expected.find(prompt.event + "\n") != std::string::npos;
|
||||||
|
};
|
||||||
|
harness.prompts.clear();
|
||||||
|
python.run_as(PLUGIN, operation.code);
|
||||||
|
|
||||||
|
std::string asked;
|
||||||
|
for (const Prompt& prompt : harness.prompts)
|
||||||
|
asked += (asked.empty() ? "" : "\n") + prompt.event;
|
||||||
|
CHECK(asked == operation.prompts);
|
||||||
|
}
|
||||||
|
#ifndef _WIN32
|
||||||
|
CHECK(python.eval("spawn_status") == "0");
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("An event with no target is granted by name and remembered", "[audit][Python]")
|
||||||
|
{
|
||||||
|
AuditHarness harness;
|
||||||
|
if (!harness.initialized)
|
||||||
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||||
|
harness.add_plugin(PLUGIN);
|
||||||
|
harness.approve = [](const Prompt& prompt) { return prompt.event != "socket.sendto"; };
|
||||||
|
py::gil_scoped_acquire gil;
|
||||||
|
PythonSide python(harness);
|
||||||
|
|
||||||
|
python.run_as(PLUGIN, R"PY(
|
||||||
|
socket.socket().close()
|
||||||
|
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
sendto_outcome = attempt(lambda: udp.sendto(b"x", ("127.0.0.1", 9)))
|
||||||
|
udp.close()
|
||||||
|
)PY");
|
||||||
|
|
||||||
|
// The socket is asked for once, without a target to show; the datagram's address is a target.
|
||||||
|
CHECK(python.str("sendto_outcome") == "denied");
|
||||||
|
CHECK(describe(harness.prompts) == "socket.__new__:\nsocket.sendto: ('127.0.0.1', 9)\n");
|
||||||
|
CHECK(harness.saved_permissions(PLUGIN).events == std::vector<std::string>{"Socket:socket.__new__"});
|
||||||
|
CHECK(harness.saved_permissions(PLUGIN).network_socket.empty());
|
||||||
|
|
||||||
|
harness.prompts.clear();
|
||||||
|
python.run_as(PLUGIN, "socket.socket().close()");
|
||||||
|
CHECK(describe(harness.prompts).empty());
|
||||||
|
|
||||||
|
// A grant for an event with no target covers nothing else: a library's approved
|
||||||
|
// gethostname() does not carry the connection it makes next.
|
||||||
|
harness.approve = [](const Prompt& prompt) { return prompt.event == "socket.gethostname"; };
|
||||||
|
python.run_as(PLUGIN, "connect_outcome = attempt(lambda: netlib.connect_from_host(PORT))");
|
||||||
|
CHECK(python.str("connect_outcome") == "denied");
|
||||||
|
CHECK(describe(harness.prompts) == "socket.gethostname:\nsocket.getaddrinfo: 127.0.0.1\n");
|
||||||
|
harness.prompts.clear();
|
||||||
|
harness.approve = [](const Prompt&) { return true; };
|
||||||
|
|
||||||
|
#ifndef _WIN32
|
||||||
|
// sendmsg() without an address names no target either.
|
||||||
|
python.run_as(PLUGIN, R"PY(
|
||||||
|
left, right = socket.socketpair()
|
||||||
|
left.sendmsg([b"x"])
|
||||||
|
left.close()
|
||||||
|
right.close()
|
||||||
|
)PY");
|
||||||
|
CHECK(describe(harness.prompts) == "socket.sendmsg:\n");
|
||||||
|
CHECK(harness.saved_permissions(PLUGIN).events ==
|
||||||
|
std::vector<std::string>{"Socket:socket.__new__", "Socket:socket.gethostname", "Socket:socket.sendmsg"});
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("An event grant does not stand in for another access or a target of the same name", "[audit][Python]")
|
||||||
|
{
|
||||||
|
AuditHarness harness;
|
||||||
|
if (!harness.initialized)
|
||||||
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||||
|
harness.add_plugin(PLUGIN);
|
||||||
|
harness.approve = [asked = 0](const Prompt&) mutable { return asked++ == 0; };
|
||||||
|
py::gil_scoped_acquire gil;
|
||||||
|
PythonSide python(harness);
|
||||||
|
python.exec(R"PY(
|
||||||
|
READ_FD = os.open(APPROVED, os.O_RDONLY)
|
||||||
|
WRITE_FD = os.open(os.path.join(OUTSIDE, "scratch.txt"), os.O_WRONLY | os.O_CREAT)
|
||||||
|
)PY");
|
||||||
|
|
||||||
|
// A file descriptor has no path to show or save: a granted read of one is not a write.
|
||||||
|
python.run_as(PLUGIN, R"PY(
|
||||||
|
fd_outcomes = [attempt(lambda: open(READ_FD, "r", closefd=False).close()),
|
||||||
|
attempt(lambda: open(WRITE_FD, "w", closefd=False).close())]
|
||||||
|
)PY");
|
||||||
|
CHECK(python.strings("fd_outcomes") == std::vector<std::string>{"ok", "denied"});
|
||||||
|
CHECK(describe(harness.prompts) == "open:\nopen:\n");
|
||||||
|
CHECK(harness.saved_permissions(PLUGIN).events == std::vector<std::string>{"FsRead:open"});
|
||||||
|
|
||||||
|
// A saved os.fork event grant is not an os.system command named "os.fork".
|
||||||
|
PluginPermissions permissions;
|
||||||
|
permissions.events = {"FsRead:open", "ProcessCreate:os.fork"};
|
||||||
|
harness.save_permissions(PLUGIN, permissions);
|
||||||
|
harness.prompts.clear();
|
||||||
|
python.run_as(PLUGIN, R"PY(
|
||||||
|
reread_outcome = attempt(lambda: open(READ_FD, "r", closefd=False).close())
|
||||||
|
system_outcome = attempt(lambda: os.system("os.fork"))
|
||||||
|
os.close(READ_FD)
|
||||||
|
os.close(WRITE_FD)
|
||||||
|
)PY");
|
||||||
|
CHECK(python.str("reread_outcome") == "ok");
|
||||||
|
CHECK(python.str("system_outcome") == "denied");
|
||||||
|
REQUIRE(harness.prompts.size() == 1);
|
||||||
|
CHECK(harness.prompts.front().event == "os.system");
|
||||||
|
CHECK(harness.saved_permissions(PLUGIN).process.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A finalizer run when a root frame is let go is judged as the plugin without a prompt", "[audit][Python]")
|
||||||
|
{
|
||||||
|
AuditHarness harness;
|
||||||
|
if (!harness.initialized)
|
||||||
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||||
|
harness.add_plugin(PLUGIN);
|
||||||
|
py::gil_scoped_acquire gil;
|
||||||
|
PythonSide python(harness);
|
||||||
|
PluginPermissions permissions = http_grants({python.str("URL_A")});
|
||||||
|
permissions.fs_read = {python.str("APPROVED")};
|
||||||
|
harness.save_permissions(PLUGIN, permissions);
|
||||||
|
PluginAuditManager& mgr = PluginAuditManager::instance();
|
||||||
|
|
||||||
|
// Once the request returns, only its held frame keeps the request object alive, and the
|
||||||
|
// request's finalizer reads a file the plugin was never granted.
|
||||||
|
python.run_as(PLUGIN, "fetch(TrackedRequest(URL_A))");
|
||||||
|
CHECK(python.strings("finalizer_outcomes") == std::vector<std::string>{"denied"}); // at scope end
|
||||||
|
CHECK(describe(harness.prompts).empty());
|
||||||
|
CHECK_FALSE(mgr.audit_denial_pending());
|
||||||
|
|
||||||
|
python.exec("finalizer_outcomes.clear()");
|
||||||
|
python.run_as(PLUGIN, R"PY(
|
||||||
|
fetch(TrackedRequest(URL_A))
|
||||||
|
open(APPROVED).close() # the next event lets the returned request's frame go
|
||||||
|
outcomes_before_scope_end = list(finalizer_outcomes)
|
||||||
|
)PY");
|
||||||
|
CHECK(python.strings("outcomes_before_scope_end") == std::vector<std::string>{"denied"});
|
||||||
|
CHECK(describe(harness.prompts).empty());
|
||||||
|
|
||||||
|
// The finalizer's denial during another event's decision leaves that event's own denial intact.
|
||||||
|
python.exec("finalizer_outcomes.clear()");
|
||||||
|
python.run_as(PLUGIN, R"PY(
|
||||||
|
fetch(TrackedRequest(URL_A))
|
||||||
|
try:
|
||||||
|
os.listdir(OUTSIDE)
|
||||||
|
outer_error = ""
|
||||||
|
except PermissionError as error:
|
||||||
|
outer_error = str(error)
|
||||||
|
)PY");
|
||||||
|
CHECK(python.strings("finalizer_outcomes") == std::vector<std::string>{"denied"});
|
||||||
|
CHECK_THAT(python.str("outer_error"), Catch::Matchers::ContainsSubstring("os.listdir"));
|
||||||
|
CHECK(describe(harness.prompts) == "os.listdir: " + python.str("OUTSIDE") + "\n");
|
||||||
|
AuditViolation violation;
|
||||||
|
REQUIRE(mgr.last_violation(violation));
|
||||||
|
CHECK(violation.event_name == "os.listdir");
|
||||||
|
CHECK(mgr.audit_denial_pending());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A nested audit scope does not see the outer scope's running operations", "[audit][Python]")
|
||||||
|
{
|
||||||
|
AuditHarness harness;
|
||||||
|
if (!harness.initialized)
|
||||||
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||||
|
harness.add_plugin("PluginA");
|
||||||
|
harness.add_plugin("PluginB");
|
||||||
|
py::gil_scoped_acquire gil;
|
||||||
|
PythonSide python(harness);
|
||||||
|
PluginPermissions permissions;
|
||||||
|
permissions.fs_read = {python.str("TREE")};
|
||||||
|
harness.save_permissions("PluginA", permissions);
|
||||||
|
|
||||||
|
{
|
||||||
|
// Two walks plugin A started and suspended after listing the tree itself.
|
||||||
|
ScopedPluginAuditContext plugin_a("PluginA");
|
||||||
|
python.exec("first = os.walk(TREE)\nnext(first)\nsecond = os.walk(TREE)\nnext(second)");
|
||||||
|
{
|
||||||
|
// Plugin B resuming one of them is asked for each subdirectory, as plugin B.
|
||||||
|
ScopedPluginAuditContext plugin_b("PluginB");
|
||||||
|
python.exec("listed_by_b = len(list(first))");
|
||||||
|
}
|
||||||
|
// Back in plugin A's scope, its walks are covered again.
|
||||||
|
python.exec("listed_by_a = len(list(second))");
|
||||||
|
}
|
||||||
|
|
||||||
|
CHECK(python.globals["listed_by_b"].cast<int>() == 0);
|
||||||
|
CHECK(python.globals["listed_by_a"].cast<int>() == 2);
|
||||||
|
REQUIRE(harness.prompts.size() == 2);
|
||||||
|
for (const Prompt& prompt : harness.prompts) {
|
||||||
|
CHECK(prompt.plugin == "PluginB");
|
||||||
|
CHECK(prompt.event == "os.scandir");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Root frames held when a capability call ends are let go with the GIL, as the plugin", "[audit][Python]")
|
||||||
|
{
|
||||||
|
AuditHarness harness;
|
||||||
|
if (!harness.initialized)
|
||||||
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||||
|
harness.approve = [](const Prompt& prompt) { return prompt.event == "os.walk"; };
|
||||||
|
py::gil_scoped_acquire gil;
|
||||||
|
PythonSide python(harness);
|
||||||
|
(void) PythonPluginBridge::instance(); // force the embedded module registration into the binary
|
||||||
|
python.globals["orca"] = py::module_::import("orca");
|
||||||
|
|
||||||
|
// walk_and_drop() leaves a returned walk's frame held by the audit scope alone, with a marker
|
||||||
|
// in its locals; the frame is let go when the capability call ends, which runs the marker's
|
||||||
|
// finalizer.
|
||||||
|
python.exec(R"PY(
|
||||||
|
class Marker:
|
||||||
|
def __init__(self, action=None):
|
||||||
|
self.action = action
|
||||||
|
def __del__(self):
|
||||||
|
finalized.append(self.action() if self.action else "ran")
|
||||||
|
|
||||||
|
finalized = []
|
||||||
|
|
||||||
|
def walk_and_drop(marker):
|
||||||
|
walker = os.walk(TREE, onerror=marker)
|
||||||
|
next(walker)
|
||||||
|
del walker
|
||||||
|
|
||||||
|
class ConfigCapability(orca.PythonPluginBase):
|
||||||
|
def get_name(self):
|
||||||
|
return "config_cap"
|
||||||
|
def get_default_config(self):
|
||||||
|
walk_and_drop(Marker())
|
||||||
|
return {"finalized_during_call": len(finalized)}
|
||||||
|
def has_config_ui(self):
|
||||||
|
walker = os.walk(TREE, onerror=Marker(lambda: attempt(lambda: open(PRIVATE).close())))
|
||||||
|
next(walker)
|
||||||
|
attempt(lambda: os.listdir(OUTSIDE)) # denied: the trampoline clears the plugin key
|
||||||
|
del walker # the walk's frame is now held by the audit scope alone
|
||||||
|
raise RuntimeError("the capability fails after a denial")
|
||||||
|
|
||||||
|
class TicketAgent(orca.printer_agent.PrinterAgentBase):
|
||||||
|
def get_name(self):
|
||||||
|
return "ticket_agent"
|
||||||
|
def request_bind_ticket(self):
|
||||||
|
walk_and_drop(Marker())
|
||||||
|
return (0, "ticket")
|
||||||
|
)PY");
|
||||||
|
|
||||||
|
py::object config_instance = python.globals["ConfigCapability"]();
|
||||||
|
auto config_cap = config_instance.cast<std::shared_ptr<PluginCapabilityInterface>>();
|
||||||
|
config_cap->set_audit_plugin_key("GilPlugin");
|
||||||
|
config_cap->set_resolved_identity("config_cap", PluginCapabilityType::Script);
|
||||||
|
|
||||||
|
py::object agent_instance = python.globals["TicketAgent"]();
|
||||||
|
auto agent_cap = agent_instance.cast<std::shared_ptr<PluginCapabilityInterface>>();
|
||||||
|
agent_cap->set_audit_plugin_key("GilPlugin");
|
||||||
|
agent_cap->set_resolved_identity("ticket_agent", PluginCapabilityType::PrinterConnection);
|
||||||
|
auto agent = std::dynamic_pointer_cast<IPrinterAgent>(agent_cap);
|
||||||
|
REQUIRE(agent);
|
||||||
|
|
||||||
|
// A host call arrives without the GIL; both calls take it before their audit scope.
|
||||||
|
nlohmann::json config;
|
||||||
|
int ticket_result = -1;
|
||||||
|
std::string ticket;
|
||||||
|
{
|
||||||
|
py::gil_scoped_release released;
|
||||||
|
config = config_cap->get_default_config();
|
||||||
|
ticket_result = agent->request_bind_ticket(&ticket);
|
||||||
|
}
|
||||||
|
CHECK(config == nlohmann::json{{"finalized_during_call", 0}});
|
||||||
|
CHECK(ticket_result == 0);
|
||||||
|
CHECK(ticket == "ticket");
|
||||||
|
CHECK(python.strings("finalized") == std::vector<std::string>{"ran", "ran"});
|
||||||
|
|
||||||
|
// On the trampoline's denial path the frame is let go as the plugin, though the ambient key
|
||||||
|
// was cleared: the finalizer's read is audited and denied without a prompt.
|
||||||
|
python.exec("finalized.clear()");
|
||||||
|
harness.prompts.clear();
|
||||||
|
CHECK_THROWS_AS(config_cap->has_config_ui(), py::error_already_set);
|
||||||
|
CHECK(python.strings("finalized") == std::vector<std::string>{"denied"});
|
||||||
|
CHECK(describe(harness.prompts) == "os.walk: " + python.str("TREE") + "\nos.listdir: " + python.str("OUTSIDE") + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Stdlib code is recognized through a symlinked Python home", "[audit][Python]")
|
||||||
|
{
|
||||||
|
AuditHarness harness;
|
||||||
|
if (!harness.initialized)
|
||||||
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||||
|
py::gil_scoped_acquire gil;
|
||||||
|
|
||||||
|
const py::dict paths = py::module_::import("sysconfig").attr("get_paths")();
|
||||||
|
const fs::path stdlib = py::str(paths["stdlib"]).cast<std::string>();
|
||||||
|
const fs::path purelib = py::str(paths["purelib"]).cast<std::string>();
|
||||||
|
const fs::path link = harness.outside.path() / "linked-stdlib";
|
||||||
|
boost::system::error_code ec;
|
||||||
|
fs::create_directory_symlink(stdlib, link, ec);
|
||||||
|
if (ec)
|
||||||
|
SKIP("Cannot create a directory symlink here: " + ec.message());
|
||||||
|
|
||||||
|
CHECK(PluginAuditManager::is_stdlib_file((stdlib / "os.py").string()));
|
||||||
|
CHECK(PluginAuditManager::is_stdlib_file((link / "urllib" / "request.py").string()));
|
||||||
|
CHECK(PluginAuditManager::is_stdlib_file("<frozen os>"));
|
||||||
|
CHECK_FALSE(PluginAuditManager::is_stdlib_file((purelib / "requests" / "api.py").string()));
|
||||||
|
CHECK_FALSE(PluginAuditManager::is_stdlib_file((link / "site-packages" / "requests" / "api.py").string()));
|
||||||
|
CHECK_FALSE(PluginAuditManager::is_stdlib_file((harness.outside.path() / "plugin.py").string()));
|
||||||
|
CHECK_FALSE(PluginAuditManager::is_stdlib_file("<string>"));
|
||||||
|
}
|
||||||
Reference in new issue
Block a user