Compare commits

..
Author SHA1 Message Date
Hanif Koh b5023acce1 Validate Zip Entry Sizes Before Parsing 3MF XML
The 3MF importers read XML entries into a single expat buffer whose size is
an int, while the archive extraction used the entry's 64-bit declared size.
The two could disagree for entries declaring more than INT_MAX bytes.

Reject such entries before allocating, and use one size for the buffer, the
extraction and the parse. This applies to the BBS importer, the PrusaSlicer
importer and the PrusaSlicer fingerprint probe. The load now fails with an
error instead.
2026-09-28 05:58:03 +08:00
10 changed files with 115 additions and 374 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).
+16 -6
View File
@@ -307,14 +307,17 @@ bool PrusaFileParser::check_3mf_from_prusa(const std::string filename)
mz_zip_archive_file_stat stat;
if (!mz_zip_reader_file_stat(&archive, model_file_index, &stat)) goto EXIT;
// expat sizes its buffer with an int, so a larger entry cannot be parsed in one piece.
if (stat.m_uncomp_size > static_cast<mz_uint64>(std::numeric_limits<int>::max())) goto EXIT;
void *parser_buffer = XML_GetBuffer(m_parser, (int) stat.m_uncomp_size);
const int xml_size = static_cast<int>(stat.m_uncomp_size);
void *parser_buffer = XML_GetBuffer(m_parser, xml_size);
if (parser_buffer == nullptr) goto EXIT;
mz_bool res = mz_zip_reader_extract_file_to_mem(&archive, stat.m_filename, parser_buffer, (size_t) stat.m_uncomp_size, 0);
mz_bool res = mz_zip_reader_extract_file_to_mem(&archive, stat.m_filename, parser_buffer, static_cast<size_t>(xml_size), 0);
if (res == 0) goto EXIT;
XML_ParseBuffer(m_parser, (int) stat.m_uncomp_size, 1);
XML_ParseBuffer(m_parser, xml_size, 1);
}
}
@@ -1346,19 +1349,26 @@ ModelVolumeType type_from_string(const std::string &s)
XML_SetUserData(m_xml_parser, (void*)this);
XML_SetElementHandler(m_xml_parser, _3MF_Importer::_handle_start_config_xml_element, _3MF_Importer::_handle_end_config_xml_element);
void* parser_buffer = XML_GetBuffer(m_xml_parser, (int)stat.m_uncomp_size);
// expat sizes its buffer with an int, so a larger entry cannot be parsed in one piece.
if (stat.m_uncomp_size > static_cast<mz_uint64>(std::numeric_limits<int>::max())) {
add_error("Found invalid size");
return false;
}
const int xml_size = static_cast<int>(stat.m_uncomp_size);
void* parser_buffer = XML_GetBuffer(m_xml_parser, xml_size);
if (parser_buffer == nullptr) {
add_error("Unable to create buffer");
return false;
}
mz_bool res = mz_zip_reader_extract_file_to_mem(&archive, stat.m_filename, parser_buffer, (size_t)stat.m_uncomp_size, 0);
mz_bool res = mz_zip_reader_extract_file_to_mem(&archive, stat.m_filename, parser_buffer, static_cast<size_t>(xml_size), 0);
if (res == 0) {
add_error("Error while reading config data to buffer");
return false;
}
if (!XML_ParseBuffer(m_xml_parser, (int)stat.m_uncomp_size, 1)) {
if (!XML_ParseBuffer(m_xml_parser, xml_size, 1)) {
char error_buf[1024];
::sprintf(error_buf, "Error (%s) while parsing xml file at line %d", XML_ErrorString(XML_GetErrorCode(m_xml_parser)), (int)XML_GetCurrentLineNumber(m_xml_parser));
add_error(error_buf);
+10 -3
View File
@@ -2508,19 +2508,26 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
XML_SetEntityDeclHandler(m_xml_parser, nullptr);
XML_SetExternalEntityRefHandler(m_xml_parser, nullptr);
void* parser_buffer = XML_GetBuffer(m_xml_parser, (int)stat.m_uncomp_size);
// expat sizes its buffer with an int, so a larger entry cannot be parsed in one piece.
if (stat.m_uncomp_size > static_cast<mz_uint64>(std::numeric_limits<int>::max())) {
add_error("Found invalid size");
return false;
}
const int xml_size = static_cast<int>(stat.m_uncomp_size);
void* parser_buffer = XML_GetBuffer(m_xml_parser, xml_size);
if (parser_buffer == nullptr) {
add_error("Unable to create buffer");
return false;
}
mz_bool res = mz_zip_reader_extract_file_to_mem(&archive, stat.m_filename, parser_buffer, (size_t)stat.m_uncomp_size, 0);
mz_bool res = mz_zip_reader_extract_file_to_mem(&archive, stat.m_filename, parser_buffer, static_cast<size_t>(xml_size), 0);
if (res == 0) {
add_error("Error while reading config data to buffer");
return false;
}
if (!XML_ParseBuffer(m_xml_parser, (int)stat.m_uncomp_size, 1)) {
if (!XML_ParseBuffer(m_xml_parser, xml_size, 1)) {
char error_buf[1024];
::snprintf(error_buf, 1024, "Error (%s) while parsing xml file at line %d", XML_ErrorString(XML_GetErrorCode(m_xml_parser)), (int)XML_GetCurrentLineNumber(m_xml_parser));
add_error(error_buf);
+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;
+77
View File
@@ -17,6 +17,7 @@
#include <nlohmann/json.hpp>
#include <boost/filesystem/operations.hpp>
#include <boost/nowide/fstream.hpp>
#include <boost/algorithm/string/predicate.hpp>
#include <algorithm>
@@ -1461,3 +1462,79 @@ SCENARIO("bbs_3mf_is_published detects only genuinely published 3MFs", "[3mf]")
}
}
// Writes a single-entry zip whose central directory carries a zip64 record declaring an
// uncompressed size beyond what the 32-bit expat buffer API can take, while the deflated
// payload inflates to only ~64 KiB. Built by hand because miniz never writes a size that
// disagrees with the data.
static void write_zip_with_oversized_entry(const std::string& path, const std::string& entry)
{
const std::string xml = "<?xml version=\"1.0\"?><!--" + std::string(65536, 'A') + "--><a/>";
size_t comp_len = 0;
void* comp = tdefl_compress_mem_to_heap(xml.data(), xml.size(), &comp_len, TDEFL_DEFAULT_MAX_PROBES);
REQUIRE(comp != nullptr);
const std::string deflated(static_cast<const char*>(comp), comp_len);
mz_free(comp);
const uint32_t crc = static_cast<uint32_t>(mz_crc32(MZ_CRC32_INIT, reinterpret_cast<const unsigned char*>(xml.data()), xml.size()));
const uint64_t claimed_size = (uint64_t(1) << 32) + 16;
std::string out;
auto put = [&out](uint64_t v, int bytes) {
for (int i = 0; i < bytes; ++i)
out.push_back(static_cast<char>((v >> (8 * i)) & 0xFF));
};
// local file header, with the true sizes
put(0x04034b50, 4); put(45, 2); put(0, 2); put(8, 2); put(0, 2); put(0, 2);
put(crc, 4); put(deflated.size(), 4); put(xml.size(), 4); put(entry.size(), 2); put(0, 2);
out += entry + deflated;
// central directory header, sizes deferred to the zip64 extra field
const size_t cd_offset = out.size();
put(0x02014b50, 4); put(45, 2); put(45, 2); put(0, 2); put(8, 2); put(0, 2); put(0, 2);
put(crc, 4); put(0xFFFFFFFF, 4); put(0xFFFFFFFF, 4); put(entry.size(), 2); put(20, 2);
put(0, 2); put(0, 2); put(0, 2); put(0, 4); put(0, 4);
out += entry;
put(0x0001, 2); put(16, 2); put(claimed_size, 8); put(deflated.size(), 8);
const size_t cd_size = out.size() - cd_offset;
// end of central directory
put(0x06054b50, 4); put(0, 2); put(0, 2); put(1, 2); put(1, 2);
put(cd_size, 4); put(cd_offset, 4); put(0, 2);
boost::nowide::ofstream f(path, std::ios::binary);
REQUIRE(f.good());
f.write(out.data(), static_cast<std::streamsize>(out.size()));
REQUIRE(f.good());
}
TEST_CASE("3MF XML entries declaring more than an int can hold fail to load", "[3mf]") {
ScopedTemporaryFile temp(".3mf");
const std::string path = temp.string();
SECTION("BBS importer") {
write_zip_with_oversized_entry(path, "_rels/.rels");
Model model;
DynamicPrintConfig config;
ConfigSubstitutionContext ctxt{ForwardCompatibilitySubstitutionRule::Enable};
PlateDataPtrs plates;
std::vector<Preset*> project_presets;
bool is_bbl_3mf = false, is_orca_3mf = false;
Semver file_version;
bool loaded = true;
REQUIRE_NOTHROW(loaded = load_bbs_3mf(path.c_str(), &config, &ctxt, &model, &plates, &project_presets, &is_bbl_3mf,
&is_orca_3mf, &file_version, nullptr, LoadStrategy::LoadModel | LoadStrategy::LoadConfig));
CHECK_FALSE(loaded);
release_PlateData_list(plates);
}
SECTION("PrusaSlicer importer") {
write_zip_with_oversized_entry(path, "Metadata/Slic3r_PE_model.config");
Model model;
DynamicPrintConfig config;
ConfigSubstitutionContext ctxt{ForwardCompatibilitySubstitutionRule::Disable};
bool loaded = true;
REQUIRE_NOTHROW(loaded = load_3mf(path.c_str(), config, ctxt, &model, false));
CHECK_FALSE(loaded);
}
SECTION("PrusaSlicer fingerprint probe") {
write_zip_with_oversized_entry(path, "3D/3dmodel.model");
PrusaFileParser parser;
CHECK_FALSE(parser.check_3mf_from_prusa(path));
}
}
+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");
}
}