mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-04 22:31:02 +00:00
* Sanitize Server-Supplied Download File Names The URL downloader used the file name from the Content-Disposition header as given, without the cleaning and unused-name search applied to the URL-derived name. Reduce the header name to a sanitized base name with the new sanitize_file_basename helper, which splits on both path separators and rejects names made only of dots and spaces. Run the result through the same unused-name search as the URL-derived name, now shared in find_unused_filename, and fall back to the URL-derived name when nothing usable remains. * Sanitize Download Names Before Choosing an Unused One The unused-name search probed the name as given and sanitized the result afterwards, so a name whose special characters are replaced could be mapped onto a file that already exists. Move the search into libslic3r as find_unused_filename, sanitize first and probe the name that is actually written. The download marker path is shared through download_marker_path. Restore the last tried name in the error reported when no free name is found, and cover the search with unit tests. * Keep Downloads on an Unused Name Until They Complete When the server supplied the name, the download marker stayed under the URL-derived name, so the adopted name was not reserved against other downloads. The final rename also replaced any file that took the name while the download ran. Move the marker to the adopted name before any data is written, and check the name again right before the final rename, picking the next free name if it is taken by then. * Sanitize the File Name of Model Import Links The model import took the file name from the link as given and only avoided an existing file with a substring match on the folder listing. Reduce the name to a sanitized base name, falling back to untitled.3mf, choose the name with the shared unused-name search, and check it again before the final rename. * Handle Filesystem Errors When Finishing a Model Import Download Choosing the final name and moving the downloaded project into place could throw from inside the download callback. Any such error now removes the temporary file and reports the existing import failure message.
246 lines
9.8 KiB
C++
246 lines
9.8 KiB
C++
#include <catch2/catch_all.hpp>
|
|
|
|
#include "libslic3r/Utils.hpp"
|
|
|
|
#include "test_utils.hpp"
|
|
|
|
#include <boost/filesystem.hpp>
|
|
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <fstream>
|
|
#include <string>
|
|
|
|
#ifndef _WIN32
|
|
#include <unistd.h> // getuid
|
|
#endif
|
|
|
|
using namespace Slic3r;
|
|
|
|
TEST_CASE("per_user_temp_dir composes a per-user temp root", "[utils]") {
|
|
const std::string base = "/tmp";
|
|
|
|
SECTION("an empty id returns base unchanged") {
|
|
REQUIRE(per_user_temp_dir(base, "") == base);
|
|
}
|
|
SECTION("a non-empty id is appended at the top level") {
|
|
REQUIRE(per_user_temp_dir(base, "1000") == base + "/orcaslicer_1000");
|
|
}
|
|
SECTION("distinct ids produce distinct roots") {
|
|
REQUIRE(per_user_temp_dir(base, "1000") != per_user_temp_dir(base, "1001"));
|
|
}
|
|
}
|
|
|
|
TEST_CASE("per_user_temp_id follows the platform contract", "[utils]") {
|
|
const std::string id = per_user_temp_id();
|
|
|
|
SECTION("stable across calls") {
|
|
REQUIRE(per_user_temp_id() == id);
|
|
}
|
|
#ifdef _WIN32
|
|
SECTION("empty on Windows (its temp dir is already per-user)") {
|
|
REQUIRE(id.empty());
|
|
}
|
|
#else
|
|
SECTION("the current uid on Linux/macOS") {
|
|
REQUIRE_FALSE(id.empty());
|
|
REQUIRE(id == std::to_string(static_cast<unsigned long>(::getuid())));
|
|
}
|
|
#endif
|
|
}
|
|
|
|
// The end-to-end contract callers depend on: the temp root is left alone on
|
|
// Windows and isolated per user on Linux/macOS.
|
|
TEST_CASE("per-user temp root is unchanged on Windows, isolated elsewhere", "[utils]") {
|
|
const std::string base = "/tmp";
|
|
const std::string root = per_user_temp_dir(base, per_user_temp_id());
|
|
#ifdef _WIN32
|
|
REQUIRE(root == base);
|
|
#else
|
|
REQUIRE(root != base);
|
|
REQUIRE_THAT(root, Catch::Matchers::StartsWith(base + "/orcaslicer_"));
|
|
#endif
|
|
}
|
|
|
|
TEST_CASE("copy_file reports the OS error when the destination cannot be written", "[utils]") {
|
|
ScopedTemporaryFile source(".txt");
|
|
{
|
|
std::ofstream ofs(source.string(), std::ios::binary);
|
|
ofs << "orca";
|
|
}
|
|
REQUIRE(boost::filesystem::exists(source.path()));
|
|
|
|
// A directory that was never created, so the copy fails on every platform.
|
|
const boost::filesystem::path destination = source.path().parent_path() / "orca-missing-dir" / "copy.txt";
|
|
REQUIRE_FALSE(boost::filesystem::exists(destination.parent_path()));
|
|
|
|
std::string error_message;
|
|
REQUIRE(copy_file(source.string(), destination.string(), error_message) == FAIL_COPY_FILE);
|
|
REQUIRE_FALSE(error_message.empty());
|
|
|
|
#ifdef _WIN32
|
|
// The Windows branch formats GetLastError() itself. Writing that as
|
|
// "Error: " + errCode adds an integer to a string literal, which indexes into the
|
|
// literal instead of appending and runs off its end for any code above 7.
|
|
const std::string prefix = "Error: ";
|
|
REQUIRE(error_message.rfind(prefix, 0) == 0);
|
|
|
|
const std::string code = error_message.substr(prefix.size());
|
|
REQUIRE_FALSE(code.empty());
|
|
REQUIRE(std::all_of(code.begin(), code.end(), [](unsigned char c) { return std::isdigit(c) != 0; }));
|
|
#endif // _WIN32
|
|
}
|
|
|
|
TEST_CASE("A resolved input path still names the same file after the working directory changes", "[utils]") {
|
|
ScopedTemporaryFile model(".3mf");
|
|
{ std::ofstream out(model.string()); out << "3mf"; }
|
|
const std::string name = model.path().filename().string();
|
|
|
|
// Resolve the bare name from the directory holding the file, then move away from it. The guard
|
|
// restores the directory the test started in, wherever this leaves it.
|
|
ScopedWorkingDirectory cwd(model.path().parent_path());
|
|
const std::string resolved = resolve_cli_input_path(name);
|
|
boost::filesystem::current_path(boost::filesystem::path(TEST_DATA_DIR));
|
|
|
|
REQUIRE(boost::filesystem::exists(resolved));
|
|
REQUIRE(boost::filesystem::equivalent(resolved, model.path()));
|
|
// Control: the bare name finds nothing from here, so resolving it this late would have failed.
|
|
REQUIRE_FALSE(boost::filesystem::exists(name));
|
|
}
|
|
|
|
TEST_CASE("resolve_cli_input_path completes a relative path against the working directory", "[utils]") {
|
|
ScopedWorkingDirectory cwd(boost::filesystem::temp_directory_path());
|
|
// Read back rather than reusing temp_directory_path(): changing to it resolves any symlink.
|
|
const boost::filesystem::path here = boost::filesystem::current_path();
|
|
|
|
SECTION("a bare name") {
|
|
REQUIRE(resolve_cli_input_path("model.3mf") == (here / "model.3mf").make_preferred().string());
|
|
}
|
|
SECTION("a ./ prefix is dropped") {
|
|
REQUIRE(resolve_cli_input_path("./model.3mf") == (here / "model.3mf").make_preferred().string());
|
|
}
|
|
SECTION("a ../ traversal is collapsed") {
|
|
REQUIRE(resolve_cli_input_path("../model.3mf") == (here.parent_path() / "model.3mf").make_preferred().string());
|
|
}
|
|
}
|
|
|
|
TEST_CASE("resolve_cli_input_path leaves inputs that must not be completed unchanged", "[utils]") {
|
|
SECTION("an absolute path") {
|
|
const boost::filesystem::path absolute = (boost::filesystem::temp_directory_path() / "model.3mf").make_preferred();
|
|
REQUIRE(resolve_cli_input_path(absolute.string()) == absolute.string());
|
|
}
|
|
#ifdef _WIN32
|
|
// Every absolute form Windows accepts opens today, so each must come back byte for byte:
|
|
// normalizing them would rewrite the forward slashes and rebuild the \\?\ and UNC prefixes.
|
|
SECTION("an absolute Windows path of any form") {
|
|
for (const std::string absolute : {R"(C:\models\model.3mf)",
|
|
R"(C:/models/model.3mf)",
|
|
R"(\\server\share\model.3mf)",
|
|
R"(\\?\C:\models\model.3mf)"})
|
|
REQUIRE(resolve_cli_input_path(absolute) == absolute);
|
|
}
|
|
#endif
|
|
// These are downloaded rather than opened, and completing one would produce a path, not a URL.
|
|
SECTION("a custom open protocol URL") {
|
|
for (const std::string url : {"orcaslicer://open/?file=https://example.com/model.3mf",
|
|
"prusaslicer://open/?file=https://example.com/model.3mf",
|
|
"bambustudio://open/?file=https://example.com/model.3mf",
|
|
"cura://open/?file=https://example.com/model.3mf"})
|
|
REQUIRE(resolve_cli_input_path(url) == url);
|
|
}
|
|
SECTION("an empty argument") {
|
|
REQUIRE(resolve_cli_input_path("").empty());
|
|
}
|
|
}
|
|
|
|
TEST_CASE("sanitize_file_basename keeps only a plain file name from an untrusted name", "[Utils]") {
|
|
const std::string unicode = "\xe6\xa8\xa1\xe5\x9e\x8b \xc3\xa9t\xc3\xa9.3mf"; // UTF-8 CJK and accented Latin
|
|
const auto [input, expected] = GENERATE_COPY(table<std::string, std::string>({
|
|
{"normal.3mf", "normal.3mf"},
|
|
{"../../x.3mf", "x.3mf"},
|
|
{"..\\..\\x.3mf", "x.3mf"},
|
|
{"C:\\x.3mf", "x.3mf"},
|
|
{"C:x.3mf", "C_x.3mf"},
|
|
{"/etc/x", "x"},
|
|
{"a/b\\c.gcode", "c.gcode"},
|
|
{"x:stream", "x_stream"}, // no NTFS alternate data stream
|
|
{"x.", "x."},
|
|
{".3mf", ".3mf"},
|
|
{unicode, unicode},
|
|
}));
|
|
CAPTURE(input);
|
|
CHECK(sanitize_file_basename(input) == expected);
|
|
}
|
|
|
|
TEST_CASE("sanitize_file_basename rejects names that do not name a file", "[Utils]") {
|
|
const std::string input = GENERATE(as<std::string>{}, "", ".", "..", "../..", "dir/", "..\\", " ", ". .", "...");
|
|
CAPTURE(input);
|
|
CHECK(sanitize_file_basename(input).empty());
|
|
}
|
|
|
|
namespace {
|
|
void touch(const boost::filesystem::path &path) { std::ofstream(path.string()) << "existing"; }
|
|
std::string file_contents(const boost::filesystem::path &path)
|
|
{
|
|
std::ifstream file(path.string());
|
|
return std::string(std::istreambuf_iterator<char>(file), std::istreambuf_iterator<char>());
|
|
}
|
|
} // namespace
|
|
|
|
TEST_CASE("find_unused_filename keeps a name nothing uses", "[Utils]") {
|
|
ScopedTemporaryDir dir;
|
|
std::string name;
|
|
REQUIRE(find_unused_filename(dir.path(), "model.3mf", {}, name));
|
|
CHECK(name == "model.3mf");
|
|
}
|
|
|
|
TEST_CASE("find_unused_filename versions a name an existing file uses", "[Utils]") {
|
|
ScopedTemporaryDir dir;
|
|
touch(dir.path() / "model.3mf");
|
|
std::string name;
|
|
REQUIRE(find_unused_filename(dir.path(), "model.3mf", {}, name));
|
|
CHECK(name == "model(1).3mf");
|
|
}
|
|
|
|
TEST_CASE("find_unused_filename versions a name that maps onto an existing file once sanitized", "[Utils]") {
|
|
ScopedTemporaryDir dir;
|
|
touch(dir.path() / "my_model.3mf");
|
|
const std::string input = GENERATE(as<std::string>{}, "my?model.3mf", "my:model.3mf", "my*model.3mf");
|
|
CAPTURE(input);
|
|
std::string name;
|
|
REQUIRE(find_unused_filename(dir.path(), input, {}, name));
|
|
CHECK(name == "my_model(1).3mf");
|
|
CHECK(file_contents(dir.path() / "my_model.3mf") == "existing");
|
|
}
|
|
|
|
TEST_CASE("find_unused_filename treats the marker of another download as used", "[Utils]") {
|
|
ScopedTemporaryDir dir;
|
|
touch(download_marker_path(dir.path(), "model.3mf"));
|
|
std::string name;
|
|
REQUIRE(find_unused_filename(dir.path(), "model.3mf", {}, name));
|
|
CHECK(name == "model(1).3mf");
|
|
}
|
|
|
|
TEST_CASE("find_unused_filename ignores the marker of the download asking", "[Utils]") {
|
|
ScopedTemporaryDir dir;
|
|
const boost::filesystem::path own_marker = download_marker_path(dir.path(), "model.3mf");
|
|
touch(own_marker);
|
|
std::string name;
|
|
REQUIRE(find_unused_filename(dir.path(), "model.3mf", own_marker, name));
|
|
CHECK(name == "model.3mf");
|
|
}
|
|
|
|
TEST_CASE("find_unused_filename gives up after 999 versions", "[Utils]") {
|
|
ScopedTemporaryDir dir;
|
|
touch(dir.path() / "model.3mf");
|
|
for (int version = 1; version < 999; ++version)
|
|
touch(dir.path() / ("model(" + std::to_string(version) + ").3mf"));
|
|
std::string name;
|
|
REQUIRE(find_unused_filename(dir.path(), "model.3mf", {}, name));
|
|
CHECK(name == "model(999).3mf");
|
|
|
|
touch(dir.path() / name);
|
|
REQUIRE_FALSE(find_unused_filename(dir.path(), "model.3mf", {}, name));
|
|
CHECK(name == "model(999).3mf");
|
|
}
|