Merge branch 'main' into feat/printer-agent-impl

This commit is contained in:
Ian Chua
2026-09-01 16:51:34 +08:00
138 changed files with 4336 additions and 2279 deletions
+39 -17
View File
@@ -30,25 +30,47 @@ if (APPLE)
target_link_libraries(test_common INTERFACE "-liconv -framework IOKit" "-framework CoreFoundation" -lc++)
endif()
# Copies runtime DLLs next to each test executable. Handles both single-config
# generators (CMAKE_BUILD_TYPE set) and multi-config generators (Ninja
# Multi-Config, Visual Studio) where CMAKE_BUILD_TYPE is empty and DLLs must
# land in every per-config output directory.
# Copies runtime shared libraries next to each test executable. Handles both
# single-config generators (CMAKE_BUILD_TYPE set) and multi-config generators
# (Ninja Multi-Config, Visual Studio) where CMAKE_BUILD_TYPE is empty and DLLs
# must land in every per-config output directory. On Windows the loader finds
# DLLs in the executable's directory; the Linux branch below does the same for
# the deps-built FFmpeg libraries and adds an $ORIGIN rpath, since the ELF
# loader does not search the executable's directory and the CI unit-test runner
# only receives the tests artifact (no deps install).
function(orcaslicer_copy_test_dlls)
if (NOT WIN32)
return()
endif()
set(_configs ${CMAKE_CONFIGURATION_TYPES})
if (NOT _configs)
set(_configs "${CMAKE_BUILD_TYPE}")
endif()
foreach(_cfg IN LISTS _configs)
if (_cfg STREQUAL "Debug")
orcaslicer_copy_dlls(COPY_DLLS "Debug" "d" _unused_dlls)
else()
orcaslicer_copy_dlls(COPY_DLLS "${_cfg}" "" _unused_dlls)
if (WIN32)
set(_configs ${CMAKE_CONFIGURATION_TYPES})
if (NOT _configs)
set(_configs "${CMAKE_BUILD_TYPE}")
endif()
endforeach()
foreach(_cfg IN LISTS _configs)
if (_cfg STREQUAL "Debug")
orcaslicer_copy_dlls(COPY_DLLS "Debug" "d" _unused_dlls)
else()
orcaslicer_copy_dlls(COPY_DLLS "${_cfg}" "" _unused_dlls)
endif()
endforeach()
elseif (UNIX AND NOT APPLE)
# Only test executables that link libslic3r_gui pull in the FFmpeg
# shared libraries (src/slic3r/CMakeLists.txt links PkgConfig::LIBAV
# into it). Copy them next to the executable and give it an $ORIGIN
# rpath so the loader finds them when the tests run on the CI unit-test
# runner, which only receives this build/tests tree.
get_target_property(_linked_libs ${_TEST_NAME}_tests LINK_LIBRARIES)
if (NOT "libslic3r_gui" IN_LIST _linked_libs)
return()
endif()
set_property(TARGET ${_TEST_NAME}_tests PROPERTY BUILD_RPATH "$ORIGIN")
set(_configs ${CMAKE_CONFIGURATION_TYPES})
if (NOT _configs)
set(_configs "${CMAKE_BUILD_TYPE}")
endif()
foreach(_cfg IN LISTS _configs)
orcaslicer_copy_sos(${_TEST_NAME}_tests "${_cfg}" "" _unused_sos)
endforeach()
endif()
endfunction()
# Register Catch2 tags as CTest labels so `ctest -L`/`-LE` can filter by tag.
File diff suppressed because it is too large Load Diff
+1
View File
@@ -29,6 +29,7 @@ add_executable(${_TEST_NAME}_tests
test_mutable_polygon.cpp
test_mutable_priority_queue.cpp
test_nozzle_volume_type.cpp
test_step.cpp
test_stl.cpp
test_triangle_selector.cpp
test_meshboolean.cpp
+93
View File
@@ -0,0 +1,93 @@
#include <catch2/catch_all.hpp>
#include <boost/nowide/fstream.hpp>
#include "libslic3r/Model.hpp"
#include "libslic3r/Format/STEP.hpp"
#include "test_utils.hpp"
using namespace Slic3r;
static void write_step_line(const std::string &path, const std::string &line)
{
boost::nowide::ofstream file(path, std::ios::binary);
file << "ISO-10303-21;\n" << line << "\nEND-ISO-10303-21;\n";
}
// preprocess() hands back the input path unless it transcoded into a temporary.
static std::string preprocess_result(const std::string &line)
{
ScopedSlic3rTemporaryDir scratch;
ScopedTemporaryFile step(".step");
write_step_line(step.string(), line);
std::string output_path;
StepPreProcessor preprocessor;
REQUIRE(preprocessor.preprocess(step.string().c_str(), output_path));
return output_path == step.string() ? "untouched" : "transcoded";
}
// data/utf8_part_names.step is three boxes written by OCCT's own STEP writer, whose
// PRODUCT names were then patched to raw UTF-8. Most CAD exporters write non-ASCII names
// that way rather than in the \X2\ escape form. The third part is ASCII, as a control.
TEST_CASE("Part names with multi-byte UTF-8 survive import", "[Step]")
{
// getNamedSolids() replaces a name that isUtf8() rejects with a running number.
const std::string path = TEST_DATA_DIR PATH_SEPARATOR "utf8_part_names.step";
Model model;
bool cancel = false;
Step step(path); // no isUtf8Fn, matching how Model::read_from_step builds it
REQUIRE(step.load() == Step::Step_Status::LOAD_SUCCESS);
REQUIRE(step.mesh(&model, cancel, false) == Step::Step_Status::MESH_SUCCESS);
REQUIRE(model.objects.size() == 1);
const ModelObject *object = model.objects.front();
REQUIRE(object->volumes.size() == 3);
// "ce" is split off, or the hex escape would swallow it as further hex digits.
CHECK(object->volumes[0]->name == "pi\xC3\xA8" "ce");
CHECK(object->volumes[1]->name == "Geh\xC3\xA4use");
CHECK(object->volumes[2]->name == "bracket");
}
TEST_CASE("isUtf8 recognises two, three and four byte sequences", "[Step]")
{
CHECK(StepPreProcessor::isUtf8("\xC3\xA9")); // U+00E9
CHECK(StepPreProcessor::isUtf8("\xE4\xB8\xAD")); // U+4E2D
CHECK(StepPreProcessor::isUtf8("\xF0\x9F\x94\xA9")); // U+1F529
CHECK_FALSE(StepPreProcessor::isUtf8("\x81\x30")); // 0x81 is not a lead byte
CHECK_FALSE(StepPreProcessor::isUtf8("\xC3")); // truncated sequence
}
// The only caller of isGBK is preprocess(), which nothing calls today.
TEST_CASE("Encoding detection decides whether a step file is transcoded", "[Step]")
{
SECTION("UTF-8, so left alone")
{
// A two byte sequence also satisfies every GBK range, so misdetecting it as
// not-UTF-8 sends it to be transcoded.
const std::string sequence = GENERATE(std::string("\xC3\xA9"), // U+00E9
std::string("\xE4\xB8\xAD"), // U+4E2D
std::string("\xF0\x9F\x94\xA9")); // U+1F529
CHECK(preprocess_result("NAME('" + sequence + "');") == "untouched");
}
SECTION("neither UTF-8 nor GBK, so left alone")
{
// 0x81 is not a UTF-8 lead byte, and 0x30 is below the 0x40 floor for a GBK trail.
CHECK(preprocess_result("NAME('\x81\x30');") == "untouched");
}
SECTION("GBK, so transcoded")
{
// U+554A in GBK, whose lead byte is not valid UTF-8. Pins the other direction,
// since a detector that never reports GBK would pass every case above.
CHECK(preprocess_result("NAME('\xB0\xA1');") == "transcoded");
}
SECTION("plain ASCII, so left alone") { CHECK(preprocess_result("NAME('bracket');") == "untouched"); }
}
+20
View File
@@ -34,4 +34,24 @@ struct ScopedDataDir
ScopedDataDir& operator=(const ScopedDataDir&) = delete;
};
// Point resources_dir() at a throwaway directory for the lifetime of a test and restore the
// previous value afterwards, mirroring ScopedDataDir.
struct ScopedResourcesDir
{
ScopedTemporaryDir tmp;
boost::filesystem::path dir;
std::string previous;
explicit ScopedResourcesDir(const std::string& tag)
: tmp("orca-" + tag), dir(tmp.path()), previous(resources_dir())
{
set_resources_dir(dir.string());
}
~ScopedResourcesDir() { set_resources_dir(previous); }
ScopedResourcesDir(const ScopedResourcesDir&) = delete;
ScopedResourcesDir& operator=(const ScopedResourcesDir&) = delete;
};
} // namespace Slic3r
+159 -8
View File
@@ -27,6 +27,16 @@ void seed_denied_names()
mgr.add_denied_filename(name);
}
// Seed the keyword registry with the same list install_hook() uses. Same rationale as
// seed_denied_names(): a process-singleton registry, seeded from the single shared source so
// production and tests cannot drift apart.
void seed_denied_keywords()
{
PluginAuditManager& mgr = PluginAuditManager::instance();
for (const auto& keyword : PluginAuditManager::default_denied_path_keywords())
mgr.add_denied_path_keyword(keyword);
}
} // namespace
TEST_CASE("Plugin audit denies app config and token filenames anywhere", "[audit]")
@@ -81,7 +91,7 @@ TEST_CASE("Plugin audit denies app config and token filenames anywhere", "[audit
}
}
TEST_CASE("Plugin audit deny beats allowed roots and the Loading read exemption", "[audit]")
TEST_CASE("Plugin audit deny beats allowed roots", "[audit]")
{
ScopedDataDir data_dir_guard("plugin-audit-deny");
seed_denied_names();
@@ -91,8 +101,8 @@ TEST_CASE("Plugin audit deny beats allowed roots and the Loading read exemption"
// config and the token would otherwise be reachable simply by living inside it.
mgr.add_global_allowed_root(data_dir());
// Enter a plugin context. The deny must hold in Loading mode, which every scope runs in.
ScopedPluginAuditContext ctx("test_plugin", "", PluginAuditManager::AuditMode::Loading);
// Enter a plugin context. The deny must hold even inside a globally allowed root.
ScopedPluginAuditContext ctx("test_plugin", "");
const fs::path conf = fs::path(data_dir()) / (SLIC3R_APP_KEY ".conf");
const fs::path token = fs::path(data_dir()) / secret_constants::USER_SECRET_FILENAME;
@@ -103,6 +113,13 @@ TEST_CASE("Plugin audit deny beats allowed roots and the Loading read exemption"
CHECK(decision.allowed);
}
SECTION("a file outside the allowed root is blocked for reads as well as writes")
{
AuditDecision decision = mgr.check_open((fs::path(data_dir()).parent_path() / "outside.txt").string(), "r");
CHECK_FALSE(decision.allowed);
CHECK(decision.reason == "outside allowed root");
}
SECTION("writing the app config is blocked despite data_dir() being allowed")
{
AuditDecision decision = mgr.check_open(conf.string(), "w");
@@ -110,16 +127,14 @@ TEST_CASE("Plugin audit deny beats allowed roots and the Loading read exemption"
CHECK(decision.reason == "denied filename");
}
SECTION("reading the app config is blocked even though Loading exempts reads")
SECTION("reading the app config is blocked despite the allowed root")
{
// Without the deny, a read in Loading mode short-circuits to allow. The deny sits above
// that exemption, so this must still be blocked.
AuditDecision decision = mgr.check_open(conf.string(), "r");
CHECK_FALSE(decision.allowed);
CHECK(decision.reason == "denied filename");
}
SECTION("reading the cloud refresh token is blocked in Loading mode")
SECTION("reading the cloud refresh token is blocked")
{
AuditDecision decision = mgr.check_open(token.string(), "r");
CHECK_FALSE(decision.allowed);
@@ -150,7 +165,7 @@ TEST_CASE("Plugin audit deny beats a plugin's own scoped root", "[audit]")
const fs::path plugin_dir = fs::path(data_dir()) / "plugins" / "test_plugin";
fs::create_directories(plugin_dir);
ScopedPluginAuditContext ctx("test_plugin", "", PluginAuditManager::AuditMode::Loading);
ScopedPluginAuditContext ctx("test_plugin", "");
mgr.add_scoped_allowed_root(plugin_dir);
SECTION("the plugin's own non-denied file opens for read and write")
@@ -185,3 +200,139 @@ TEST_CASE("Plugin audit does not constrain non-plugin code", "[audit]")
CHECK(mgr.check_open(conf.string(), "w").allowed);
CHECK(mgr.check_open(conf.string(), "r").allowed);
}
TEST_CASE("Plugin audit denies secret/certificate/config-like paths by keyword", "[audit]")
{
seed_denied_keywords();
const PluginAuditManager& mgr = PluginAuditManager::instance();
SECTION("a 'secrets' directory component is denied")
{
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/secrets/api_key.json")));
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/secret/token.txt")));
}
SECTION("a 'certificate(s)' directory component is denied")
{
CHECK(mgr.is_denied_path_keyword(fs::path("/resources/cert/slicer_base64.cer")));
CHECK(mgr.is_denied_path_keyword(fs::path("/resources/certificates/ca.pem")));
}
SECTION("a 'conf'/'config' directory or file component is denied")
{
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/conf/settings.json")));
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/config/settings.json")));
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/plugin.conf")));
}
SECTION("matching is case-insensitive")
{
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/SECRETS/token.txt")));
CHECK(mgr.is_denied_path_keyword(fs::path("/resources/CertBundle/ca.pem")));
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/CONFIG.JSON")));
}
SECTION("matching is not limited to the base name -- any ancestor component counts")
{
CHECK(mgr.is_denied_path_keyword(fs::path("/data/secrets/nested/deep/file.txt")));
}
SECTION("an unrelated path is not denied")
{
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/plugin/output/model.gcode")));
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/plugin/storage/state.json")));
}
SECTION("an empty path is not denied")
{
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path()));
}
}
TEST_CASE("Plugin audit is_denied_path combines the filename and keyword registries", "[audit]")
{
seed_denied_names();
seed_denied_keywords();
const PluginAuditManager& mgr = PluginAuditManager::instance();
SECTION("a filename-registry match is denied")
{
CHECK(mgr.is_denied_path(fs::path(SLIC3R_APP_KEY ".conf")));
}
SECTION("a keyword-registry match is denied")
{
CHECK(mgr.is_denied_path(fs::path("/plugin/secrets/token.txt")));
}
SECTION("a path matching neither registry is not denied")
{
CHECK_FALSE(mgr.is_denied_path(fs::path("/plugin/output/model.gcode")));
}
}
TEST_CASE("Plugin audit a read-only allowed root blocks writes but not reads", "[audit]")
{
ScopedDataDir data_dir_guard("plugin-audit-readonly");
ScopedResourcesDir resources_dir_guard("plugin-audit-readonly-resources");
seed_denied_names();
seed_denied_keywords();
PluginAuditManager& mgr = PluginAuditManager::instance();
mgr.add_global_allowed_root(resources_dir(), /*allow_write=*/false);
ScopedPluginAuditContext ctx("test_plugin", "");
const fs::path readonly_file = fs::path(resources_dir()) / "profiles" / "vendor.json";
SECTION("a read inside the read-only root is allowed")
{
CHECK(mgr.check_open(readonly_file.string(), "r").allowed);
}
SECTION("a write inside the read-only root is blocked")
{
AuditDecision decision = mgr.check_open(readonly_file.string(), "w");
CHECK_FALSE(decision.allowed);
CHECK(decision.reason == "outside allowed root");
}
SECTION("a create inside the read-only root is blocked")
{
AuditDecision decision = mgr.check_path_access(readonly_file, /*is_write=*/true);
CHECK_FALSE(decision.allowed);
}
SECTION("the bundled cert underneath the read-only root is denied even for reads")
{
const fs::path cert = fs::path(resources_dir()) / "cert" / "slicer_base64.cer";
AuditDecision decision = mgr.check_open(cert.string(), "r");
CHECK_FALSE(decision.allowed);
CHECK(decision.reason == "denied path keyword");
}
}
TEST_CASE("Plugin audit a scoped root can also be registered read-only", "[audit]")
{
ScopedDataDir data_dir_guard("plugin-audit-scoped-readonly");
seed_denied_names();
seed_denied_keywords();
PluginAuditManager& mgr = PluginAuditManager::instance();
const fs::path readonly_dir = fs::path(data_dir()) / "readonly_scope";
fs::create_directories(readonly_dir);
ScopedPluginAuditContext ctx("test_plugin", "");
mgr.add_scoped_allowed_root(readonly_dir, /*allow_write=*/false);
SECTION("a read inside the scoped read-only root is allowed")
{
CHECK(mgr.check_open((readonly_dir / "vendor.json").string(), "r").allowed);
}
SECTION("a write inside the scoped read-only root is blocked")
{
CHECK_FALSE(mgr.check_open((readonly_dir / "vendor.json").string(), "w").allowed);
}
}
+17 -1
View File
@@ -112,6 +112,22 @@ TEST_CASE("install-state sidecar is the source of truth for a cloud plugin's ins
REQUIRE(read_install_state(plugin_dir, state));
CHECK(state.installed_version == "1.2.0");
state.permissions.fs_read = {"/path/to/read"};
state.permissions.fs_readwrite = {"/path/to/readwrite"};
state.permissions.network_http = {"https://api.example.com"};
state.permissions.network_socket = {"192.168.45.6:443"};
state.permissions.process = {"/usr/bin/curl"};
REQUIRE(write_install_state(plugin_dir, state));
// Permission data is persisted in the same sidecar as the installation metadata.
PluginInstallState persisted;
REQUIRE(read_install_state(plugin_dir, persisted));
CHECK(persisted.permissions.fs_read == state.permissions.fs_read);
CHECK(persisted.permissions.fs_readwrite == state.permissions.fs_readwrite);
CHECK(persisted.permissions.network_http == state.permissions.network_http);
CHECK(persisted.permissions.network_socket == state.permissions.network_socket);
CHECK(persisted.permissions.process == state.permissions.process);
// Reading the sidecar back onto a freshly-scanned descriptor (whose header version is still
// 1.0.0) must surface the cloud-installed 1.2.0. This is what lets update_cloud_metadata compare
// the cloud's latest version against the installed version instead of the stale header, so an
@@ -120,4 +136,4 @@ TEST_CASE("install-state sidecar is the source of truth for a cloud plugin's ins
scanned.version = "1.0.0"; // as parsed from the unchanged PEP723 header
read_install_state(plugin_dir, scanned);
CHECK(scanned.installed_version == "1.2.0");
}
}
+20 -1
View File
@@ -4,6 +4,7 @@
#include <libslic3r/TriangleMesh.hpp>
#include <libslic3r/Format/OBJ.hpp>
#include <libslic3r/SVG.hpp>
#include <libslic3r/Utils.hpp>
#include <boost/filesystem.hpp>
@@ -32,7 +33,7 @@ inline Slic3r::TriangleMesh load_model(const std::string &obj_filename)
// ---------------------------------------------------------------------------
// Owns a unique path under the system temp dir, "<prefix>-<unique>[<extension>]"
// (parallel-safe, cross-platform). Shared base for the two RAII temp guards below.
// (parallel-safe, cross-platform). Shared base for the RAII temp guards below.
class ScopedTemporaryPath
{
public:
@@ -70,6 +71,24 @@ public:
~ScopedTemporaryDir() { boost::system::error_code ec; boost::filesystem::remove_all(m_path, ec); }
};
// A temp directory that is also Slic3r::temporary_dir() for its lifetime. No test
// process sets that global, so code under test which writes there (for example
// StepPreProcessor::preprocess) lands at the filesystem root. Restored on scope exit
// even when an assertion throws, so it cannot leak into later tests.
class ScopedSlic3rTemporaryDir : public ScopedTemporaryDir
{
public:
explicit ScopedSlic3rTemporaryDir(const std::string &prefix = "orca")
: ScopedTemporaryDir(prefix), m_previous(Slic3r::temporary_dir())
{ Slic3r::set_temporary_dir(string()); }
// Runs before ~ScopedTemporaryDir, so the setting goes back while the directory
// it names still exists.
~ScopedSlic3rTemporaryDir() { Slic3r::set_temporary_dir(m_previous); }
private:
const std::string m_previous;
};
// ---------------------------------------------------------------------------
// Debug-only test artifacts
//