Compare commits

..
Author SHA1 Message Date
Hanif Koh 769c885052 Keep UploadFinished Paired with UploadStarted When an Upload Throws
The exception from a throwing upload was caught around perform_job, so
the UploadFinished lifecycle event was skipped and plugins saw an
upload start that never finished.

The catch now sits around the upload call. The error is reported
through the job's error callback and UploadFinished is fired with an
error code, as for any other failed upload. The worker keeps running
for the next job. The started, upload and finished sequence moved to
PrintHostJobQueue::upload_job so it can be tested without the dialog.
2026-09-29 22:37:13 +08:00
Hanif Koh 77248a7e9d Stop Malformed Network Responses from Crashing the App
Duet, MKS and UltiMaker parsed print host replies with boost read_json
inside the HTTP completion callback with no try, so an HTML or truncated
reply threw out of the Physical Printer Test button and terminated the app,
or killed the upload queue thread. The five identical copies of the parser
(ESP3D's and Flashforge's were unused) are replaced by one shared
PrintHost::get_err_code_from_body that reports a non-JSON reply as an error.
The upload queue now catches a failing job per job, so one bad upload no
longer leaves later jobs queued forever.

Flashforge read material station slots with nlohmann value(), which throws
on off-type fields or non-object entries. The parsing moves into
Flashforge::parse_material_slots, which reads fields leniently with the
existing try_parse_json_int and skips bad entries.

UserManager::parse_json parsed the payload before its try block; the parse
now happens inside it.
2026-09-29 22:37:13 +08:00
25 changed files with 328 additions and 456 deletions
+1 -4
View File
@@ -256,13 +256,10 @@ extern bool is_gallery_file(const std::string& path, char const* type);
extern bool is_shapes_dir(const std::string& dir); extern bool is_shapes_dir(const std::string& dir);
//BBS: add json support //BBS: add json support
extern bool is_json_file(const std::string& path); extern bool is_json_file(const std::string& path);
// True if rel_path is relative, has no ".." component or embedded NUL and, joined to root, still resolves inside it. // True if rel_path is relative, has no ".." component and, joined to root, still resolves inside it.
// Both '/' and '\\' are treated as separators on every platform, so an archive rejected on one OS // Both '/' and '\\' are treated as separators on every platform, so an archive rejected on one OS
// is rejected on all of them. // is rejected on all of them.
extern bool is_path_within_root(const std::string &rel_path, const boost::filesystem::path &root); extern bool is_path_within_root(const std::string &rel_path, const boost::filesystem::path &root);
// True if a symlink stored at link_rel_path (relative to root) with this target stays inside root: the target
// must be relative, and joined to the link's directory it must pass is_path_within_root.
extern bool is_symlink_target_within_root(const std::string &link_rel_path, const std::string &target, const boost::filesystem::path &root);
// Orca: custom protocal support utils // Orca: custom protocal support utils
inline bool is_orca_open(const std::string& url) { return boost::starts_with(url, "orcaslicer://open"); } inline bool is_orca_open(const std::string& url) { return boost::starts_with(url, "orcaslicer://open"); }
-63
View File
@@ -4,9 +4,6 @@
#include "miniz_extension.hpp" #include "miniz_extension.hpp"
#include "Utils.hpp" #include "Utils.hpp"
#include <boost/filesystem.hpp>
#include <boost/log/trivial.hpp>
#if defined(_MSC_VER) || defined(__MINGW64__) #if defined(_MSC_VER) || defined(__MINGW64__)
#include "boost/nowide/cstdio.hpp" #include "boost/nowide/cstdio.hpp"
#endif #endif
@@ -118,66 +115,6 @@ std::string decode_archive_entry_path(mz_zip_archive *zip, const mz_zip_archive_
return decode_zip_unicode_path_extra_field(extra.substr(0, extra_size > 0 ? extra_size - 1 : 0), stat.m_filename); return decode_zip_unicode_path_extra_field(extra.substr(0, extra_size > 0 ? extra_size - 1 : 0), stat.m_filename);
} }
bool extract_archive_confined(const std::string &zip_path_utf8, const std::string &dest_dir)
{
mz_zip_archive archive;
mz_zip_zero_struct(&archive);
if (!open_zip_reader(&archive, zip_path_utf8)) {
BOOST_LOG_TRIVIAL(error) << "Unable to open zip reader for " << zip_path_utf8;
return false;
}
const mz_uint num_entries = mz_zip_reader_get_num_files(&archive);
mz_zip_archive_file_stat stat;
// Validate every entry first so an archive with a single escaping entry leaves no partial output behind.
const boost::filesystem::path root(dest_dir);
for (mz_uint i = 0; i < num_entries; ++i) {
if (mz_zip_reader_file_stat(&archive, i, &stat) && !is_path_within_root(stat.m_filename, root)) {
BOOST_LOG_TRIVIAL(error) << "Unzip: rejecting " << zip_path_utf8 << ", entry " << stat.m_filename << " resolves outside " << dest_dir;
close_zip_reader(&archive);
return false;
}
}
for (mz_uint i = 0; i < num_entries; ++i) {
if (!mz_zip_reader_file_stat(&archive, i, &stat)) {
BOOST_LOG_TRIVIAL(warning) << "Unzip: read file stat failed";
continue;
}
const std::string dest_file = dest_dir + "/" + stat.m_filename;
try {
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);
continue;
}
if (stat.m_uncomp_size == 0) {
BOOST_LOG_TRIVIAL(warning) << "Unzip: invalid size for file " << stat.m_filename;
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)) {
BOOST_LOG_TRIVIAL(error) << "Unzip: extract file " << stat.m_filename << " to dest " << dest_file << " failed";
close_zip_reader(&archive);
return false;
}
BOOST_LOG_TRIVIAL(info) << "Unzip: successfully extract file " << stat.m_file_index << " to " << dest_file;
} catch (const std::exception &e) {
close_zip_reader(&archive);
BOOST_LOG_TRIVIAL(error) << "Unzip: archive read exception: " << e.what();
return false;
}
}
close_zip_reader(&archive);
return true;
}
MZ_Archive::MZ_Archive() MZ_Archive::MZ_Archive()
{ {
mz_zip_zero_struct(&arch); mz_zip_zero_struct(&arch);
-2
View File
@@ -11,8 +11,6 @@ bool open_zip_writer(mz_zip_archive *zip, const std::string &fname_utf8);
bool close_zip_reader(mz_zip_archive *zip); bool close_zip_reader(mz_zip_archive *zip);
bool close_zip_writer(mz_zip_archive *zip); 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); 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);
class MZ_Archive { class MZ_Archive {
public: public:
+1 -16
View File
@@ -1093,9 +1093,6 @@ bool is_path_within_root(const std::string &rel_path, const boost::filesystem::p
auto is_separator = [](char c) { return c == '/' || c == '\\'; }; auto is_separator = [](char c) { return c == '/' || c == '\\'; };
if (rel_path.empty() || is_separator(rel_path.front()) || (rel_path.size() > 1 && rel_path[1] == ':')) if (rel_path.empty() || is_separator(rel_path.front()) || (rel_path.size() > 1 && rel_path[1] == ':'))
return false; return false;
// The filesystem calls stop at a NUL, so they would act on a shorter path than the one checked here.
if (rel_path.find('\0') != std::string::npos)
return false;
for (size_t start = 0; start <= rel_path.size();) { for (size_t start = 0; start <= rel_path.size();) {
size_t end = start; size_t end = start;
while (end < rel_path.size() && !is_separator(rel_path[end])) while (end < rel_path.size() && !is_separator(rel_path[end]))
@@ -1106,10 +1103,7 @@ bool is_path_within_root(const std::string &rel_path, const boost::filesystem::p
} }
// Resolve against the canonical root so a symlink inside it cannot lead back out. // Resolve against the canonical root so a symlink inside it cannot lead back out.
try { try {
std::string root_str = boost::filesystem::weakly_canonical(root).string(); const std::string root_str = boost::filesystem::weakly_canonical(root).string();
// A trailing separator on root would otherwise fail the prefix match below for every path.
while (!root_str.empty() && (root_str.back() == '/' || root_str.back() == boost::filesystem::path::preferred_separator))
root_str.pop_back();
const std::string full_str = boost::filesystem::weakly_canonical(root / rel_path).string(); const std::string full_str = boost::filesystem::weakly_canonical(root / rel_path).string();
return full_str.compare(0, root_str.size(), root_str) == 0 && return full_str.compare(0, root_str.size(), root_str) == 0 &&
(full_str.size() == root_str.size() || full_str[root_str.size()] == boost::filesystem::path::preferred_separator); (full_str.size() == root_str.size() || full_str[root_str.size()] == boost::filesystem::path::preferred_separator);
@@ -1118,15 +1112,6 @@ bool is_path_within_root(const std::string &rel_path, const boost::filesystem::p
} }
} }
bool is_symlink_target_within_root(const std::string &link_rel_path, const std::string &target, const boost::filesystem::path &root)
{
if (target.empty() || target.front() == '/' || target.front() == '\\' || (target.size() > 1 && target[1] == ':'))
return false;
// A relative target without ".." only descends from the link's directory, so no chain of such links can leave root.
const size_t sep = link_rel_path.find_last_of("/\\");
return is_path_within_root((sep == std::string::npos ? std::string() : link_rel_path.substr(0, sep + 1)) + target, root);
}
bool is_img_file(const std::string &path) bool is_img_file(const std::string &path)
{ {
return boost::iends_with(path, ".png") || boost::iends_with(path, ".svg"); return boost::iends_with(path, ".png") || boost::iends_with(path, ".svg");
+5 -26
View File
@@ -1512,33 +1512,11 @@ int GUI_App::install_plugin(std::string name, std::string package_name, InstallP
size_t n = mz_zip_reader_get_extra(&archive, stat.m_file_index, extra.data(), extra.size()); size_t n = mz_zip_reader_get_extra(&archive, stat.m_file_index, extra.data(), extra.size());
dest_file = decode(extra.substr(0, n), stat.m_filename); dest_file = decode(extra.substr(0, n), stat.m_filename);
} }
if (!is_path_within_root(dest_file, plugin_folder)) {
BOOST_LOG_TRIVIAL(error) << "[install_plugin] entry " << dest_file << " resolves outside " << plugin_folder.string();
close_zip_reader(&archive);
if (pro_fn) { pro_fn(InstallStatusUnzipFailed, 0, cancel); }
return InstallStatusUnzipFailed;
}
auto dest_path = plugin_folder / dest_file; auto dest_path = plugin_folder / dest_file;
boost::filesystem::create_directories(dest_path.parent_path());
std::string dest_zip_file = encode_path(dest_path.string().c_str()); std::string dest_zip_file = encode_path(dest_path.string().c_str());
#ifndef WIN32
// Validate a symlink's target before anything at the destination is replaced.
const bool is_link = S_ISLNK(stat.m_external_attr >> 16);
std::string link;
if (is_link) {
link.assign(stat.m_uncomp_size, 0);
if (!mz_zip_reader_extract_to_mem(&archive, stat.m_file_index, link.data(), stat.m_uncomp_size, 0) ||
!is_symlink_target_within_root(dest_file, link, plugin_folder)) {
BOOST_LOG_TRIVIAL(error) << "[install_plugin] link " << dest_file << " -> " << link << " is unreadable or resolves outside " << plugin_folder.string();
close_zip_reader(&archive);
if (pro_fn) { pro_fn(InstallStatusUnzipFailed, 0, cancel); }
return InstallStatusUnzipFailed;
}
}
#endif
try { try {
boost::filesystem::create_directories(dest_path.parent_path()); if (fs::exists(dest_path)) {
// symlink_status so that an existing symlink, dangling or not, is replaced rather than written through.
if (fs::exists(fs::symlink_status(dest_path))) {
boost::system::error_code ec; boost::system::error_code ec;
fs::remove(dest_path, ec); fs::remove(dest_path, ec);
if (ec) { if (ec) {
@@ -1566,8 +1544,9 @@ int GUI_App::install_plugin(std::string name, std::string package_name, InstallP
} }
mz_bool res = 0; mz_bool res = 0;
#ifndef WIN32 #ifndef WIN32
if (is_link) { if (S_ISLNK(stat.m_external_attr >> 16)) {
res = 1; std::string link(stat.m_uncomp_size + 1, 0);
res = mz_zip_reader_extract_to_mem(&archive, stat.m_file_index, link.data(), stat.m_uncomp_size, 0);
try { try {
boost::filesystem::create_symlink(link, dest_path); boost::filesystem::create_symlink(link, dest_path);
} catch (const std::exception &e) { } catch (const std::exception &e) {
+5 -4
View File
@@ -31,14 +31,15 @@ int UserManager::parse_json(std::string payload)
{ {
bool restored_json = false; bool restored_json = false;
json j; json j;
json j_pre = json::parse(payload);
if (j_pre.empty()) {
return -1;
}
//bind/unbind //bind/unbind
try { try {
json j_pre = json::parse(payload);
if (j_pre.empty()) {
return -1;
}
if (j_pre.contains("bind")) { if (j_pre.contains("bind")) {
if (j_pre["bind"].contains("command")) { if (j_pre["bind"].contains("command")) {
-9
View File
@@ -274,13 +274,4 @@ bool Duet::start_print(wxString &msg, const std::string &filename, ConnectionTyp
return res; return res;
} }
int Duet::get_err_code_from_body(const std::string &body) const
{
pt::ptree root;
std::istringstream iss (body); // wrap returned json to istringstream
pt::read_json(iss, root);
return root.get<int>("err", 0);
}
} }
-1
View File
@@ -40,7 +40,6 @@ private:
ConnectionType connect(wxString &msg) const; ConnectionType connect(wxString &msg) const;
void disconnect(ConnectionType connectionType) const; void disconnect(ConnectionType connectionType) const;
bool start_print(wxString &msg, const std::string &filename, ConnectionType connectionType, bool simulationMode) const; bool start_print(wxString &msg, const std::string &filename, ConnectionType connectionType, bool simulationMode) const;
int get_err_code_from_body(const std::string &body) const;
}; };
} }
-9
View File
@@ -146,15 +146,6 @@ bool ESP3D::start_print(wxString& msg, const std::string& filename) const
return ret; return ret;
} }
int ESP3D::get_err_code_from_body(const std::string& body) const
{
pt::ptree root;
std::istringstream iss(body); // wrap returned json to istringstream
pt::read_json(iss, root);
return root.get<int>("err", 0);
}
// ESP3D only accepts 8.3 filenames else it crashes marlin and other undefined behaviour // ESP3D only accepts 8.3 filenames else it crashes marlin and other undefined behaviour
std::string ESP3D::get_short_name(const std::string& filename) const std::string ESP3D::get_short_name(const std::string& filename) const
{ {
-1
View File
@@ -33,7 +33,6 @@ private:
std::string m_console_port; std::string m_console_port;
bool start_print(wxString& msg, const std::string& filename) const; bool start_print(wxString& msg, const std::string& filename) const;
int get_err_code_from_body(const std::string& body) const;
std::string get_short_name(const std::string& filename) const; std::string get_short_name(const std::string& filename) const;
std::string format_command(const std::string& path, const std::string& arg, const std::string& val) const; std::string format_command(const std::string& path, const std::string& arg, const std::string& val) const;
}; };
+25 -15
View File
@@ -510,12 +510,22 @@ bool Flashforge::fetch_material_slots(std::vector<FlashforgeMaterialSlot>& slots
if (!request_local_api_json("detail", json{{"serialNumber", m_serial_number}, {"checkCode", m_check_code}}.dump(), body, msg)) if (!request_local_api_json("detail", json{{"serialNumber", m_serial_number}, {"checkCode", m_check_code}}.dump(), body, msg))
return false; return false;
const auto parsed = json::parse(body, nullptr, false, true); if (!parse_material_slots(body, slots, supports_material_station)) {
if (parsed.is_discarded()) {
msg = _(L("Flashforge returned an invalid JSON response.")); msg = _(L("Flashforge returned an invalid JSON response."));
return false; return false;
} }
return true;
}
bool Flashforge::parse_material_slots(const std::string& body, std::vector<FlashforgeMaterialSlot>& slots, bool* supports_material_station)
{
slots.clear();
const auto parsed = json::parse(body, nullptr, false, true);
if (parsed.is_discarded())
return false;
const auto& detail = parsed.contains("detail") ? parsed["detail"] : parsed; const auto& detail = parsed.contains("detail") ? parsed["detail"] : parsed;
const auto& station = detail.contains("matlStationInfo") ? detail["matlStationInfo"] : const auto& station = detail.contains("matlStationInfo") ? detail["matlStationInfo"] :
detail.contains("MatlStationInfo") ? detail["MatlStationInfo"] : json(); detail.contains("MatlStationInfo") ? detail["MatlStationInfo"] : json();
@@ -542,12 +552,21 @@ bool Flashforge::fetch_material_slots(std::vector<FlashforgeMaterialSlot>& slots
if (supports_material_station != nullptr) if (supports_material_station != nullptr)
*supports_material_station = reports_material_station; *supports_material_station = reports_material_station;
// Fields are read leniently: firmware may send numbers as strings or flags as numbers.
for (const auto& slot : slot_infos) { for (const auto& slot : slot_infos) {
if (!slot.is_object())
continue;
FlashforgeMaterialSlot info; FlashforgeMaterialSlot info;
info.slot_id = slot.value("slotId", static_cast<int>(slots.size()) + 1); info.slot_id = static_cast<int>(slots.size()) + 1;
info.has_filament = slot.value("hasFilament", false); if (const auto it = slot.find("slotId"); it != slot.end())
info.material_name = slot.value("materialName", std::string()); try_parse_json_int(*it, info.slot_id);
info.material_color = slot.value("materialColor", std::string()); int has_filament = 0;
if (const auto it = slot.find("hasFilament"); it != slot.end() && try_parse_json_int(*it, has_filament))
info.has_filament = has_filament != 0;
if (const auto it = slot.find("materialName"); it != slot.end() && it->is_string())
info.material_name = it->get<std::string>();
if (const auto it = slot.find("materialColor"); it != slot.end() && it->is_string())
info.material_color = it->get<std::string>();
slots.emplace_back(std::move(info)); slots.emplace_back(std::move(info));
} }
@@ -670,13 +689,4 @@ std::string Flashforge::extract_host_name() const
return out; return out;
} }
int Flashforge::get_err_code_from_body(const std::string& body) const
{
pt::ptree root;
std::istringstream iss(body); // wrap returned json to istringstream
pt::read_json(iss, root);
return root.get<int>("err", 0);
}
} // namespace Slic3r } // namespace Slic3r
+2 -1
View File
@@ -45,6 +45,8 @@ public:
PrintHostPostUploadActions get_post_upload_actions() const override { return PrintHostPostUploadAction::StartPrint; } PrintHostPostUploadActions get_post_upload_actions() const override { return PrintHostPostUploadAction::StartPrint; }
std::string get_host() const override { return m_host; } std::string get_host() const override { return m_host; }
bool fetch_material_slots(std::vector<FlashforgeMaterialSlot>& slots, bool* supports_material_station, wxString& msg) const; bool fetch_material_slots(std::vector<FlashforgeMaterialSlot>& slots, bool* supports_material_station, wxString& msg) const;
// Parses a local API "detail" reply. Returns false when the body is not valid JSON.
static bool parse_material_slots(const std::string& body, std::vector<FlashforgeMaterialSlot>& slots, bool* supports_material_station);
static bool discover_printers(std::vector<FlashforgeDiscoveredPrinter>& printers, wxString& msg, int timeout_ms = 10000, int idle_timeout_ms = 1500, int max_retries = 3); static bool discover_printers(std::vector<FlashforgeDiscoveredPrinter>& printers, wxString& msg, int timeout_ms = 10000, int idle_timeout_ms = 1500, int max_retries = 3);
private: private:
@@ -68,7 +70,6 @@ private:
bool request_local_api_json(const std::string& path, const std::string& body, std::string& response_body, wxString& error_msg) const; bool request_local_api_json(const std::string& path, const std::string& body, std::string& response_body, wxString& error_msg) const;
std::string make_http_url(const std::string& path) const; std::string make_http_url(const std::string& path) const;
std::string extract_host_name() const; std::string extract_host_name() const;
int get_err_code_from_body(const std::string &body) const;
bool connect(wxString& msg) const; bool connect(wxString& msg) const;
bool start_print(wxString& msg, const std::string& filename) const; bool start_print(wxString& msg, const std::string& filename) const;
}; };
-9
View File
@@ -141,13 +141,4 @@ bool MKS::start_print(wxString& msg, const std::string& filename) const
return ret; return ret;
} }
int MKS::get_err_code_from_body(const std::string& body) const
{
pt::ptree root;
std::istringstream iss(body); // wrap returned json to istringstream
pt::read_json(iss, root);
return root.get<int>("err", 0);
}
} // Slic3r } // Slic3r
-1
View File
@@ -34,7 +34,6 @@ private:
std::string get_upload_url(const std::string& filename) const; std::string get_upload_url(const std::string& filename) const;
bool start_print(wxString& msg, const std::string& filename) const; bool start_print(wxString& msg, const std::string& filename) const;
int get_err_code_from_body(const std::string& body) const;
}; };
} }
+56 -2
View File
@@ -339,8 +339,62 @@ bool PresetUpdater::priv::get_file(const std::string &url, const fs::path &targe
//BBS: refine preset update logic //BBS: refine preset update logic
bool PresetUpdater::priv::extract_file(const fs::path &source_path, const fs::path &dest_path) bool PresetUpdater::priv::extract_file(const fs::path &source_path, const fs::path &dest_path)
{ {
const std::string parent_path = (!dest_path.empty() ? dest_path : source_path.parent_path()).string(); bool res = true;
return extract_archive_confined(source_path.string(), parent_path); std::string file_path = source_path.string();
std::string parent_path = (!dest_path.empty() ? dest_path : source_path.parent_path()).string();
mz_zip_archive archive;
mz_zip_zero_struct(&archive);
if (!open_zip_reader(&archive, file_path))
{
BOOST_LOG_TRIVIAL(error) << "Unable to open zip reader for "<<file_path;
return false;
}
mz_uint num_entries = mz_zip_reader_get_num_files(&archive);
mz_zip_archive_file_stat stat;
// we first loop the entries to read from the archive the .amf file only, in order to extract the version from it
for (mz_uint i = 0; i < num_entries; ++i)
{
if (mz_zip_reader_file_stat(&archive, i, &stat))
{
std::string dest_file = parent_path+"/"+stat.m_filename;
if (stat.m_is_directory) {
fs::path dest_path(dest_file);
if (!fs::exists(dest_path))
fs::create_directories(dest_path);
continue;
}
else if (stat.m_uncomp_size == 0) {
BOOST_LOG_TRIVIAL(warning) << "[Orca Updater]Unzip: invalid size for file "<<stat.m_filename;
continue;
}
try
{
res = mz_zip_reader_extract_to_file(&archive, stat.m_file_index, dest_file.c_str(), 0);
if (!res) {
BOOST_LOG_TRIVIAL(error) << "[Orca Updater]extract file "<<stat.m_filename<<" to dest "<<dest_file<<" failed";
close_zip_reader(&archive);
return res;
}
BOOST_LOG_TRIVIAL(info) << "[Orca Updater]successfully extract file " << stat.m_file_index << " to "<<dest_file;
}
catch (const std::exception& e)
{
// ensure the zip archive is closed and rethrow the exception
close_zip_reader(&archive);
BOOST_LOG_TRIVIAL(error) << "[Orca Updater]Archive read exception:"<<e.what();
return false;
}
}
else {
BOOST_LOG_TRIVIAL(warning) << "[Orca Updater]Unzip: read file stat failed";
}
}
close_zip_reader(&archive);
return true;
} }
// Remove a leftover partial archive for the vendor about to be synchronized. // Remove a leftover partial archive for the vendor about to be synchronized.
+42 -9
View File
@@ -3,10 +3,13 @@
#include <vector> #include <vector>
#include <thread> #include <thread>
#include <exception> #include <exception>
#include <sstream>
#include <boost/optional.hpp> #include <boost/optional.hpp>
#include <boost/log/trivial.hpp> #include <boost/log/trivial.hpp>
#include <boost/filesystem.hpp> #include <boost/filesystem.hpp>
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include <boost/property_tree/ptree.hpp>
#include <boost/property_tree/json_parser.hpp>
#include <wx/string.h> #include <wx/string.h>
#include <wx/app.h> #include <wx/app.h>
@@ -172,6 +175,20 @@ std::string moonraker_error_reason(const std::string &body)
} // namespace } // namespace
int PrintHost::get_err_code_from_body(const std::string &body)
{
boost::property_tree::ptree root;
std::istringstream iss(body);
try {
boost::property_tree::read_json(iss, root);
} catch (const std::exception &ex) {
BOOST_LOG_TRIVIAL(error) << "PrintHost: response is not valid JSON: " << ex.what();
return -1;
}
return root.get<int>("err", 0);
}
wxString PrintHost::format_error(const std::string &body, const std::string &error, unsigned status) const wxString PrintHost::format_error(const std::string &body, const std::string &error, unsigned status) const
{ {
if (status != 0) { if (status != 0) {
@@ -415,12 +432,10 @@ void PrintHostJobQueue::priv::remove_source()
source_to_remove.clear(); source_to_remove.clear();
} }
void PrintHostJobQueue::priv::perform_job(PrintHostJob the_job) bool PrintHostJobQueue::upload_job(PrintHostJob &job, PrintHost::ProgressFn progress_fn, PrintHost::ErrorFn error_fn, PrintHost::InfoFn info_fn)
{ {
emit_progress(0); // Indicate the upload is starting
// Captured before upload_data is moved into upload() below. // Captured before upload_data is moved into upload() below.
const std::string upload_filename = the_job.upload_data.source_path.filename().string(); const std::string upload_filename = job.upload_data.source_path.filename().string();
{ {
LifecycleEventContext ctx; LifecycleEventContext ctx;
@@ -429,19 +444,37 @@ void PrintHostJobQueue::priv::perform_job(PrintHostJob the_job)
fire_lifecycle_event(LifecycleEvent::UploadStarted, ctx); fire_lifecycle_event(LifecycleEvent::UploadStarted, ctx);
} }
bool success = the_job.printhost->upload(std::move(the_job.upload_data), bool success = false;
[this](Http::Progress progress, bool &cancel) { this->progress_fn(std::move(progress), cancel); }, std::string error;
[this](wxString error) { this->error_fn(std::move(error)); }, // A throwing upload must not stop the worker, or later jobs would stay queued forever.
[this](wxString tag, wxString host) { this->info_fn(std::move(tag), std::move(host)); } try {
); success = job.printhost->upload(std::move(job.upload_data), std::move(progress_fn), error_fn, std::move(info_fn));
} catch (const std::exception &e) {
error = e.what();
error_fn(error);
}
{ {
LifecycleEventContext ctx; LifecycleEventContext ctx;
ctx.name = upload_filename; ctx.name = upload_filename;
ctx.code = success ? LifecycleEvtCode::Ok : LifecycleEvtCode::Error; ctx.code = success ? LifecycleEvtCode::Ok : LifecycleEvtCode::Error;
ctx.msg = error;
fire_lifecycle_event(LifecycleEvent::UploadFinished, ctx); fire_lifecycle_event(LifecycleEvent::UploadFinished, ctx);
} }
return success;
}
void PrintHostJobQueue::priv::perform_job(PrintHostJob the_job)
{
emit_progress(0); // Indicate the upload is starting
bool success = PrintHostJobQueue::upload_job(the_job,
[this](Http::Progress progress, bool &cancel) { this->progress_fn(std::move(progress), cancel); },
[this](wxString error) { this->error_fn(std::move(error)); },
[this](wxString tag, wxString host) { this->info_fn(std::move(tag), std::move(host)); }
);
if (success) { if (success) {
emit_progress(100); emit_progress(100);
if (the_job.switch_to_device_tab) { if (the_job.switch_to_device_tab) {
+6
View File
@@ -87,6 +87,8 @@ public:
static PrintHost* get_print_host(DynamicPrintConfig *config); static PrintHost* get_print_host(DynamicPrintConfig *config);
static std::string get_print_host_webui(DynamicPrintConfig *config); static std::string get_print_host_webui(DynamicPrintConfig *config);
// Reads the "err" field of a JSON reply, 0 when absent. Returns -1 when the body is not valid JSON.
static int get_err_code_from_body(const std::string &body);
//Support for cloud webui login //Support for cloud webui login
virtual bool is_cloud() const { return false; } virtual bool is_cloud() const { return false; }
@@ -150,6 +152,10 @@ public:
void enqueue(PrintHostJob job); void enqueue(PrintHostJob job);
void cancel(size_t id); void cancel(size_t id);
// Uploads the job, firing UploadStarted and a matching UploadFinished. An exception thrown by
// the upload is reported through error_fn and makes the upload fail.
static bool upload_job(PrintHostJob &job, PrintHost::ProgressFn progress_fn, PrintHost::ErrorFn error_fn, PrintHost::InfoFn info_fn);
private: private:
struct priv; struct priv;
std::shared_ptr<priv> p; std::shared_ptr<priv> p;
-9
View File
@@ -654,13 +654,4 @@ bool UltiMaker::start_print(wxString &msg, const std::string &filename, Connecti
return res; return res;
} }
int UltiMaker::get_err_code_from_body(const std::string &body) const
{
pt::ptree root;
std::istringstream iss (body); // wrap returned json to istringstream
pt::read_json(iss, root);
return root.get<int>("err", 0);
}
} }
-1
View File
@@ -64,7 +64,6 @@ private:
void set_auth(Http& http) const; void set_auth(Http& http) const;
void disconnect(ConnectionType connectionType) const; void disconnect(ConnectionType connectionType) const;
bool start_print(wxString &msg, const std::string &filename, ConnectionType connectionType) const; bool start_print(wxString &msg, const std::string &filename, ConnectionType connectionType) const;
int get_err_code_from_body(const std::string &body) const;
}; };
} }
-1
View File
@@ -44,7 +44,6 @@ add_executable(${_TEST_NAME}_tests
test_lay_on_face.cpp test_lay_on_face.cpp
test_model.cpp test_model.cpp
test_utils.cpp test_utils.cpp
test_miniz_extension.cpp
test_timeutils.cpp test_timeutils.cpp
test_voronoi.cpp test_voronoi.cpp
test_wipe_tower_estimate.cpp test_wipe_tower_estimate.cpp
-194
View File
@@ -1,194 +0,0 @@
#include <catch2/catch_all.hpp>
#include "libslic3r/miniz_extension.hpp"
#include "test_utils.hpp"
#include <boost/filesystem.hpp>
#include <algorithm>
#include <fstream>
#include <iterator>
#include <string>
#include <utility>
#include <vector>
using namespace Slic3r;
namespace fs = boost::filesystem;
namespace {
void write_zip(const fs::path &zip_file, const std::vector<std::pair<std::string, std::string>> &entries)
{
mz_zip_archive zip;
mz_zip_zero_struct(&zip);
REQUIRE(open_zip_writer(&zip, zip_file.string()));
for (const auto &[name, content] : entries)
REQUIRE(mz_zip_writer_add_mem(&zip, name.c_str(), content.data(), content.size(), MZ_DEFAULT_COMPRESSION));
REQUIRE(mz_zip_writer_finalize_archive(&zip));
REQUIRE(close_zip_writer(&zip));
}
// miniz refuses to write a name starting with '/', so write a placeholder of the same length and patch it in place.
void rename_entry(const fs::path &zip_file, const std::string &from, const std::string &to)
{
REQUIRE(from.size() == to.size());
std::string bytes;
{
std::ifstream in(zip_file.string(), std::ios::binary);
bytes.assign(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>());
}
size_t count = 0;
for (size_t pos = bytes.find(from); pos != std::string::npos; pos = bytes.find(from, pos + to.size()), ++count)
bytes.replace(pos, from.size(), to);
// Once in the local header and once in the central directory.
REQUIRE(count == 2);
std::ofstream out(zip_file.string(), std::ios::binary | std::ios::trunc);
out << bytes;
}
std::vector<std::string> list_dir(const fs::path &dir)
{
std::vector<std::string> names;
for (const fs::directory_entry &entry : fs::directory_iterator(dir))
names.push_back(entry.path().filename().string());
std::sort(names.begin(), names.end());
return names;
}
std::string read_file(const fs::path &file)
{
std::ifstream in(file.string(), std::ios::binary);
return std::string(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>());
}
} // namespace
TEST_CASE("Confined extraction writes a well-formed archive under the target directory", "[MinizExtension]")
{
ScopedTemporaryDir tmp;
const fs::path zip_file = tmp.path() / "bundle.zip";
const fs::path target = tmp.path() / "cache";
fs::create_directories(target);
write_zip(zip_file, {{"vendor/", ""}, {"vendor/machine/", ""}, {"vendor.json", "{\"a\":1}"}, {"vendor/machine/printer.json", "{\"b\":2}"}});
REQUIRE(extract_archive_confined(zip_file.string(), target.string()));
CHECK(fs::is_directory(target / "vendor"));
CHECK(read_file(target / "vendor.json") == "{\"a\":1}");
CHECK(read_file(target / "vendor" / "machine" / "printer.json") == "{\"b\":2}");
}
TEST_CASE("Confined extraction rejects an archive with an entry outside the target directory", "[MinizExtension]")
{
ScopedTemporaryDir tmp;
const fs::path zip_file = tmp.path() / "bundle.zip";
const fs::path target = tmp.path() / "cache";
fs::create_directories(target);
const std::string escaping_entry = GENERATE(std::string("../escape.txt"), std::string("..\\escape.txt"),
std::string("sub/../../escape.txt"), std::string("C:/escape.txt"),
std::string("C:escape.txt"), std::string("\\escape.txt"));
// The normal entry comes first so a per-entry check would already have written it.
write_zip(zip_file, {{"normal.json", "{}"}, {escaping_entry, "escaped"}});
CAPTURE(escaping_entry);
CHECK_FALSE(extract_archive_confined(zip_file.string(), target.string()));
CHECK_FALSE(fs::exists(tmp.path() / "escape.txt"));
CHECK(fs::is_empty(target));
}
TEST_CASE("Confined extraction rejects an archive with an absolute entry name", "[MinizExtension]")
{
ScopedTemporaryDir tmp;
const fs::path zip_file = tmp.path() / "bundle.zip";
const fs::path target = tmp.path() / "cache";
fs::create_directories(target);
const std::string absolute = (tmp.path() / "escape.txt").generic_string();
const std::string placeholder = "#" + absolute.substr(1);
write_zip(zip_file, {{"normal.json", "{}"}, {placeholder, "escaped"}});
rename_entry(zip_file, placeholder, absolute);
CHECK_FALSE(extract_archive_confined(zip_file.string(), target.string()));
CHECK_FALSE(fs::exists(tmp.path() / "escape.txt"));
CHECK(fs::is_empty(target));
}
TEST_CASE("Confined extraction rejects a directory entry outside the target directory", "[MinizExtension]")
{
ScopedTemporaryDir tmp;
const fs::path zip_file = tmp.path() / "bundle.zip";
const fs::path target = tmp.path() / "cache";
fs::create_directories(target);
write_zip(zip_file, {{"vendor/", ""}, {"../outside/", ""}});
CHECK_FALSE(extract_archive_confined(zip_file.string(), target.string()));
CHECK_FALSE(fs::exists(tmp.path() / "outside"));
CHECK(fs::is_empty(target));
}
TEST_CASE("Confined extraction validates zero-size entries like any other", "[MinizExtension]")
{
ScopedTemporaryDir tmp;
const fs::path zip_file = tmp.path() / "bundle.zip";
const fs::path target = tmp.path() / "cache";
fs::create_directories(target);
SECTION("an empty file inside the target does not fail the archive") {
write_zip(zip_file, {{"empty.json", ""}, {"vendor.json", "{}"}});
CHECK(extract_archive_confined(zip_file.string(), target.string()));
CHECK(read_file(target / "vendor.json") == "{}");
}
SECTION("an empty file outside the target rejects the archive") {
write_zip(zip_file, {{"vendor.json", "{}"}, {"../escape.txt", ""}});
CHECK_FALSE(extract_archive_confined(zip_file.string(), target.string()));
CHECK_FALSE(fs::exists(tmp.path() / "escape.txt"));
CHECK(fs::is_empty(target));
}
}
TEST_CASE("Confined extraction writes nothing outside the target for Windows-specific name forms", "[MinizExtension]")
{
ScopedTemporaryDir tmp;
const fs::path zip_file = tmp.path() / "bundle.zip";
const fs::path target = tmp.path() / "cache";
fs::create_directories(target);
// Windows strips trailing dots and spaces and maps device names; whether these extract depends on the
// platform, but none of them may land beside the target.
const std::string name = GENERATE(std::string("name."), std::string("name "), std::string("..."), std::string(".. "),
std::string(".. /escape.txt"), std::string(".../escape.txt"), std::string("CON"),
std::string("sub/NUL.txt"), std::string("C:escape.txt"));
write_zip(zip_file, {{name, "payload"}});
CAPTURE(name);
extract_archive_confined(zip_file.string(), target.string());
CHECK(list_dir(tmp.path()) == std::vector<std::string>{"bundle.zip", "cache"});
}
#ifndef _WIN32
TEST_CASE("Confined extraction replaces a symlink at the destination instead of writing through it", "[MinizExtension]")
{
ScopedTemporaryDir tmp;
const fs::path zip_file = tmp.path() / "bundle.zip";
const fs::path target = tmp.path() / "cache";
const fs::path outside = tmp.path() / "outside";
fs::create_directories(target);
fs::create_directories(outside);
write_zip(zip_file, {{"vendor.json", "{\"a\":1}"}});
SECTION("a dangling symlink") {
fs::create_symlink(outside / "vendor.json", target / "vendor.json");
CHECK(extract_archive_confined(zip_file.string(), target.string()));
CHECK_FALSE(fs::exists(outside / "vendor.json"));
CHECK_FALSE(fs::is_symlink(fs::symlink_status(target / "vendor.json")));
CHECK(read_file(target / "vendor.json") == "{\"a\":1}");
}
SECTION("a symlink to an existing file") {
{ std::ofstream((outside / "vendor.json").string()) << "original"; }
fs::create_symlink(outside / "vendor.json", target / "vendor.json");
extract_archive_confined(zip_file.string(), target.string());
CHECK(read_file(outside / "vendor.json") == "original");
}
}
#endif
-79
View File
@@ -243,82 +243,3 @@ TEST_CASE("find_unused_filename gives up after 999 versions", "[Utils]") {
REQUIRE_FALSE(find_unused_filename(dir.path(), "model.3mf", {}, name)); REQUIRE_FALSE(find_unused_filename(dir.path(), "model.3mf", {}, name));
CHECK(name == "model(999).3mf"); CHECK(name == "model(999).3mf");
} }
TEST_CASE("is_path_within_root accepts a root given with a trailing separator", "[utils]") {
ScopedTemporaryDir tmp;
const std::string root = tmp.path().string();
const std::string with_separator = GENERATE_COPY(root + "/", root + std::string(1, static_cast<char>(boost::filesystem::path::preferred_separator)));
CAPTURE(with_separator);
CHECK(is_path_within_root("vendor.json", with_separator));
CHECK(is_path_within_root("vendor/machine/printer.json", with_separator));
CHECK_FALSE(is_path_within_root("../vendor.json", with_separator));
}
TEST_CASE("is_path_within_root treats Windows-specific name forms the same on every platform", "[utils]") {
ScopedTemporaryDir tmp;
SECTION("names ending in dots or spaces stay inside the root") {
const std::string name = GENERATE(std::string("name."), std::string("name "), std::string("dir./file.json"), std::string("dir /file.json"));
CAPTURE(name);
CHECK(is_path_within_root(name, tmp.path()));
}
SECTION("drive-relative names are rejected") {
const std::string name = GENERATE(std::string("C:x"), std::string("c:x/y.json"), std::string("C:"));
CAPTURE(name);
CHECK_FALSE(is_path_within_root(name, tmp.path()));
}
}
TEST_CASE("is_path_within_root rejects a name with an embedded NUL", "[utils]") {
ScopedTemporaryDir tmp;
// The filesystem calls stop at the NUL, so they would act on a different path than the one checked.
const std::string name = GENERATE(std::string("..\0", 3), std::string("..\0x/file.json", 14), std::string("sub/..\0x", 8),
std::string("file.json\0", 10), std::string("\0file.json", 10));
CAPTURE(name.size());
CHECK_FALSE(is_path_within_root(name, tmp.path()));
}
TEST_CASE("is_symlink_target_within_root accepts relative targets that stay inside the root", "[utils]") {
ScopedTemporaryDir tmp;
const auto [link, target] = GENERATE(std::make_pair(std::string("Versions/Current"), std::string("A")),
std::make_pair(std::string("Foo.framework/Foo"), std::string("Versions/Current/Foo")),
std::make_pair(std::string("libfoo.so"), std::string("libfoo.so.1")),
std::make_pair(std::string("a/b/link"), std::string("c/d")));
CAPTURE(link, target);
CHECK(is_symlink_target_within_root(link, target, tmp.path()));
}
TEST_CASE("is_symlink_target_within_root rejects absolute targets and targets that climb out", "[utils]") {
ScopedTemporaryDir tmp;
const std::string outside = (tmp.path().parent_path() / "outside").generic_string();
const auto [link, target] = GENERATE_COPY(std::make_pair(std::string("sub/link"), outside),
std::make_pair(std::string("sub/link"), std::string("/etc/passwd")),
std::make_pair(std::string("sub/link"), std::string("\\outside")),
std::make_pair(std::string("sub/link"), std::string("C:/outside")),
std::make_pair(std::string("sub/link"), std::string("C:outside")),
std::make_pair(std::string("sub/link"), std::string("")),
std::make_pair(std::string("link"), std::string("..")),
std::make_pair(std::string("link"), std::string("../outside")),
std::make_pair(std::string("sub/link"), std::string("../../outside")),
std::make_pair(std::string("sub/link"), std::string("x/../../../outside")),
std::make_pair(std::string("sub/link"), std::string("..\\..\\outside")),
// symlink() stops at the NUL, so this target would be created as "..".
std::make_pair(std::string("link"), std::string("..\0", 3)));
CAPTURE(link, target);
CHECK_FALSE(is_symlink_target_within_root(link, target, tmp.path()));
}
#ifndef _WIN32
TEST_CASE("is_symlink_target_within_root rejects a target that passes through a symlink leading out", "[utils]") {
ScopedTemporaryDir tmp;
const boost::filesystem::path root = tmp.path() / "root";
const boost::filesystem::path outside = tmp.path() / "outside";
boost::filesystem::create_directories(root);
boost::filesystem::create_directories(outside);
boost::filesystem::create_symlink(outside, root / "out");
CHECK_FALSE(is_symlink_target_within_root("link", "out/lib.so", root));
CHECK(is_symlink_target_within_root("link", "in/lib.so", root));
}
#endif
+1
View File
@@ -28,6 +28,7 @@ add_executable(${_TEST_NAME}_tests
test_plugin_audit.cpp test_plugin_audit.cpp
test_shortcuts.cpp test_shortcuts.cpp
test_file_url.cpp test_file_url.cpp
test_user_manager.cpp
../fff_print/test_helpers.cpp ../fff_print/test_helpers.cpp
) )
+161
View File
@@ -1,8 +1,15 @@
#include <catch2/catch_all.hpp> #include <catch2/catch_all.hpp>
#include <memory>
#include <stdexcept>
#include <string>
#include <vector>
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include "libslic3r/LifecycleEvents.hpp"
#include "slic3r/Utils/PrintHost.hpp" #include "slic3r/Utils/PrintHost.hpp"
#include "slic3r/Utils/Flashforge.hpp"
using namespace Slic3r; using namespace Slic3r;
@@ -24,6 +31,41 @@ public:
std::string get_host() const override { return {}; } std::string get_host() const override { return {}; }
}; };
class ThrowingPrintHost : public TestPrintHost
{
public:
bool upload(PrintHostUpload, ProgressFn, ErrorFn, InfoFn) const override { throw std::runtime_error("reply could not be read"); }
};
struct UploadEvents
{
std::vector<LifecycleEvent> events;
std::vector<LifecycleEvtCode> codes;
std::vector<std::string> errors;
bool uploaded{false};
explicit UploadEvents(std::unique_ptr<PrintHost> host)
{
set_lifecycle_hook_fn([this](LifecycleEvent event, const LifecycleEventContext& ctx) {
events.push_back(event);
codes.push_back(ctx.code);
});
PrintHostJob job;
job.printhost = std::move(host);
job.upload_data.source_path = "plate.gcode";
try {
uploaded = PrintHostJobQueue::upload_job(job, [](Http::Progress, bool&) {},
[this](wxString error) { errors.push_back(error.ToStdString()); },
[](wxString, wxString) {});
} catch (...) {
set_lifecycle_hook_fn(nullptr);
throw;
}
set_lifecycle_hook_fn(nullptr);
}
};
std::string format_error(const std::string& body, const std::string& error, unsigned status) std::string format_error(const std::string& body, const std::string& error, unsigned status)
{ {
return TestPrintHost().format_error(body, error, status).ToStdString(); return TestPrintHost().format_error(body, error, status).ToStdString();
@@ -46,6 +88,14 @@ std::string moonraker_error(int code, const std::string& message, const std::str
constexpr const char* k_busy_file_403 = 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"; 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";
// Replies a print host can send instead of JSON: a proxy or login page, nothing, a cut-off body.
const std::vector<std::string> non_json_replies = {
"<html><body>proxy login required</body></html>",
"",
"{\"err\":",
"{\"detail\":{\"matlStationInfo\":{\"slotInfos\":[{\"slotId\":1,",
};
} // namespace } // namespace
TEST_CASE("A Klipper upload error shows its reason instead of a Python traceback", "[PrintHost][Regression]") TEST_CASE("A Klipper upload error shows its reason instead of a Python traceback", "[PrintHost][Regression]")
@@ -211,3 +261,114 @@ TEST_CASE("Error bodies that are not a Moonraker envelope are left unchanged", "
CHECK(format_error("", "curl:Could not connect", 0) == "curl:Could not connect"); CHECK(format_error("", "curl:Could not connect", 0) == "curl:Could not connect");
} }
} }
TEST_CASE("Print host error code is read from a JSON reply", "[PrintHost]")
{
CHECK(PrintHost::get_err_code_from_body(R"({"err":0})") == 0);
CHECK(PrintHost::get_err_code_from_body(R"({"err":2})") == 2);
CHECK(PrintHost::get_err_code_from_body(R"({"status":"ok"})") == 0);
}
TEST_CASE("Print host error code reports a reply that is not JSON as an error", "[PrintHost]")
{
const std::string body = GENERATE(from_range(non_json_replies));
int err = 0;
REQUIRE_NOTHROW(err = PrintHost::get_err_code_from_body(body));
CHECK(err != 0);
}
TEST_CASE("Print host error code tolerates a wrongly typed err field", "[PrintHost]")
{
const std::string body = GENERATE(as<std::string>{}, R"({"err":"busy"})", R"({"err":{"code":1}})", R"([1,2])");
CHECK_NOTHROW(PrintHost::get_err_code_from_body(body));
}
TEST_CASE("Flashforge material slots are read from a well-formed reply", "[PrintHost][Flashforge]")
{
const std::string body = R"({"code":0,"detail":{"hasMatlStation":true,"matlStationInfo":{"slotCnt":2,"slotInfos":[
{"slotId":1,"hasFilament":true,"materialName":"PLA","materialColor":"#FFFFFF"},
{"slotId":2,"hasFilament":false,"materialName":"","materialColor":""}]}}})";
std::vector<FlashforgeMaterialSlot> slots;
bool supports_station = false;
REQUIRE(Flashforge::parse_material_slots(body, slots, &supports_station));
CHECK(supports_station);
REQUIRE(slots.size() == 2);
CHECK(slots[0].slot_id == 1);
CHECK(slots[0].has_filament);
CHECK(slots[0].material_name == "PLA");
CHECK(slots[0].material_color == "#FFFFFF");
CHECK(slots[1].slot_id == 2);
CHECK_FALSE(slots[1].has_filament);
}
TEST_CASE("Flashforge material slots accept numbers as strings and flags as numbers", "[PrintHost][Flashforge]")
{
const std::string body = R"({"detail":{"matlStationInfo":{"slotInfos":[
{"slotId":"3","hasFilament":1,"materialName":null,"materialColor":7}]}}})";
std::vector<FlashforgeMaterialSlot> slots;
REQUIRE_NOTHROW(Flashforge::parse_material_slots(body, slots, nullptr));
REQUIRE(slots.size() == 1);
CHECK(slots[0].slot_id == 3);
CHECK(slots[0].has_filament);
CHECK(slots[0].material_name.empty());
CHECK(slots[0].material_color.empty());
}
TEST_CASE("Flashforge material slots skip entries that are not objects", "[PrintHost][Flashforge]")
{
const std::string body = R"({"detail":{"matlStationInfo":{"slotInfos":[5,"slot",null,[],
{"slotId":4,"hasFilament":true,"materialName":"PETG"}]}}})";
std::vector<FlashforgeMaterialSlot> slots;
REQUIRE_NOTHROW(Flashforge::parse_material_slots(body, slots, nullptr));
REQUIRE(slots.size() == 1);
CHECK(slots[0].slot_id == 4);
CHECK(slots[0].material_name == "PETG");
}
TEST_CASE("Flashforge material slots tolerate slot info that is not a list", "[PrintHost][Flashforge]")
{
const std::string body = GENERATE(as<std::string>{},
R"({"detail":{"matlStationInfo":{"slotInfos":5}}})",
R"({"detail":{"matlStationInfo":{"slotInfos":"none"}}})",
R"({"detail":{"matlStationInfo":7}})",
R"({"detail":"offline"})");
std::vector<FlashforgeMaterialSlot> slots;
bool ok = false;
REQUIRE_NOTHROW(ok = Flashforge::parse_material_slots(body, slots, nullptr));
CHECK(ok);
CHECK(slots.empty());
}
TEST_CASE("Flashforge material slots reject a reply that is not JSON", "[PrintHost][Flashforge]")
{
const std::string body = GENERATE(from_range(non_json_replies));
std::vector<FlashforgeMaterialSlot> slots;
bool ok = true;
REQUIRE_NOTHROW(ok = Flashforge::parse_material_slots(body, slots, nullptr));
CHECK_FALSE(ok);
CHECK(slots.empty());
}
TEST_CASE("An upload that throws still finishes with an error", "[PrintHost][LifecycleEvents]")
{
UploadEvents run(std::make_unique<ThrowingPrintHost>());
CHECK_FALSE(run.uploaded);
CHECK(run.errors == std::vector<std::string>{"reply could not be read"});
CHECK(run.events == std::vector<LifecycleEvent>{LifecycleEvent::UploadStarted, LifecycleEvent::UploadFinished});
CHECK(run.codes == std::vector<LifecycleEvtCode>{LifecycleEvtCode::Ok, LifecycleEvtCode::Error});
}
TEST_CASE("A successful upload finishes without an error", "[PrintHost][LifecycleEvents]")
{
UploadEvents run(std::make_unique<TestPrintHost>());
CHECK(run.uploaded);
CHECK(run.errors.empty());
CHECK(run.events == std::vector<LifecycleEvent>{LifecycleEvent::UploadStarted, LifecycleEvent::UploadFinished});
CHECK(run.codes == std::vector<LifecycleEvtCode>{LifecycleEvtCode::Ok, LifecycleEvtCode::Ok});
}
+23
View File
@@ -0,0 +1,23 @@
#include <catch2/catch_all.hpp>
#include <string>
#include "slic3r/GUI/UserManager.hpp"
using namespace Slic3r;
TEST_CASE("User message that is not JSON is rejected without throwing", "[UserManager]")
{
const std::string payload = GENERATE(as<std::string>{}, "not json", "", "<html></html>", "{\"bind\":");
UserManager manager;
int result = 0;
REQUIRE_NOTHROW(result = manager.parse_json(payload));
CHECK(result == -1);
}
TEST_CASE("User message without a successful bind is ignored", "[UserManager]")
{
const std::string payload = GENERATE(as<std::string>{}, "{}", R"({"bind":{"command":"unbind"}})", R"({"bind":"bind"})", "[1]");
UserManager manager;
CHECK(manager.parse_json(payload) == -1);
}