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
11 changed files with 283 additions and 375 deletions
+2 -2
View File
@@ -1629,7 +1629,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
}
while (it != m_plater_data.end())
{
if (it->first <= 0 || static_cast<size_t>(it->first) > m_plater_data.size())
if (it->first > m_plater_data.size())
{
add_error("invalid plate index");
return false;
@@ -2312,7 +2312,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
}
while (it != m_plater_data.end())
{
if (it->first <= 0 || static_cast<size_t>(it->first) > m_plater_data.size())
if (it->first > m_plater_data.size())
{
add_error("invalid plate index");
return false;
+1 -5
View File
@@ -3702,11 +3702,7 @@ void FacetsAnnotation::set_triangle_from_string(int triangle_id, const std::stri
m_data.bitstream.insert(m_data.bitstream.end(), bool(dec & (1 << i)));
}
if (!m_data.update_used_states(bitstream_start_idx)) {
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": dropping malformed paint data of triangle " << triangle_id;
m_data.bitstream.resize(bitstream_start_idx);
m_data.triangles_to_split.pop_back();
}
m_data.update_used_states(bitstream_start_idx);
}
bool FacetsAnnotation::equals(const FacetsAnnotation &other) const
+43 -68
View File
@@ -1778,13 +1778,6 @@ TriangleSelector::TriangleSplittingData TriangleSelector::serialize() const {
return out.data;
}
// A split code keeps the split side (one split) or the kept side (two splits) in its upper two
// bits, where 3 is not a side. The value is ignored for a three-side split.
static bool split_code_valid(int code)
{
return (code & 0b11) == 3 || (code >> 2) != 3;
}
void TriangleSelector::deserialize(const TriangleSplittingData &data,
bool needs_reset,
EnforcerBlockerType max_ebt,
@@ -1819,12 +1812,11 @@ void TriangleSelector::deserialize(const TriangleSplittingData &data,
for (auto [triangle_id, ibit] : data.triangles_to_split) {
assert(triangle_id < int(m_triangles.size()));
// Set when the bitstream runs out or holds an impossible split before this triangle's tree is complete.
bool corrupt = false;
auto next_nibble = [&data, &ibit = ibit, &corrupt]() {
assert(ibit < int(data.bitstream.size()));
auto next_nibble = [&data, &ibit = ibit]() {
int n = 0;
if (! data.read_nibble(ibit, n))
corrupt = true;
for (int i = 0; i < 4; ++ i)
n |= data.bitstream[ibit ++] << i;
return n;
};
// Decode a leaf state stored behind the "11" prefix: one nibble of (state-3) for states
@@ -1843,10 +1835,6 @@ void TriangleSelector::deserialize(const TriangleSplittingData &data,
bool is_split = num_of_children != 0;
// Only valid if not is_split.
auto state = is_split ? EnforcerBlockerType::NONE : ((code & 0b1100) == 0b1100 ? decode_leaf_state() : EnforcerBlockerType(code >> 2));
if (is_split && ! split_code_valid(code))
corrupt = true;
if (corrupt)
break;
// BBS
if (state == to_delete_filament)
@@ -1861,7 +1849,7 @@ void TriangleSelector::deserialize(const TriangleSplittingData &data,
}
// Only valid if is_split.
int special_side = num_of_split_sides == 3 ? 0 : code >> 2;
int special_side = code >> 2;
// Take care of the first iteration separately, so handling of the others is simpler.
if (parents.empty()) {
@@ -1916,55 +1904,47 @@ void TriangleSelector::deserialize(const TriangleSplittingData &data,
if (parents.empty())
break;
}
if (corrupt) {
// Every split above allocated all of its children, so the partial tree unwinds cleanly.
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": malformed paint data, dropping paint of triangle " << triangle_id;
undivide_triangle(triangle_id);
m_triangles[triangle_id].set_state(EnforcerBlockerType::NONE);
}
}
}
bool TriangleSelector::TriangleSplittingData::update_used_states(const size_t bitstream_start_idx) {
int ibit = static_cast<int>(bitstream_start_idx);
uint64_t states = 0;
do {
// Walk one triangle's tree depth-first, counting the nodes still to be read; a split node adds its children.
for (int pending_nodes = 1; pending_nodes > 0; --pending_nodes) {
int code;
if (!this->read_nibble(ibit, code))
return false;
void TriangleSelector::TriangleSplittingData::update_used_states(const size_t bitstream_start_idx) {
assert(bitstream_start_idx < this->bitstream.size());
assert(!this->bitstream.empty() && this->bitstream.size() != bitstream_start_idx);
assert((this->bitstream.size() - bitstream_start_idx) % 4 == 0);
if (const int num_of_split_sides = code & 0b11; num_of_split_sides != 0) {
if (!split_code_valid(code))
return false;
pending_nodes += num_of_split_sides + 1;
continue;
}
if (this->bitstream.empty() || this->bitstream.size() == bitstream_start_idx)
return;
int facet_state = code >> 2;
if (facet_state == 0b11) {
// Leaf behind the "11" prefix: one nibble of (state-3), or 0b1111 + (state-18).
int nibble;
if (!this->read_nibble(ibit, nibble))
return false;
facet_state = nibble + 3;
if (nibble == 0b1111) {
if (!this->read_nibble(ibit, nibble))
return false;
facet_state = nibble + 18;
}
}
states |= uint64_t(1) << facet_state;
size_t nibble_idx = bitstream_start_idx;
auto read_next_nibble = [&data_bitstream = std::as_const(this->bitstream), &nibble_idx]() -> uint8_t {
assert(nibble_idx + 3 < data_bitstream.size());
uint8_t code = 0;
for (size_t bit_idx = 0; bit_idx < 4; ++bit_idx)
code |= data_bitstream[nibble_idx++] << bit_idx;
return code;
};
while (nibble_idx < this->bitstream.size()) {
const uint8_t code = read_next_nibble();
if (const bool is_split = (code & 0b11) != 0; is_split)
continue;
uint8_t facet_state;
if ((code & 0b1100) == 0b1100) {
// Leaf behind the "11" prefix: one nibble of (state-3), or 0b1111 + (state-18).
const uint8_t nibble = read_next_nibble();
facet_state = nibble == 0b1111 ? uint8_t(read_next_nibble() + 18) : uint8_t(nibble + 3);
} else {
facet_state = code >> 2;
}
} while (static_cast<size_t>(ibit) < this->bitstream.size());
assert(facet_state < this->used_states.size());
if (facet_state >= this->used_states.size())
continue;
// The leaf encoding tops out at state 33, so every state fits the 64-bit mask.
for (size_t state_idx = 0; state_idx < std::min<size_t>(this->used_states.size(), 64); ++state_idx)
if (states & (uint64_t(1) << state_idx))
this->used_states[state_idx] = true;
return true;
this->used_states[facet_state] = true;
}
}
// Lightweight variant of deserialization, which only tests whether a face of test_state exists.
@@ -1976,12 +1956,11 @@ bool TriangleSelector::has_facets(const TriangleSplittingData &data, const Enfor
for (const TriangleBitStreamMapping &triangle_id_and_ibit : data.triangles_to_split) {
int ibit = triangle_id_and_ibit.bitstream_start_idx;
// Stop reading a triangle whose stream is truncated.
bool truncated = false;
auto next_nibble = [&data, &ibit = ibit, &truncated]() {
assert(ibit < int(data.bitstream.size()));
auto next_nibble = [&data, &ibit = ibit]() {
int n = 0;
if (! data.read_nibble(ibit, n))
truncated = true;
for (int i = 0; i < 4; ++ i)
n |= data.bitstream[ibit ++] << i;
return n;
};
// < 0 -> negative of a number of children
@@ -1999,8 +1978,6 @@ bool TriangleSelector::has_facets(const TriangleSplittingData &data, const Enfor
};
int state = num_children_or_state();
if (truncated)
continue;
if (state < 0) {
// Root is split.
parents_children.clear();
@@ -2008,8 +1985,6 @@ bool TriangleSelector::has_facets(const TriangleSplittingData &data, const Enfor
do {
if (-- parents_children.back() >= 0) {
int state = num_children_or_state();
if (truncated)
break;
if (state < 0)
// Child is split.
parents_children.emplace_back(- state);
+2 -14
View File
@@ -297,20 +297,8 @@ public:
std::fill(used_states.begin(), used_states.end(), false);
}
// Update used states from the triangle trees stored between bitstream_start_idx and the end of the bitstream.
// Returns false and leaves used states untouched if a tree is truncated or malformed.
bool update_used_states(size_t bitstream_start_idx);
// Read the 4-bit code at bit index ibit (LSB first) and advance ibit past it.
// Returns false without advancing when fewer than 4 bits remain.
bool read_nibble(int &ibit, int &nibble) const {
if (ibit < 0 || static_cast<size_t>(ibit) + 4 > bitstream.size())
return false;
nibble = 0;
for (int i = 0; i < 4; ++i)
nibble |= static_cast<int>(bitstream[ibit++]) << i;
return true;
}
// Update used states based on the bitstream. It just iterated over the bitstream from the bitstream_start_idx till the end.
void update_used_states(size_t bitstream_start_idx);
private:
friend class cereal::access;
+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
+15 -131
View File
@@ -19,7 +19,6 @@
#include <boost/filesystem/operations.hpp>
#include <boost/algorithm/string/predicate.hpp>
#include <algorithm>
#include <functional>
#include <catch2/catch_tostring.hpp>
#include <Eigen/Core>
@@ -185,13 +184,16 @@ static bool read_cad_recipe_entry(const std::string& path, std::string& out,
return found;
}
// Rewrites the archive at `path`, letting `edit` change the name or data of each entry; returns
// whether `edit` reported a change for any of them. miniz cannot edit in place and
// open_zip_writer truncates, so the entries are held across the switch.
static bool rewrite_3mf_entries(const std::string& path, const std::function<bool(std::string& name, std::string& data)>& edit)
// Rewrites the archive at `path` with the recipe entry back under the name it had before the
// rename, which is what every project saved by an earlier build looks like on disk. Generated
// rather than checked in because a whole project archive is not frozen evidence the way a bare
// recipe blob is -- it has to be whatever today's exporter writes, with only the name aged.
// miniz cannot rename in place and open_zip_writer truncates, so the entries are held across
// the switch.
static void rename_cad_recipe_entry_to_legacy(const std::string& path)
{
std::vector<std::pair<std::string, std::string>> entries;
bool changed = false;
bool renamed = false;
{
mz_zip_archive zip;
mz_zip_zero_struct(&zip);
@@ -206,140 +208,22 @@ static bool rewrite_3mf_entries(const std::string& path, const std::function<boo
std::string data((size_t) st.m_uncomp_size, '\0');
if (st.m_uncomp_size > 0)
REQUIRE(mz_zip_reader_extract_to_mem(&zip, i, data.data(), data.size(), 0));
changed |= edit(name, data);
if (boost::algorithm::iequals(name, CAD_RECIPE_ENTRY)) {
name = LEGACY_CAD_RECIPE_ENTRY;
renamed = true;
}
entries.emplace_back(std::move(name), std::move(data));
}
close_zip_reader(&zip);
}
// Without this the scenario would degrade silently into re-testing the new name if the
// exporter's constant ever moved again: every load below would still pass.
REQUIRE(renamed);
Zipper out(path);
for (const auto& e : entries)
out.add_entry(e.first, e.second.data(), e.second.size());
out.finalize();
return changed;
}
// Rewrites the archive at `path` with the recipe entry back under the name it had before the
// rename, which is what every project saved by an earlier build looks like on disk. Generated
// rather than checked in because a whole project archive is not frozen evidence the way a bare
// recipe blob is -- it has to be whatever today's exporter writes, with only the name aged.
static void rename_cad_recipe_entry_to_legacy(const std::string& path)
{
const bool renamed = rewrite_3mf_entries(path, [](std::string& name, std::string&) {
if (!boost::algorithm::iequals(name, CAD_RECIPE_ENTRY))
return false;
name = LEGACY_CAD_RECIPE_ENTRY;
return true;
});
// Without this the scenario would degrade silently into re-testing the new name if the
// exporter's constant ever moved again: every load below would still pass.
REQUIRE(renamed);
}
// Replaces the first occurrence of `from` in any entry whose name ends with `suffix`.
static bool replace_in_3mf_entry(const std::string& path, const std::string& suffix, const std::string& from, const std::string& to)
{
bool replaced = false;
rewrite_3mf_entries(path, [&](std::string& name, std::string& data) {
if (replaced || !boost::algorithm::ends_with(name, suffix))
return false;
if (const size_t pos = data.find(from); pos != std::string::npos) {
data.replace(pos, from.size(), to);
replaced = true;
}
return replaced;
});
return replaced;
}
// Stores a one-plate project holding a cube whose first two facets are painted Extruder2 and
// Extruder3, which the exporter writes as paint_color="8" and paint_color="0C".
static void store_painted_cube(const std::string& path)
{
Model model;
ModelObject* object = model.add_object();
ModelVolume* volume = object->add_volume(make_cube(10., 10., 10.));
object->add_instance();
{
TriangleSelector selector(volume->mesh());
selector.set_facet(0, EnforcerBlockerType::Extruder2);
selector.set_facet(1, EnforcerBlockerType::Extruder3);
REQUIRE(volume->mmu_segmentation_facets.set(selector));
}
ScopedTemporaryDir backup_dir("orca_paint_src");
model.set_backup_path(backup_dir.string());
DynamicPrintConfig cfg;
PlateData plate;
plate.plate_index = 0;
StoreParams sp;
sp.path = path.c_str();
sp.model = &model;
sp.config = &cfg;
sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
sp.plate_data_list.push_back(&plate);
REQUIRE(store_bbs_3mf(sp));
}
// Loads `path` through the BBS importer into `model`, releasing the plates it returns. The
// importer stages metadata through `backup_dir`, which has to outlive the model.
static bool load_project(const std::string& path, Model& model, const ScopedTemporaryDir& backup_dir)
{
model.set_backup_path(backup_dir.string());
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;
const bool 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);
release_PlateData_list(plates);
return loaded;
}
TEST_CASE("A project with a plate id below 1 fails to load", "[3mf][Regression]")
{
const int plate_id = GENERATE(0, -1);
INFO("plater_id " << plate_id);
ScopedTemporaryFile temp(".3mf");
store_painted_cube(temp.string());
{
ScopedTemporaryDir backup_dir("orca_plate_dst");
Model model;
REQUIRE(load_project(temp.string(), model, backup_dir));
}
REQUIRE(replace_in_3mf_entry(temp.string(), "model_settings.config", "key=\"plater_id\" value=\"1\"",
"key=\"plater_id\" value=\"" + std::to_string(plate_id) + "\""));
ScopedTemporaryDir backup_dir("orca_plate_dst");
Model model;
bool loaded = true;
REQUIRE_NOTHROW(loaded = load_project(temp.string(), model, backup_dir));
REQUIRE_FALSE(loaded);
}
TEST_CASE("A project with malformed paint data loads without the damaged facet", "[3mf][Regression]")
{
ScopedTemporaryFile temp(".3mf");
store_painted_cube(temp.string());
// Split codes with no children behind them: the stream runs out mid-tree.
REQUIRE(replace_in_3mf_entry(temp.string(), ".model", "paint_color=\"8\"", "paint_color=\"FFFFFFFFFFFFFFFF3\""));
ScopedTemporaryDir backup_dir("orca_paint_dst");
Model model;
REQUIRE(load_project(temp.string(), model, backup_dir));
REQUIRE(model.objects.size() == 1);
const ModelVolume& volume = *model.objects.front()->volumes.front();
const auto& data = volume.mmu_segmentation_facets.get_data();
REQUIRE_FALSE(data.used_states[size_t(EnforcerBlockerType::Extruder2)]);
REQUIRE(data.used_states[size_t(EnforcerBlockerType::Extruder3)]);
TriangleSelector selector(volume.mesh());
REQUIRE_NOTHROW(selector.deserialize(data));
REQUIRE(selector.num_facets(EnforcerBlockerType::Extruder2) == 0);
REQUIRE(selector.num_facets(EnforcerBlockerType::Extruder3) == 1);
}
// The recipe lives only in the BBS-native backend, because that is the only one that runs:
@@ -3,8 +3,6 @@
#include "libslic3r/TriangleSelector.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include <algorithm>
using namespace Slic3r;
// A sphere gives well over ExtruderMax original facets, so every extruder state can be assigned
@@ -125,77 +123,3 @@ TEST_CASE("Extruder states match the CONST_FILAMENTS hex encoding", "[TriangleSe
INFO("Hex " << c.hex << " -> extruder " << c.state);
REQUIRE(TriangleSelector::has_facets(data, EnforcerBlockerType(c.state)));
}
// Pack 4-bit codes into a bitstream, least significant bit first, in the order the decoder reads them.
static std::vector<bool> pack_nibbles(const std::vector<int> &nibbles)
{
std::vector<bool> bitstream;
for (const int nibble : nibbles)
for (int bit = 0; bit < 4; ++bit)
bitstream.push_back((nibble >> bit) & 1);
return bitstream;
}
TEST_CASE("A valid paint stream with nested splits round-trips bit for bit", "[TriangleSelector]")
{
const TriangleMesh mesh = test_mesh();
TriangleSelector::TriangleSplittingData data;
data.triangles_to_split.emplace_back(0, 0);
// A three-side split whose children, in stream order, are: a one-side split (side 2) into two
// leaves, a two-side split (side 1) into leaves of states 20, 0 and 8, then two plain leaves.
const std::vector<int> triangle_0 = {0b0011,
0b1001, 0b1000, 0b0100,
0b0110, 0b1100, 0b1111, 20 - 18, 0b0000, 0b1100, 8 - 3,
0b1000,
0b0100};
data.bitstream = pack_nibbles(triangle_0);
data.triangles_to_split.emplace_back(5, int(data.bitstream.size()));
const std::vector<bool> triangle_5 = pack_nibbles({0b1100, 3 - 3});
data.bitstream.insert(data.bitstream.end(), triangle_5.begin(), triangle_5.end());
data.reset_used_states();
REQUIRE(data.update_used_states(0));
TriangleSelector restored(mesh);
restored.deserialize(data);
REQUIRE(restored.num_facets(EnforcerBlockerType::Extruder20) == 1);
REQUIRE(restored.num_facets(EnforcerBlockerType::Extruder3) == 1);
REQUIRE(restored.serialize() == data);
}
TEST_CASE("A truncated or malformed paint stream drops only the damaged triangle", "[TriangleSelector][Regression]")
{
struct Case { const char *name; std::vector<int> nibbles; };
const auto c = GENERATE(values<Case>({
{"three-side split missing two children", {0b0011, 0b1000, 0b1000}},
{"leaf missing its state nibble", {0b1100}},
{"leaf missing its second state nibble", {0b1100, 0b1111}},
{"splits nested past the end", {0b0011, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF,
0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF}},
{"one-side split of the nonexistent side 3", {0b1101, 0b1000, 0b1000}},
}));
INFO(c.name);
const TriangleMesh mesh = test_mesh();
TriangleSelector intact(mesh);
intact.set_facet(0, EnforcerBlockerType::Extruder2);
// Triangle 0 stays intact, triangle 1 carries the damaged stream.
TriangleSelector::TriangleSplittingData data = intact.serialize();
data.triangles_to_split.emplace_back(1, int(data.bitstream.size()));
const std::vector<bool> damaged = pack_nibbles(c.nibbles);
data.bitstream.insert(data.bitstream.end(), damaged.begin(), damaged.end());
TriangleSelector restored(mesh);
REQUIRE_NOTHROW(restored.deserialize(data));
// Triangle 1 unwinds completely, so the selector holds exactly the intact paint.
REQUIRE(restored.serialize() == intact.serialize());
REQUIRE_NOTHROW(TriangleSelector::has_facets(data, EnforcerBlockerType::Extruder3));
TriangleSelector::TriangleSplittingData recomputed = data;
recomputed.reset_used_states();
REQUIRE_FALSE(recomputed.update_used_states(0));
REQUIRE(std::none_of(recomputed.used_states.begin(), recomputed.used_states.end(), [](bool used) { return used; }));
}
+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");
}