diff --git a/src/libslic3r/Utils.hpp b/src/libslic3r/Utils.hpp index eb171e4ee0..c5cf29d590 100644 --- a/src/libslic3r/Utils.hpp +++ b/src/libslic3r/Utils.hpp @@ -842,6 +842,13 @@ void load_string_file(const boost::filesystem::path& p, std::string& str); bool check_layer_id_pattern(const std::string& pattern, int layer_id); +// On Windows, return an equivalent path carrying the extended-length ("\\?\" / "\\?\UNC\") +// prefix so the Win32 APIs accept it past MAX_PATH even when LongPathsEnabled is off. The path +// is made absolute and lexically normalized first, because the prefix disables normalisation. +// A path that is already extended (or a device path) is returned unchanged; on other platforms, +// or when an absolute path cannot be formed, the input is returned unchanged. +boost::filesystem::path extended_path(const boost::filesystem::path& path); + } // namespace Slic3r #if WIN32 diff --git a/src/libslic3r/miniz_extension.cpp b/src/libslic3r/miniz_extension.cpp index f4a520d0ce..1e1efbec54 100644 --- a/src/libslic3r/miniz_extension.cpp +++ b/src/libslic3r/miniz_extension.cpp @@ -17,6 +17,7 @@ #if defined(_MSC_VER) || defined(__MINGW64__) #include "boost/nowide/cstdio.hpp" +#include "boost/nowide/convert.hpp" #endif #include "I18N.hpp" @@ -62,7 +63,11 @@ bool open_zip(mz_zip_archive *zip, const char *fname, bool isread) FILE *f = nullptr; #if defined(_MSC_VER) || defined(__MINGW64__) - f = boost::nowide::fopen(fname, mode); + // fname is UTF-8 (the nowide contract). Route it through the shared extended-path helper so an + // archive stored past MAX_PATH opens even with LongPathsEnabled off. + const std::wstring wide_path = boost::nowide::widen(fname); + const std::string extended = boost::nowide::narrow(Slic3r::extended_path(boost::filesystem::path(wide_path)).wstring()); + f = boost::nowide::fopen(extended.c_str(), mode); #elif defined(__GNUC__) && defined(_LARGEFILE64_SOURCE) f = fopen64(fname, mode); #else @@ -101,6 +106,14 @@ bool close_zip(mz_zip_archive *zip, bool isread) } return ret; } + +#ifdef WIN32 +// mz_file_write_func writing an extracted entry into an already-open FILE*. +size_t mz_write_to_cfile(void *opaque, mz_uint64 /*file_ofs*/, const void *buffer, size_t size) +{ + return std::fwrite(buffer, 1, size, static_cast(opaque)); +} +#endif } bool open_zip_reader(mz_zip_archive *zip, const std::string &fname) @@ -116,6 +129,36 @@ bool open_zip_writer(mz_zip_archive *zip, const std::string &fname) bool close_zip_reader(mz_zip_archive *zip) { return close_zip(zip, true); } bool close_zip_writer(mz_zip_archive *zip) { return close_zip(zip, false); } +bool extract_zip_entry_to_file(mz_zip_archive *zip, mz_uint file_index, const boost::filesystem::path &dest_path) +{ + if (!zip) + return false; +#ifdef WIN32 + // Open the destination ourselves with a Windows extended-length path: miniz's narrow + // extract_to_file cannot be relied on past MAX_PATH with LongPathsEnabled off, and its wide + // variant takes a plain (unprefixed) path and a text-mode stream. This keeps the write binary. + FILE *file = ::_wfopen(Slic3r::extended_path(dest_path).wstring().c_str(), L"wb"); + if (!file) { + zip->m_last_error = MZ_ZIP_FILE_OPEN_FAILED; + return false; + } + + const bool written = mz_zip_reader_extract_to_callback(zip, file_index, mz_write_to_cfile, file, 0) != 0; + const bool closed = std::fclose(file) == 0; + if (!written || !closed) { + if (written && !closed) + zip->m_last_error = MZ_ZIP_FILE_CLOSE_FAILED; + // Drop the partial entry rather than leave a truncated file behind. + boost::system::error_code ec; + boost::filesystem::remove(Slic3r::extended_path(dest_path), ec); + return false; + } + return true; +#else + return mz_zip_reader_extract_to_file(zip, file_index, dest_path.string().c_str(), 0) != 0; +#endif +} + std::string decode_archive_entry_path(mz_zip_archive *zip, const mz_zip_archive_file_stat &stat) { if (stat.m_is_utf8) @@ -156,10 +199,13 @@ bool extract_archive_confined(const std::string &zip_path_utf8, const std::strin } const std::string dest_file = dest_dir + "/" + stat.m_filename; try { + // Use the extended-length form for every filesystem call so entries under a destination + // past MAX_PATH are created even with LongPathsEnabled off. + const boost::filesystem::path dest_path(dest_file); + const boost::filesystem::path io_dest = extended_path(dest_path); if (stat.m_is_directory) { - const boost::filesystem::path dest_path(dest_file); - if (!boost::filesystem::exists(dest_path)) - boost::filesystem::create_directories(dest_path); + if (!boost::filesystem::exists(io_dest)) + boost::filesystem::create_directories(io_dest); continue; } if (stat.m_uncomp_size == 0) { @@ -167,10 +213,9 @@ bool extract_archive_confined(const std::string &zip_path_utf8, const std::strin continue; } // Replace a symlink at the destination rather than writing through it. - const boost::filesystem::path dest_path(dest_file); - if (boost::filesystem::is_symlink(boost::filesystem::symlink_status(dest_path))) - boost::filesystem::remove(dest_path); - if (!mz_zip_reader_extract_to_file(&archive, stat.m_file_index, dest_file.c_str(), 0)) { + if (boost::filesystem::is_symlink(boost::filesystem::symlink_status(io_dest))) + boost::filesystem::remove(io_dest); + if (!extract_zip_entry_to_file(&archive, stat.m_file_index, dest_path)) { BOOST_LOG_TRIVIAL(error) << "Unzip: extract file " << stat.m_filename << " to dest " << dest_file << " failed"; close_zip_reader(&archive); return false; diff --git a/src/libslic3r/miniz_extension.hpp b/src/libslic3r/miniz_extension.hpp index 97aa91b93f..4953f61f58 100644 --- a/src/libslic3r/miniz_extension.hpp +++ b/src/libslic3r/miniz_extension.hpp @@ -1,6 +1,7 @@ #ifndef MINIZ_EXTENSION_HPP #define MINIZ_EXTENSION_HPP +#include #include #include @@ -13,6 +14,10 @@ bool close_zip_writer(mz_zip_archive *zip); std::string decode_archive_entry_path(mz_zip_archive *zip, const mz_zip_archive_file_stat &stat); // Extracts every entry of the archive under dest_dir. Nothing is written if any entry would resolve outside dest_dir. bool extract_archive_confined(const std::string &zip_path_utf8, const std::string &dest_dir); +// Extracts one entry into dest_path, binary-safe and overwriting. On Windows the destination is +// opened through an extended-length path so it works past MAX_PATH even with LongPathsEnabled off; +// a partial file is removed if the write fails. False for a directory/unsupported entry or on error. +bool extract_zip_entry_to_file(mz_zip_archive *zip, mz_uint file_index, const boost::filesystem::path &dest_path); class MZ_Archive { public: diff --git a/src/libslic3r/utils.cpp b/src/libslic3r/utils.cpp index 260e1e7d95..12b3083d4d 100644 --- a/src/libslic3r/utils.cpp +++ b/src/libslic3r/utils.cpp @@ -2254,5 +2254,45 @@ bool check_layer_id_pattern(const std::string& pattern, int layer_id){ return mod >= 0 && mod < count; } +boost::filesystem::path extended_path(const boost::filesystem::path& path) { +#ifdef WIN32 + namespace fs = boost::filesystem; + + // Already extended, or an explicit device path (which is not subject to MAX_PATH and must not + // be absolutized or normalized): leave it alone. + { + fs::path prefix_candidate = path; + const std::wstring native = prefix_candidate.make_preferred().wstring(); + if (native.rfind(L"\\\\?\\", 0) == 0 || native.rfind(L"\\\\.\\", 0) == 0) + return fs::path(native); + } + + fs::path absolute = path; + if (!absolute.is_absolute()) { + boost::system::error_code ec; + absolute = fs::absolute(absolute, ec); + if (ec) + return path; // can't form a valid prefixed path from a relative one + } + + // A trailing separator is redundant to the Win32 APIs, but lexically_normal() would rewrite it + // as a "." element, which the "\\?\" prefix (normalisation off) takes literally. Drop it first. + while (absolute.has_relative_path() && absolute.filename() == ".") + absolute = absolute.parent_path(); + + // The "\\?\" prefix turns off Win32 normalisation, so do it here first. + absolute = absolute.lexically_normal(); + + // Extended paths only recognize backslash separators. + const std::wstring native = absolute.make_preferred().wstring(); + if (native.empty()) + return path; + if (native.rfind(L"\\\\", 0) == 0) + return fs::path(L"\\\\?\\UNC\\" + native.substr(2)); // \\server\share\... -> \\?\UNC\server\share\... + return fs::path(L"\\\\?\\" + native); // C:\dir\... -> \\?\C:\dir\... +#else + return path; +#endif +} }; // namespace Slic3r diff --git a/src/slic3r/plugin/PluginFsUtils.cpp b/src/slic3r/plugin/PluginFsUtils.cpp index c04b15c26f..1dd8391ad7 100644 --- a/src/slic3r/plugin/PluginFsUtils.cpp +++ b/src/slic3r/plugin/PluginFsUtils.cpp @@ -34,10 +34,6 @@ #include #include -#ifdef WIN32 -#include -#endif - namespace Slic3r { const char* const INSTALL_STATE_FILE = ".install_state.json"; @@ -665,7 +661,7 @@ bool extract_zip_to_directory(const boost::filesystem::path& zip_path, const boo namespace fs = boost::filesystem; boost::system::error_code ec; - fs::create_directories(destination, ec); + fs::create_directories(extended_path(destination), ec); if (ec) { error = "Failed to create plugin staging directory: " + ec.message(); return false; @@ -709,7 +705,7 @@ bool extract_zip_to_directory(const boost::filesystem::path& zip_path, const boo const fs::path output_path = destination / relative_path; if (stat.m_is_directory || mz_zip_reader_is_file_a_directory(&reader.archive, stat.m_file_index)) { - fs::create_directories(output_path, ec); + fs::create_directories(extended_path(output_path), ec); if (ec) { error = "Failed to create plugin zip directory " + output_path.string() + ": " + ec.message(); return false; @@ -717,26 +713,19 @@ bool extract_zip_to_directory(const boost::filesystem::path& zip_path, const boo continue; } - fs::create_directories(output_path.parent_path(), ec); + fs::create_directories(extended_path(output_path.parent_path()), ec); if (ec) { error = "Failed to create plugin zip parent directory " + output_path.parent_path().string() + ": " + ec.message(); return false; } - if (fs::exists(output_path, ec) && fs::is_directory(output_path, ec)) { + if (fs::exists(extended_path(output_path), ec) && fs::is_directory(extended_path(output_path), ec)) { error = "Plugin zip file conflicts with an existing directory: " + output_path.string(); return false; } - const std::string encoded_output_path = encode_path(output_path.string().c_str()); - mz_bool extracted = mz_zip_reader_extract_to_file(&reader.archive, stat.m_file_index, encoded_output_path.c_str(), 0); -#ifdef WIN32 - if (!extracted) { - const std::wstring wide_output_path = boost::locale::conv::utf_to_utf(output_path.generic_string()); - extracted = mz_zip_reader_extract_to_file_w(&reader.archive, stat.m_file_index, wide_output_path.c_str(), 0); - } -#endif - if (!extracted) { + // Shared with the rest of the archive code: long-path capable and binary-safe. + if (!extract_zip_entry_to_file(&reader.archive, stat.m_file_index, output_path)) { error = "Failed to extract plugin zip entry " + relative_key + ": " + MZ_Archive::get_errorstr(mz_zip_get_last_error(&reader.archive)); return false; diff --git a/tests/libslic3r/test_miniz_extension.cpp b/tests/libslic3r/test_miniz_extension.cpp index 3a10a8eb8e..dfb23c22cb 100644 --- a/tests/libslic3r/test_miniz_extension.cpp +++ b/tests/libslic3r/test_miniz_extension.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -176,6 +177,55 @@ TEST_CASE("Confined extraction writes nothing outside the target for Windows-spe CHECK(list_dir(tmp.path()) == std::vector{"bundle.zip", "cache"}); } +TEST_CASE("Confined extraction writes files under a target path longer than MAX_PATH", "[MinizExtension]") +{ + ScopedTemporaryDir tmp; + const fs::path zip_file = tmp.path() / "bundle.zip"; + // Directory entries are explicit: extract_archive_confined recreates directories only for them. + write_zip(zip_file, {{"vendor/", ""}, {"vendor/machine/", ""}, {"vendor.json", "{\"a\":1}"}, + {"vendor/machine/printer.json", "{\"b\":2}"}}); + + // One 240-char component keeps every name below the NTFS 255 limit while pushing the full + // target path past the 260-char MAX_PATH the Win32 APIs enforce when LongPathsEnabled is off. + const fs::path target = tmp.path() / std::string(240, 'd'); + REQUIRE(target.string().size() > 260); + fs::create_directories(extended_path(target)); + + REQUIRE(extract_archive_confined(zip_file.string(), target.string())); + // Verify through extended-length paths, otherwise the checks themselves are bounded by MAX_PATH. + CHECK(fs::is_directory(extended_path(target / "vendor"))); + CHECK(read_file(extended_path(target / "vendor.json")) == "{\"a\":1}"); + CHECK(read_file(extended_path(target / "vendor" / "machine" / "printer.json")) == "{\"b\":2}"); + + // The temporary dir guard cannot remove a deep tree on Windows, so drop it here. + boost::system::error_code ec; + fs::remove_all(extended_path(target), ec); + CHECK_FALSE(ec); +} + +TEST_CASE("A zip archive under a path longer than MAX_PATH can be written and read", "[MinizExtension]") +{ + ScopedTemporaryDir tmp; + + // Create the long directory with the extended-length path, then exercise the archive + // reader/writer against an archive whose own path is past MAX_PATH. + const fs::path long_dir = tmp.path() / std::string(240, 'z'); + fs::create_directories(extended_path(long_dir)); + const fs::path zip_file = long_dir / "bundle.zip"; + REQUIRE(zip_file.string().size() > 260); + + write_zip(zip_file, {{"vendor.json", "{\"a\":1}"}}); + + const fs::path target = tmp.path() / "out"; + fs::create_directories(target); + REQUIRE(extract_archive_confined(zip_file.string(), target.string())); + CHECK(read_file(target / "vendor.json") == "{\"a\":1}"); + + boost::system::error_code ec; + fs::remove_all(extended_path(long_dir), ec); + CHECK_FALSE(ec); +} + #ifndef _WIN32 TEST_CASE("Confined extraction replaces a symlink at the destination instead of writing through it", "[MinizExtension]") { diff --git a/tests/libslic3r/test_utils.cpp b/tests/libslic3r/test_utils.cpp index 49161d5d6c..d87b8e686e 100644 --- a/tests/libslic3r/test_utils.cpp +++ b/tests/libslic3r/test_utils.cpp @@ -521,3 +521,49 @@ TEST_CASE("ascii_iequals compares ASCII letters regardless of case", "[Utils]") CHECK_FALSE(ascii_iequals("[", "{")); CHECK_FALSE(ascii_iequals("@", "`")); } + +TEST_CASE("extended_path turns a Windows path into its long-path form", "[Utils]") { +#ifdef _WIN32 + namespace fs = boost::filesystem; + + SECTION("a normal absolute drive path is prefixed") { + CHECK(extended_path(fs::path(R"(C:\models\model.3mf)")).wstring() == LR"(\\?\C:\models\model.3mf)"); + } + SECTION("separators are made native") { + CHECK(extended_path(fs::path("C:/models/model.3mf")).wstring() == LR"(\\?\C:\models\model.3mf)"); + } + SECTION("dot segments are collapsed before prefixing") { + // The "\\?\" prefix disables Win32 normalisation, so "." and ".." must be gone beforehand. + CHECK(extended_path(fs::path(R"(C:\models\..\model.3mf)")).wstring() == LR"(\\?\C:\model.3mf)"); + } + SECTION("a trailing separator is dropped, not turned into a dot element") { + CHECK(extended_path(fs::path(R"(C:\models\)")).wstring() == LR"(\\?\C:\models)"); + } + SECTION("a UNC path becomes an extended UNC path") { + CHECK(extended_path(fs::path(R"(\\server\share\model.3mf)")).wstring() == LR"(\\?\UNC\server\share\model.3mf)"); + } + SECTION("an already extended path is returned unchanged") { + const fs::path extended(R"(\\?\C:\models\model.3mf)"); + CHECK(extended_path(extended) == extended); + } + SECTION("a device path is returned unchanged") { + const fs::path device(R"(\\.\C:\models\model.3mf)"); + CHECK(extended_path(device) == device); + } + SECTION("a bare device path without a root directory is returned unchanged") { + // Not "absolute" by Boost's rules, so it must be recognized before absolutizing. + const fs::path device(R"(\\.\PhysicalDrive0)"); + CHECK(extended_path(device) == device); + } + SECTION("a relative path is completed against the working directory") { + const fs::path result = extended_path(fs::path("model.3mf")); + CHECK(result.wstring().rfind(L"\\\\?\\", 0) == 0); + CHECK(result.filename() == fs::path("model.3mf")); + } +#else + SECTION("non-Windows returns the path unchanged") { + const boost::filesystem::path p = boost::filesystem::temp_directory_path() / "model.3mf"; + CHECK(extended_path(p) == p); + } +#endif +} diff --git a/tests/slic3rutils/test_plugin_install.cpp b/tests/slic3rutils/test_plugin_install.cpp index 0f3b9a7fcf..ac7f826ed8 100644 --- a/tests/slic3rutils/test_plugin_install.cpp +++ b/tests/slic3rutils/test_plugin_install.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -10,13 +11,17 @@ #include #include #include +#include "catch2/catch_message.hpp" #include "plugin_test_utils.hpp" +#include "test_utils.hpp" #include #include #include +#include + using namespace Slic3r; namespace fs = boost::filesystem; @@ -31,6 +36,14 @@ fs::path write_py_file(const fs::path& dir, const std::string& filename, const s 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]") @@ -184,3 +197,59 @@ TEST_CASE("an unsubscribed cloud plugin follows its header version", "[PluginIns 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")); +}