Compare commits

..
Author SHA1 Message Date
Hanif Koh 54ed1cbade 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.
2026-09-28 16:38:14 +08:00
Hanif Koh d4a611147c 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.
2026-09-28 15:54:48 +08:00
Hanif Koh d6d19795b2 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.
2026-09-28 15:54:48 +08:00
Hanif Koh 570b94ec49 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.
2026-09-28 15:54:48 +08:00
Hanif Koh f99cf7ca3f 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.
2026-09-28 04:48:31 +08:00
12 changed files with 232 additions and 444 deletions
-8
View File
@@ -44,14 +44,6 @@ jobs:
uses: actions/download-artifact@v8
with:
name: ${{ inputs.artifact }}
# run_unit_tests.sh installs the plugin tests' numpy with the uv the build stages
# beside them; the Windows arm64 build bundles none, so put one on PATH there.
- name: Install uv
if: runner.os == 'Windows' && runner.arch == 'ARM64'
uses: astral-sh/setup-uv@v10.2.0
with:
version: "0.11.21" # ORCA_UV_VERSION in CMakeLists.txt
enable-cache: false
- uses: lukka/get-cmake@latest
with:
cmakeVersion: "~4.3.0" # use most recent 4.3.x version
+1 -3
View File
@@ -52,6 +52,4 @@ internal_docs/
__pycache__/
*.pyc
*.opc
/.test/
docs/superpowers/
ctest_results.xml
docs/superpowers/
+1 -48
View File
@@ -2,8 +2,7 @@
# This file is made to support the unit tests workflow.
# It should only require the directories build/tests, scripts/, and tests/ to function,
# and cmake (with ctest) installed -- plus network access to PyPI whenever numpy has to
# be installed into a freshly built test tree (see below).
# and cmake (with ctest) installed.
# (otherwise, update the workflow too, but try to avoid to keep things self-contained)
#
# Usage: run_unit_tests.sh [TEST_DIR] [BUILD_CONFIG]
@@ -19,52 +18,6 @@ cd "${ROOT_DIR}" || exit 1
TEST_DIR="${1:-build/tests}"
BUILD_CONFIG="${2:-}"
# The slic3rutils plugin-host tests build numpy arrays through the CPython copied next
# to the test binary (see tests/slic3rutils/CMakeLists.txt), which ships no numpy.
# Install it with the uv staged beside that runtime -- the tool the app installs plugin
# dependencies with -- straight into the interpreter's own site-packages: no pip needed
# in the runtime, no PYTHONPATH. Re-checked every run because a rebuild of the test
# target re-copies the runtime; needs network whenever it installs. Pinned so a numpy
# release cannot change results on its own.
NUMPY_VERSION="2.5.3"
# Without numpy those tests assert the numpy-absent error path instead, so a local run
# only warns. Under CI it fails the run, which would otherwise stay green while silently
# dropping the array coverage. (The Flatpak leg runs this inside `flatpak build`, whose
# minimal environment has no CI, and its offline build stages no uv, so numpy stays
# best-effort there.)
numpy_unavailable() {
if [ -n "${CI:-}" ]; then
echo "error: $1" >&2
exit 1
fi
echo "warning: $1; the numpy-backed binding tests will cover only the numpy-absent path."
}
has_pinned_numpy() {
"${python_exe}" -c "import sys, numpy; sys.exit(numpy.__version__ != '${NUMPY_VERSION}')" >/dev/null 2>&1
}
find_args=("${TEST_DIR}" \( -path '*/python/bin/python3' -o -path '*/python/python.exe' \))
# Multi-config trees hold one copy per configuration; only bootstrap the one being run.
[ -n "${BUILD_CONFIG}" ] && find_args+=(-path "*/${BUILD_CONFIG}/*")
python_exe="$(find "${find_args[@]}" -print -quit 2>/dev/null)"
if [ -z "${python_exe}" ]; then
numpy_unavailable "no bundled Python under ${TEST_DIR}"
elif ! has_pinned_numpy; then
uv_exe="${python_exe%/python/*}/tools/uv/uv"
# Builds that bundle no uv (Windows arm64) fall back to one on PATH.
[ -x "${uv_exe}" ] || uv_exe="$(command -v uv)"
echo "Installing numpy ${NUMPY_VERSION} into the embedded test interpreter (${python_exe})..."
if [ -z "${uv_exe}" ]; then
numpy_unavailable "no uv staged beside the tests or on PATH"
elif ! "${uv_exe}" pip install --python "${python_exe}" --only-binary :all: "numpy==${NUMPY_VERSION}" \
|| ! has_pinned_numpy; then
numpy_unavailable "could not install numpy ${NUMPY_VERSION} into ${python_exe}"
fi
fi
# Run the whole suite, excluding tests tagged [NotWorking] and tests labelled RequiresApp,
# which run the built orca-slicer binary that this directory does not contain.
# --no-tests=error fails the job if the filter matches nothing (instead of passing green).
+15
View File
@@ -285,6 +285,21 @@ inline std::string sanitize_filename(const std::string &filename){
const std::regex special_chars("[/\\\\:*?\"<>|]");
return std::regex_replace(filename, special_chars, "_");
}
// Reduce an untrusted, possibly path-qualified name to a single sanitized file name.
// Returns an empty string when nothing usable remains.
inline std::string sanitize_file_basename(const std::string &name){
const size_t sep = name.find_last_of("/\\");
const std::string base = sanitize_filename(sep == std::string::npos ? name : name.substr(sep + 1));
// Names made only of dots and spaces refer to the folder or its parent, or are stripped to nothing on Windows.
return base.find_first_not_of(". ") == std::string::npos ? std::string() : base;
}
// Marker file a download of this process writes to before it is renamed to filename.
boost::filesystem::path download_marker_path(const boost::filesystem::path &dest_folder, const std::string &filename);
// Finds a sanitized variant of filename, "name(N).ext" if needed, that neither an entry of dest_folder
// nor the download marker of another download uses. The marker at ignored_marker does not count.
// Returns true and the name in result, or false and the last name tried.
bool find_unused_filename(const boost::filesystem::path &dest_folder, const std::string &filename,
const boost::filesystem::path &ignored_marker, std::string &result);
// File path / name / extension splitting utilities, working with UTF-8,
// to be published to Perl.
namespace PerlUtils {
+25
View File
@@ -1321,6 +1321,31 @@ unsigned get_current_pid()
#endif
}
boost::filesystem::path download_marker_path(const boost::filesystem::path &dest_folder, const std::string &filename)
{
return dest_folder / (filename + "." + std::to_string(get_current_pid()) + ".download");
}
bool find_unused_filename(const boost::filesystem::path &dest_folder, const std::string &filename,
const boost::filesystem::path &ignored_marker, std::string &result)
{
// Probe the name that will be written, so a name the sanitizing maps onto an existing file is versioned too.
const std::string sanitized = sanitize_filename(filename);
const std::string extension = boost::filesystem::path(sanitized).extension().string();
const std::string stem = sanitized.substr(0, sanitized.size() - extension.size());
auto is_used = [&](const std::string &name) {
const boost::filesystem::path marker = download_marker_path(dest_folder, name);
return boost::filesystem::exists(dest_folder / name) || (marker != ignored_marker && boost::filesystem::exists(marker));
};
result = sanitized;
for (size_t version = 1; is_used(result); ++version) {
if (version > 999)
return false;
result = stem + "(" + std::to_string(version) + ")" + extension;
}
return true;
}
std::string per_user_temp_id()
{
#ifdef WIN32
+51 -32
View File
@@ -71,17 +71,6 @@ bool FileGet::is_subdomain(const std::string& url, const std::string& domain)
return false;
}
namespace {
unsigned get_current_pid()
{
#ifdef WIN32
return GetCurrentProcessId();
#else
return ::getpid();
#endif
}
}
// int = DOWNLOAD ID; string = file path
wxDEFINE_EVENT(EVT_DWNLDR_FILE_COMPLETE, wxCommandEvent);
// int = DOWNLOAD ID; string = error msg
@@ -144,25 +133,10 @@ void FileGet::priv::get_perform()
std::string extension;
if (m_written == 0)
{
boost::filesystem::path dest_path = m_dest_folder / m_filename;
extension = dest_path.extension().string();
std::string just_filename = m_filename.substr(0, m_filename.size() - extension.size());
std::string final_filename = just_filename;
// Find unsed filename
std::string final_filename;
bool found = false;
try {
size_t version = 0;
while (boost::filesystem::exists(m_dest_folder / (final_filename + extension)) || boost::filesystem::exists(m_dest_folder / (final_filename + extension + "." + std::to_string(get_current_pid()) + ".download")))
{
++version;
if (version > 999) {
wxCommandEvent* evt = new wxCommandEvent(EVT_DWNLDR_FILE_ERROR);
evt->SetString(GUI::format_wxstr(L"Failed to find suitable filename. Last name: %1%." , (m_dest_folder / (final_filename + extension)).string()));
evt->SetInt(m_id);
m_evt_handler->QueueEvent(evt);
return;
}
final_filename = GUI::format("%1%(%2%)", just_filename, std::to_string(version));
}
found = find_unused_filename(m_dest_folder, m_filename, m_tmp_path, final_filename);
} catch (const boost::filesystem::filesystem_error& e)
{
wxCommandEvent* evt = new wxCommandEvent(EVT_DWNLDR_FILE_ERROR);
@@ -171,10 +145,18 @@ void FileGet::priv::get_perform()
m_evt_handler->QueueEvent(evt);
return;
}
if (!found) {
wxCommandEvent* evt = new wxCommandEvent(EVT_DWNLDR_FILE_ERROR);
evt->SetString(GUI::format_wxstr(L"Failed to find suitable filename. Last name: %1%." , (m_dest_folder / final_filename).string()));
evt->SetInt(m_id);
m_evt_handler->QueueEvent(evt);
return;
}
m_filename = sanitize_filename(final_filename + extension);
m_filename = final_filename;
extension = boost::filesystem::path(m_filename).extension().string();
m_tmp_path = m_dest_folder / (m_filename + "." + std::to_string(get_current_pid()) + ".download");
m_tmp_path = download_marker_path(m_dest_folder, m_filename);
wxCommandEvent* evt = new wxCommandEvent(EVT_DWNLDR_FILE_NAME_CHANGE);
evt->SetString(boost::nowide::widen(m_filename));
@@ -221,7 +203,32 @@ void FileGet::priv::get_perform()
if(dest_path.empty()) {
std::string filename = extract_remote_filename(header);
if (!filename.empty()) {
m_filename = filename;
// The name comes from the server: keep it inside the destination folder and never
// replace an existing file. Keep the current name if nothing usable remains.
filename = sanitize_file_basename(filename);
std::string unused;
try {
if (filename.empty() || !find_unused_filename(m_dest_folder, filename, m_tmp_path, unused))
unused.clear();
} catch (const boost::filesystem::filesystem_error&) {
unused.clear();
}
const boost::filesystem::path tmp_path = unused.empty() ? m_tmp_path : download_marker_path(m_dest_folder, unused);
if (tmp_path != m_tmp_path) {
// Move the marker to the adopted name so that other downloads see the name as taken.
// Only before anything is written, so that no downloaded data has to be carried over.
FILE* tmp_file = m_written == 0 ? fopen(wxString(tmp_path.wstring()).c_str(), "wb") : nullptr;
if (tmp_file != nullptr) {
fclose(file);
boost::system::error_code ec;
boost::filesystem::remove(m_tmp_path, ec);
file = tmp_file;
m_tmp_path = tmp_path;
} else
unused.clear();
}
if (!unused.empty())
m_filename = unused;
dest_path = m_dest_folder / m_filename;
wxCommandEvent* evt = new wxCommandEvent(EVT_DWNLDR_FILE_NAME_CHANGE);
evt->SetString(boost::nowide::widen(m_filename));
@@ -327,6 +334,18 @@ void FileGet::priv::get_perform()
m_evt_handler->QueueEvent(evt);
}
fclose(file);
// Another file may have taken the name while downloading.
if (!dest_path.empty() && boost::filesystem::exists(dest_path)) {
std::string unused;
if (!find_unused_filename(m_dest_folder, m_filename, m_tmp_path, unused))
throw std::runtime_error("No unused file name.");
m_filename = unused;
dest_path = m_dest_folder / m_filename;
wxCommandEvent* evt = new wxCommandEvent(EVT_DWNLDR_FILE_NAME_CHANGE);
evt->SetString(boost::nowide::widen(m_filename));
evt->SetInt(m_id);
m_evt_handler->QueueEvent(evt);
}
boost::filesystem::rename(m_tmp_path, dest_path);
}
catch (const std::exception& /*e*/)
+38 -47
View File
@@ -15681,6 +15681,11 @@ void Plater::import_model_id(wxString download_info)
//wxString sError = error.what();
}
// The name comes from the link: reduce it to a plain file name inside the download folder.
filename = from_u8(sanitize_file_basename(into_u8(filename)));
if (filename.empty())
filename = "untitled.3mf";
bool download_ok = false;
int retry_count = 0;
const int max_retries = 3;
@@ -15722,51 +15727,28 @@ void Plater::import_model_id(wxString download_info)
msg = _L("Preparing 3MF file...");
//gets the number of files with the same name
std::vector<wxString> vecFiles;
bool is_already_exist = false;
target_path = fs::path(wxGetApp().app_config->get("download_path"));
try
{
vecFiles.clear();
wxString extension = fs::path(filename.wx_str()).extension().c_str();
//check file suffix
if (!extension.Contains(".3mf")) {
msg = _L("Download failed; unknown file format.");
return;
}
auto name = filename.substr(0, filename.length() - extension.length() - 1);
for (const auto& iter : boost::filesystem::directory_iterator(target_path))
{
if (boost::filesystem::is_directory(iter.path()))
continue;
wxString sFile = iter.path().filename().string().c_str();
if (strstr(sFile.c_str(), name.c_str()) != NULL) {
vecFiles.push_back(sFile);
}
if (sFile == filename) is_already_exist = true;
}
}
catch (const std::exception&)
{
//wxString sError = error.what();
//check file suffix
wxString extension = fs::path(filename.wx_str()).extension().c_str();
if (!extension.Contains(".3mf")) {
msg = _L("Download failed; unknown file format.");
return;
}
//update filename
if (is_already_exist && vecFiles.size() >= 1) {
wxString extension = fs::path(filename.wx_str()).extension().c_str();
wxString name = filename.substr(0, filename.length() - extension.length());
filename = wxString::Format("%s(%d)%s", name, vecFiles.size() + 1, extension).ToStdString();
//never replace an existing file
std::string unused_filename;
try {
if (!find_unused_filename(target_path, into_u8(filename), {}, unused_filename))
unused_filename.clear();
} catch (const std::exception&) {
unused_filename.clear();
}
if (unused_filename.empty()) {
msg = _L("Importing to Orca Slicer failed. Please download the file and manually import it.");
return;
}
filename = from_u8(unused_filename);
msg = _L("Downloading project...");
@@ -15778,10 +15760,6 @@ void Plater::import_model_id(wxString download_info)
boost::uuids::uuid uuid = boost::uuids::random_generator()();
std::string unique = to_string(uuid).substr(0, 6);
if (filename.empty()) {
filename = "untitled.3mf";
}
//target_path /= (boost::format("%1%_%2%.3mf") % filename % unique).str();
target_path /= fs::path(filename.wc_str());
@@ -15830,13 +15808,26 @@ void Plater::import_model_id(wxString download_info)
cont = false;
}
})
.on_complete([&cont, &download_ok, tmp_path, target_path](std::string body, unsigned /* http_status */) {
.on_complete([&cont, &download_ok, &msg, tmp_path, &target_path](std::string body, unsigned /* http_status */) {
fs::fstream file(tmp_path, std::ios::out | std::ios::binary | std::ios::trunc);
file.write(body.c_str(), body.size());
file.close();
fs::rename(tmp_path, target_path);
cont = false;
download_ok = true;
try {
// Another file may have taken the name while downloading.
std::string unused_filename;
if (find_unused_filename(target_path.parent_path(), target_path.filename().string(), {}, unused_filename)) {
target_path = target_path.parent_path() / unused_filename;
fs::rename(tmp_path, target_path);
download_ok = true;
return;
}
} catch (const std::exception &e) {
BOOST_LOG_TRIVIAL(error) << "import_model_id: failed to move the download into place: " << e.what();
}
boost::system::error_code ec;
fs::remove(tmp_path, ec);
msg = _L("Importing to Orca Slicer failed. Please download the file and manually import it.");
}).perform_sync();
// for break while
+1 -59
View File
@@ -6,7 +6,6 @@
#include <boost/optional.hpp>
#include <boost/log/trivial.hpp>
#include <boost/filesystem.hpp>
#include <nlohmann/json.hpp>
#include <wx/string.h>
#include <wx/app.h>
@@ -116,67 +115,10 @@ std::string PrintHost::get_print_host_webui(DynamicPrintConfig* config)
return webui_url;
}
namespace {
// Moonraker (Klipper's API server) reports a raised exception as { "error": { "code", "message", "traceback" } }
// under every host type that connects to it, often with the cause only in the traceback. Returns the reason to show,
// or empty for any other body.
std::string moonraker_error_reason(const std::string &body)
{
const auto root = nlohmann::json::parse(body, nullptr, false);
const auto err = root.find("error");
if (err == root.end())
return {};
const auto message = err->find("message");
const auto traceback = err->find("traceback");
if (message == err->end() || traceback == err->end() || !message->is_string() || !traceback->is_string())
return {};
const auto &msg = message->get_ref<const std::string &>();
const auto &tb = traceback->get_ref<const std::string &>();
if (msg.empty())
return {};
const auto end = tb.find_last_not_of(" \t\r\n");
if (end == std::string::npos)
return msg;
// Chained exceptions each start a new traceback; the one that failed the request is the last.
const auto header = tb.rfind("Traceback (most recent call last):", end);
// Tornado renders a raised HTTPError as "HTTP <code>: <reason>[ (<detail>)]", and the detail may span lines.
const auto code = err->find("code");
if (code != err->end() && code->is_number_integer()) {
const std::string marker = "HTTP " + std::to_string(code->get<int>()) + ": ";
const auto pos = tb.rfind(marker, end);
if (pos != std::string::npos && (header == std::string::npos || pos > header) && pos + marker.size() <= end) {
const std::string reason = tb.substr(pos + marker.size(), end + 1 - pos - marker.size());
// An HTTPError whose detail equals its reason, like HTTPError(401, "Unauthorized"), renders the phrase twice.
return reason == msg + " (" + msg + ")" ? msg : reason;
}
}
// Any other exception's type and message are everything from the first unindented line after its frames.
auto begin = (header == std::string::npos) ? std::string::npos : tb.find('\n', header);
while (begin != std::string::npos && begin < end) {
++begin;
if (tb[begin] != ' ' && tb[begin] != '\r' && tb[begin] != '\n')
break;
begin = tb.find('\n', begin);
}
if (begin == std::string::npos || begin > end) {
const auto nl = tb.rfind('\n', end);
begin = (nl == std::string::npos) ? 0 : nl + 1;
}
return msg + " (" + tb.substr(begin, end + 1 - begin) + ")";
}
} // namespace
wxString PrintHost::format_error(const std::string &body, const std::string &error, unsigned status) const
{
if (status != 0) {
const std::string reason = moonraker_error_reason(body);
auto wxbody = wxString::FromUTF8(reason.empty() ? body : reason);
auto wxbody = wxString::FromUTF8(body.data());
return wxString::Format("HTTP %u: %s", status, wxbody);
} else {
if (error.find("curl:Timeout was reached") != std::string::npos) {
+5 -10
View File
@@ -345,21 +345,16 @@ boost::filesystem::path find_bundled_python_home()
fs::path bundle_python = fs::path(resources_dir()).parent_path() / "MacOS" / "python";
if (valid_python_home(bundle_python))
return bundle_python;
#elif !defined(_WIN32)
#elif defined(_WIN32)
fs::path exe_python = boost::dll::program_location().parent_path() / "python";
if (valid_python_home(exe_python))
return exe_python;
#else
fs::path linux_python = fs::path(resources_dir()).parent_path() / "lib" / "python";
if (valid_python_home(linux_python))
return linux_python;
#endif
// Next to the executable: the Windows install layout, and the runtime copied
// beside every platform's unit-test binary (tests/slic3rutils/CMakeLists.txt).
// The CI test runner only receives the build/tests tree, so the candidates
// below -- all of which point into the deps or install trees -- never resolve
// there.
fs::path exe_python = boost::dll::program_location().parent_path() / "python";
if (valid_python_home(exe_python))
return exe_python;
fs::path configured_python = ORCA_BUNDLED_PYTHON_ROOT;
if (!configured_python.empty() && valid_python_home(configured_python))
return configured_python;
+91
View File
@@ -152,3 +152,94 @@ TEST_CASE("resolve_cli_input_path leaves inputs that must not be completed uncha
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");
}
+4 -24
View File
@@ -18,7 +18,6 @@ add_executable(${_TEST_NAME}_tests
test_plugin_install.cpp
test_plugin_lifecycle.cpp
test_plugin_printer_agent.cpp
test_printhost.cpp
test_slicing_pipeline_bindings.cpp
test_slicing_pipeline_config.cpp
test_plugin_sort.cpp
@@ -49,16 +48,9 @@ if (WIN32)
COMMENT "Copying Python runtime for slic3rutils plugin host API tests"
VERBATIM
)
elseif (NOT FLATPAK)
# The CI unit-test runner only receives the build/tests tree, so both the
# interpreter and the libpython the test binary links have to travel next to
# the executable; find_bundled_python_home() picks the copy up from there.
if (APPLE)
target_link_options(${_TEST_NAME}_tests PRIVATE
"LINKER:-rpath,@executable_path/python/lib")
else ()
set_property(TARGET ${_TEST_NAME}_tests APPEND PROPERTY BUILD_RPATH "$ORIGIN/python/lib")
endif ()
elseif (APPLE)
target_link_options(${_TEST_NAME}_tests PRIVATE
"LINKER:-rpath,@executable_path/python/lib")
add_custom_command(TARGET ${_TEST_NAME}_tests POST_BUILD
COMMAND ${CMAKE_COMMAND} -E rm -rf
@@ -66,7 +58,7 @@ elseif (NOT FLATPAK)
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${CMAKE_PREFIX_PATH}/libpython"
"$<TARGET_FILE_DIR:${_TEST_NAME}_tests>/python"
COMMENT "Copying Python runtime for the plugin host API tests"
COMMENT "Copying Python runtime for macOS plugin host API tests"
VERBATIM
)
elseif (FLATPAK)
@@ -83,16 +75,4 @@ elseif (FLATPAK)
)
endif()
# scripts/run_unit_tests.sh installs the tests' numpy into that runtime with this uv,
# staged where the app build tree keeps it (<exe dir>/tools/uv, see src/CMakeLists.txt).
if (ORCA_BUNDLED_UV_EXECUTABLE)
add_custom_command(TARGET ${_TEST_NAME}_tests POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory "$<TARGET_FILE_DIR:${_TEST_NAME}_tests>/tools/uv"
COMMAND ${CMAKE_COMMAND} -E copy_if_different "${ORCA_BUNDLED_UV_EXECUTABLE}"
"$<TARGET_FILE_DIR:${_TEST_NAME}_tests>/tools/uv/${ORCA_BUNDLED_UV_FILENAME}"
COMMENT "Copying uv for the plugin host API tests"
VERBATIM
)
endif()
orcaslicer_discover_tests(${_TEST_NAME}_tests)
-213
View File
@@ -1,213 +0,0 @@
#include <catch2/catch_all.hpp>
#include <nlohmann/json.hpp>
#include "slic3r/Utils/PrintHost.hpp"
using namespace Slic3r;
namespace {
class TestPrintHost : public PrintHost
{
public:
using PrintHost::format_error;
const char* get_name() const override { return "Test"; }
bool test(wxString&) const override { return true; }
wxString get_test_ok_msg() const override { return {}; }
wxString get_test_failed_msg(wxString&) const override { return {}; }
bool upload(PrintHostUpload, ProgressFn, ErrorFn, InfoFn) const override { return true; }
bool has_auto_discovery() const override { return false; }
bool can_test() const override { return false; }
PrintHostPostUploadActions get_post_upload_actions() const override { return {}; }
std::string get_host() const override { return {}; }
};
std::string format_error(const std::string& body, const std::string& error, unsigned status)
{
return TestPrintHost().format_error(body, error, status).ToStdString();
}
std::string envelope(int code, const std::string& message, const std::string& traceback)
{
return nlohmann::json{{"error", {{"code", code}, {"message", message}, {"traceback", traceback}}}}.dump();
}
std::string moonraker_error(int code, const std::string& message, const std::string& detail = {})
{
std::string line = "tornado.web.HTTPError: HTTP " + std::to_string(code) + ": " + message;
if (!detail.empty())
line += " (" + detail + ")";
return envelope(code, message, "Traceback (most recent call last):\n ...\n" + line + "\n");
}
// A real Moonraker body for uploading a file that is being printed.
constexpr const char* k_busy_file_403 =
R"JSON({"error": {"code": 403, "message": "Forbidden", "traceback": "Traceback (most recent call last):\n\n File \"/home/lava/moonraker/moonraker/components/file_manager/file_manager.py\", line 1017, in _finish_gcode_upload\n can_start = self._handle_operation_check(check_path)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\nmoonraker.utils.exceptions.ServerError: File currently in use\n\nDuring handling of the above exception, another exception occurred:\n\nTraceback (most recent call last):\n\n File \"/home/lava/moonraker/moonraker/components/application.py\", line 1069, in post\n raise tornado.web.HTTPError(\ntornado.web.HTTPError: HTTP 403: Forbidden (File is loaded, upload not permitted)\n"}})JSON";
} // namespace
TEST_CASE("A Klipper upload error shows its reason instead of a Python traceback", "[PrintHost][Regression]")
{
const std::string msg = format_error(k_busy_file_403, "", 403);
INFO("actual: " << msg);
CHECK(msg == "HTTP 403: Forbidden (File is loaded, upload not permitted)");
CHECK_THAT(msg, !Catch::Matchers::ContainsSubstring("Traceback"));
CHECK_THAT(msg, !Catch::Matchers::ContainsSubstring("file_manager.py"));
}
TEST_CASE("The specific cause is recovered from a file endpoint's traceback", "[PrintHost]")
{
SECTION("a plain detail")
{
const std::string body = moonraker_error(403, "Forbidden", "File is loaded, upload not permitted");
CHECK(format_error(body, "", 403) == "HTTP 403: Forbidden (File is loaded, upload not permitted)");
}
SECTION("a detail whose own parentheses nest (a filename)")
{
const std::string detail = "Directory does not exist (/home/pi/gcodes/plate (1).gcode)";
const std::string body = moonraker_error(400, "Bad Request", detail);
CHECK(format_error(body, "", 400) == "HTTP 400: Bad Request (" + detail + ")");
}
SECTION("a detail that contains the reason phrase")
{
const std::string body = moonraker_error(403, "Forbidden", "Forbidden zone: access denied");
CHECK(format_error(body, "", 403) == "HTTP 403: Forbidden (Forbidden zone: access denied)");
}
SECTION("a detail that spans lines")
{
const std::string detail = "Move out of range\nX=250.000 Y=10.000";
const std::string body = moonraker_error(400, "Bad Request", detail);
CHECK(format_error(body, "", 400) == "HTTP 400: Bad Request (" + detail + ")");
}
SECTION("a detail that only repeats the reason phrase is dropped")
{
const std::string body = moonraker_error(401, "Unauthorized", "Unauthorized");
CHECK(format_error(body, "", 401) == "HTTP 401: Unauthorized");
}
}
TEST_CASE("An unhandled exception shows its type and message", "[PrintHost]")
{
const std::string frame = "Traceback (most recent call last):\n"
" File \"/home/pi/moonraker/moonraker/components/file_manager/file_manager.py\", line 1, in write\n"
" self._write(data)\n";
SECTION("a one-line message")
{
const std::string body = envelope(500, "Internal Server Error", frame + "OSError: [Errno 28] No space left on device\n");
CHECK(format_error(body, "", 500) == "HTTP 500: Internal Server Error (OSError: [Errno 28] No space left on device)");
}
SECTION("a message that spans lines")
{
const std::string body = envelope(500, "Internal Server Error", frame + "ServerError: Klippy request failed\n see klippy.log\n");
CHECK(format_error(body, "", 500) == "HTTP 500: Internal Server Error (ServerError: Klippy request failed\n see klippy.log)");
}
SECTION("raised while handling an HTTPError with the same code")
{
const std::string traceback = frame + "tornado.web.HTTPError: HTTP 500: Internal Server Error (Database locked)\n\n"
"During handling of the above exception, another exception occurred:\n\n" +
frame + "OSError: [Errno 5] Input/output error\n";
const std::string body = envelope(500, "Internal Server Error", traceback);
CHECK(format_error(body, "", 500) == "HTTP 500: Internal Server Error (OSError: [Errno 5] Input/output error)");
}
SECTION("a traceback with no header")
{
const std::string body = envelope(500, "Internal Server Error", "OSError: [Errno 5] Input/output error");
CHECK(format_error(body, "", 500) == "HTTP 500: Internal Server Error (OSError: [Errno 5] Input/output error)");
}
}
TEST_CASE("A reason already complete in message is shown unchanged", "[PrintHost]")
{
SECTION("message is the whole reason, no trailing detail")
{
const std::string body = moonraker_error(503, "Klippy is not ready");
CHECK(format_error(body, "", 503) == "HTTP 503: Klippy is not ready");
}
SECTION("a message that itself contains parentheses is not duplicated")
{
const std::string reason = "Requested blocks (0-5) are unavailable";
const std::string body = moonraker_error(400, reason);
CHECK(format_error(body, "", 400) == "HTTP 400: " + reason);
}
}
TEST_CASE("A Moonraker error with no usable detail shows just the reason phrase", "[PrintHost]")
{
struct Case
{
const char* name;
const char* body;
unsigned status;
const char* expected;
};
const auto c = GENERATE(
Case{"an empty traceback", R"JSON({"error": {"code": 500, "message": "Internal Server Error", "traceback": ""}})JSON", 500,
"HTTP 500: Internal Server Error"},
Case{"a traceback of only whitespace", R"JSON({"error": {"code": 500, "message": "Internal Server Error", "traceback": "\n \n"}})JSON",
500, "HTTP 500: Internal Server Error"});
DYNAMIC_SECTION(c.name) { CHECK(format_error(c.body, "", c.status) == c.expected); }
}
TEST_CASE("A percent sign in the reason is not a format specifier", "[PrintHost]")
{
const std::string body = moonraker_error(507, "Insufficient Storage", "disk 100% full");
CHECK(format_error(body, "", 507) == "HTTP 507: Insufficient Storage (disk 100% full)");
}
TEST_CASE("Error bodies that are not a Moonraker envelope are left unchanged", "[PrintHost]")
{
SECTION("OctoPrint's string-valued error member")
{
const std::string body = R"JSON({"error": "File not found"})JSON";
CHECK(format_error(body, "", 404) == "HTTP 404: " + body);
}
SECTION("PrusaLink's top-level message, not under error")
{
const std::string body = R"JSON({"title": "Conflict", "message": "Printer is printing"})JSON";
CHECK(format_error(body, "", 409) == "HTTP 409: " + body);
}
SECTION("a body that is not JSON")
{
const std::string html = "<html><head><title>502 Bad Gateway</title></head></html>";
CHECK(format_error(html, "", 502) == "HTTP 502: " + html);
}
SECTION("an error object with no traceback")
{
const std::string body = R"JSON({"error": {"code": 500, "message": "Internal Server Error"}})JSON";
CHECK(format_error(body, "", 500) == "HTTP 500: " + body);
}
SECTION("an error object whose traceback is null")
{
const std::string body = R"JSON({"error": {"code": 500, "message": "Internal Server Error", "traceback": null}})JSON";
CHECK(format_error(body, "", 500) == "HTTP 500: " + body);
}
SECTION("an envelope whose reason phrase is empty")
{
const std::string body = envelope(403, "", "Traceback (most recent call last):\nOSError: denied\n");
CHECK(format_error(body, "", 403) == "HTTP 403: " + body);
}
SECTION("a transport error with no HTTP status")
{
CHECK(format_error("", "curl:Could not connect", 0) == "curl:Could not connect");
}
}