mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-16 20:31:14 +00:00
# Description <!-- > Please provide a summary of the changes made in this PR. Include details such as: > * What issue does this PR address or fix? > * What new features or enhancements does this PR introduce? > * Are there any breaking changes or dependencies that need to be considered? --> Windows traditionally limits many file operations to `MAX_PATH` (260 characters). Deeply nested plugin packages can exceed this limit during `.whl` extraction, leaving files missing and causing the plugin to fail to load. This change adds a shared extended_path helper that prepares paths for Windows file operations using the extended-length prefixes `\\?\` or `\\?\UNC\`. The helper is used for ZIP archive I/O and extraction destinations, including plugin wheel extraction. This avoids relying on the Windows long-path setting, which changes machine-wide settings. Paths remain unprefixed outside file operations. Code that bypasses the shared ZIP helpers may still require separate long-path handling. See [Microsoft's documentation on path length limitation](https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation?tabs=registry) # Screenshots/Recordings/Graphs <!-- > Please attach relevant screenshots to showcase the UI changes. > Please attach images that can help explain the changes. --> ## Tests <!-- > Please describe the tests that you have conducted to verify the changes made in this PR. --> Tested on Windows 11 by using a portable installation of OrcaSlicer and placing it in some really long filepath length e.g. `C:/somename/<240 characters long folder path>/OrcaSlicer` and then running and trying to install any plugin locally or on OrcaCloud. <!-- > A guide for users on how to download the artifacts from this PR. --> [How to Download Pull Requests Artifacts for Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
256 lines
10 KiB
C++
256 lines
10 KiB
C++
#include <boost/filesystem/path.hpp>
|
|
#include <boost/filesystem/operations.hpp>
|
|
#include <catch2/catch_all.hpp>
|
|
|
|
#include <ios>
|
|
#include <libslic3r/Utils.hpp>
|
|
#include <slic3r/plugin/PluginLoader.hpp>
|
|
#include <slic3r/plugin/PluginDescriptor.hpp>
|
|
#include <slic3r/plugin/PluginFsUtils.hpp>
|
|
|
|
#include <catch2/catch_test_macros.hpp>
|
|
#include <catch2/matchers/catch_matchers.hpp>
|
|
#include <catch2/matchers/catch_matchers_string.hpp>
|
|
#include "catch2/catch_message.hpp"
|
|
#include "plugin_test_utils.hpp"
|
|
#include "test_utils.hpp"
|
|
|
|
#include <boost/filesystem.hpp>
|
|
|
|
#include <fstream>
|
|
#include <string>
|
|
|
|
#include <libslic3r/Zipper.hpp>
|
|
|
|
using namespace Slic3r;
|
|
namespace fs = boost::filesystem;
|
|
|
|
namespace {
|
|
|
|
fs::path write_py_file(const fs::path& dir, const std::string& filename, const std::string& contents)
|
|
{
|
|
fs::create_directories(dir);
|
|
const fs::path p = dir / filename;
|
|
std::ofstream out(p.string(), std::ios::binary);
|
|
out << contents;
|
|
return p;
|
|
}
|
|
|
|
// The shared production helper: makes the path absolute and lexically normalized and, on
|
|
// Windows, prefixes it with the extended-length "\\?\" form, so the test can create, verify and
|
|
// clean up a tree past MAX_PATH even with LongPathsEnabled off. Identity on other platforms.
|
|
fs::path to_extended_path(const fs::path& p)
|
|
{
|
|
return Slic3r::extended_path(p);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("install_plugin rejects a cloud UUID containing path traversal", "[PluginInstall]")
|
|
{
|
|
ScopedDataDir data_dir_guard("cor2");
|
|
|
|
// Package contents are irrelevant: the UUID is validated before metadata is read.
|
|
const fs::path py = write_py_file(data_dir_guard.dir / "src", "evil.py", "print('hi')\n");
|
|
|
|
|
|
|
|
PluginDescriptor descriptor;
|
|
// is_cloud_plugin() -> true; cloud_uuid() -> the traversal string.
|
|
descriptor.cloud = CloudPluginState{"../../escape", true, false, false, false};
|
|
|
|
std::string error;
|
|
const bool installed = plugin_loader::install_plugin(py, /*cloud_user_id=*/"test-user", descriptor, error);
|
|
|
|
REQUIRE_FALSE(installed);
|
|
CHECK_THAT(error, Catch::Matchers::ContainsSubstring("valid identifier"));
|
|
}
|
|
|
|
TEST_CASE("install_plugin rejects a side-loaded .py with no PEP 723 metadata", "[PluginInstall]")
|
|
{
|
|
ScopedDataDir data_dir_guard("cor3-bad");
|
|
|
|
// No `# /// script` block -> name stays empty and type stays Unknown.
|
|
const fs::path py = write_py_file(data_dir_guard.dir / "src", "nameless.py", "print('no metadata here')\n");
|
|
|
|
|
|
PluginDescriptor descriptor;
|
|
std::string error;
|
|
const bool installed = plugin_loader::install_plugin(py, /*cloud_user_id=*/"", descriptor, error);
|
|
|
|
REQUIRE_FALSE(installed);
|
|
CHECK_THAT(error, Catch::Matchers::ContainsSubstring("PEP 723"));
|
|
}
|
|
|
|
TEST_CASE("install_plugin accepts a side-loaded .py with complete PEP 723 metadata", "[PluginInstall]")
|
|
{
|
|
ScopedDataDir data_dir_guard("cor3-good");
|
|
|
|
const std::string contents =
|
|
"# /// script\n"
|
|
"# requires-python = \">=3.12\"\n"
|
|
"#\n"
|
|
"# [tool.orcaslicer.plugin]\n"
|
|
"# name = \"Test Plugin\"\n"
|
|
"# type = \"script\"\n"
|
|
"# ///\n"
|
|
"print('ok')\n";
|
|
const fs::path py = write_py_file(data_dir_guard.dir / "src", "good.py", contents);
|
|
|
|
|
|
PluginDescriptor descriptor;
|
|
std::string error;
|
|
const bool installed = plugin_loader::install_plugin(py, /*cloud_user_id=*/"", descriptor, error);
|
|
|
|
// Positive control: a complete side-loaded .py must still install (guards against over-rejection).
|
|
REQUIRE(installed);
|
|
CHECK(error.empty());
|
|
}
|
|
|
|
TEST_CASE("install-state sidecar is the source of truth for a cloud plugin's installed version", "[PluginInstall]")
|
|
{
|
|
ScopedDataDir data_dir_guard("installed-version");
|
|
|
|
const fs::path plugin_dir = data_dir_guard.dir / "plugin";
|
|
fs::create_directories(plugin_dir);
|
|
|
|
// A cloud plugin whose local manifest/PEP723 header lags the version actually fetched from
|
|
// the cloud: the user bumped the version on the cloud without touching the local header.
|
|
PluginDescriptor descriptor;
|
|
descriptor.name = "Versioned Plugin";
|
|
descriptor.version = "1.0.0"; // stale header version
|
|
descriptor.installed_version = "1.2.0"; // version fetched from the cloud at install time
|
|
descriptor.cloud = CloudPluginState{"uuid-1", true, false, false, false};
|
|
|
|
REQUIRE(write_install_state(plugin_dir, descriptor));
|
|
|
|
// The writer must persist the installed_version (1.2.0), not the header version (1.0.0),
|
|
// so a subsequent re-write from a freshly-scanned descriptor cannot clobber it.
|
|
PluginInstallState state;
|
|
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"};
|
|
state.permissions.threading = {"thread"};
|
|
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);
|
|
CHECK(persisted.permissions.threading == state.permissions.threading);
|
|
|
|
// 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
|
|
// already-updated plugin no longer looks perpetually out of date.
|
|
PluginDescriptor scanned;
|
|
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");
|
|
}
|
|
|
|
TEST_CASE("a local plugin's installed version follows its entry file header", "[PluginInstall]")
|
|
{
|
|
ScopedDataDir data_dir_guard("local-installed-version");
|
|
|
|
const fs::path plugin_dir = data_dir_guard.dir / "plugin";
|
|
fs::create_directories(plugin_dir);
|
|
|
|
// Sidecar written when the local plugin was first installed at 1.0.0.
|
|
PluginInstallState state;
|
|
state.installed_from = "local";
|
|
state.installed_version = "1.0.0";
|
|
REQUIRE(write_install_state(plugin_dir, state));
|
|
|
|
// The user then edited the plugin file: the freshly scanned header says 1.1.0.
|
|
PluginDescriptor scanned;
|
|
scanned.version = "1.1.0";
|
|
REQUIRE(read_install_state(plugin_dir, scanned));
|
|
CHECK(scanned.installed_version == "1.1.0");
|
|
}
|
|
|
|
TEST_CASE("an unsubscribed cloud plugin follows its header version", "[PluginInstall]")
|
|
{
|
|
ScopedDataDir data_dir_guard("unsubscribed-installed-version");
|
|
|
|
const fs::path plugin_dir = data_dir_guard.dir / "plugin";
|
|
fs::create_directories(plugin_dir);
|
|
|
|
// What keep_installed_plugin_as_local leaves behind: a normal local package. The sidecar still
|
|
// holds the version fetched from the cloud (1.2.0), but the header (1.0.0) is the source of truth.
|
|
PluginInstallState state;
|
|
state.installed_from = "local";
|
|
state.installed_version = "1.2.0";
|
|
REQUIRE(write_install_state(plugin_dir, state));
|
|
|
|
PluginDescriptor scanned;
|
|
scanned.version = "1.0.0";
|
|
REQUIRE(read_install_state(plugin_dir, scanned));
|
|
CHECK(scanned.installed_version == "1.0.0");
|
|
}
|
|
|
|
TEST_CASE("extract_zip_to_directory writes entries under a destination longer than MAX_PATH",
|
|
"[PluginInstall][PluginFsUtils]")
|
|
{
|
|
ScopedTemporaryDir dir("deep-extract");
|
|
|
|
const fs::path wheel = dir.path() / "deep_pkg-1.0-py3-none-any.whl";
|
|
const std::string payload = "print('hello from a deep path')\n";
|
|
{
|
|
Zipper zipper(wheel.string(), Zipper::NO_COMPRESSION);
|
|
zipper.add_entry("pkg/", "", 0); // explicit directory entry
|
|
zipper.add_entry("pkg/module.py", payload.data(), payload.size());
|
|
zipper.finalize();
|
|
}
|
|
|
|
// A single 240-char component keeps every filename below the NTFS 255 limit while pushing the
|
|
// full destination (temp dir + component) past the 260-char MAX_PATH the Win32 APIs enforce
|
|
// when LongPathsEnabled is off.
|
|
const fs::path destination = dir.path() / std::string(240, 'd');
|
|
REQUIRE(destination.string().size() > 260);
|
|
|
|
std::string error;
|
|
REQUIRE(extract_zip_to_directory(wheel, destination, error));
|
|
INFO("extract error: " << error);
|
|
CHECK(error.empty());
|
|
|
|
// Verify through a long-path-capable path, otherwise the check itself is bounded by MAX_PATH.
|
|
const fs::path module_py = destination / "pkg" / "module.py";
|
|
REQUIRE(fs::exists(to_extended_path(module_py)));
|
|
REQUIRE(fs::is_regular_file(to_extended_path(module_py)));
|
|
|
|
// Remove the deep subtree here: ScopedTemporaryDir's plain remove_all cannot reach past
|
|
// MAX_PATH on Windows.
|
|
boost::system::error_code ec;
|
|
fs::remove_all(to_extended_path(destination), ec);
|
|
CHECK_FALSE(ec);
|
|
}
|
|
|
|
TEST_CASE("extract_zip_to_directory rejects an entry that escapes the destination",
|
|
"[PluginInstall][PluginFsUtils]")
|
|
{
|
|
ScopedTemporaryDir dir("escape-extract");
|
|
|
|
const fs::path wheel = dir.path() / "evil-1.0-py3-none-any.whl";
|
|
{
|
|
Zipper zipper(wheel.string(), Zipper::NO_COMPRESSION);
|
|
zipper.add_entry("pkg/module.py", "print('ok')\n", 12);
|
|
zipper.add_entry("../escape.txt", "escaped\n", 8);
|
|
zipper.finalize();
|
|
}
|
|
|
|
std::string error;
|
|
CHECK_FALSE(extract_zip_to_directory(wheel, dir.path() / "out", error));
|
|
CHECK_THAT(error, Catch::Matchers::ContainsSubstring("escapes the plugin package"));
|
|
CHECK_FALSE(fs::exists(dir.path() / "escape.txt"));
|
|
}
|