Compare commits

..
Author SHA1 Message Date
Hanif Koh 6dafd6e067 Confirm Before Opening Program Attachments and Load Only HTTPS Images
Opening a project attachment whose type runs as a program or script
(executables, installers, shortcuts, shell and PowerShell scripts, macOS
command files and apps, Linux desktop entries) now asks for confirmation
first. The check lives in libslic3r as is_executable_file_name and ignores
the trailing dots and spaces Windows strips from file names.

Images in project descriptions are kept only when they load over https,
so opening the Project tab no longer issues plain-http requests.
2026-09-28 17:05:29 +08:00
Hanif Koh e39fd0f839 Tighten Project Page Description Rendering and Keep More Formatting
Link and image URLs in descriptions must now start with an http or https
scheme as written and parse as such with the URL parser. Preview images are
built as DOM nodes like the file list, and accessory names show their full
text as a tooltip.

Descriptions keep more plain formatting: del, ins, figure, figcaption, dl,
dt, dd, caption, q, abbr, kbd and wbr, plus alt, title, width and height on
images, colspan and rowspan on table cells and start on ordered lists.
Numeric attributes must be plain integers. Embedded YouTube players become
a link to the video.
2026-09-28 15:23:31 +08:00
Hanif Koh e5af09d900 Escape Project Metadata in the Project Page and Restrict Accessory Opening
The Project page rendered the model and profile name, author, description
and accessory file names from the 3MF as live HTML. Names, authors and file
names are now set as text, and the file list is built from DOM nodes with
bound click handlers instead of concatenated markup. Descriptions can
legitimately carry rich-text HTML, so they are rebuilt from an inert
DOMParser document, keeping only plain formatting tags, http(s) links and
http(s) images, with every other attribute dropped.

Opening an accessory from the page now only launches regular files that
lie inside the project's extracted auxiliary directory. The containment
check is a new libslic3r helper, is_absolute_path_within_root, built on
is_path_within_root so symlinks leading out of the root are rejected too.
2026-09-28 05:56:10 +08:00
11 changed files with 306 additions and 328 deletions
+129 -30
View File
@@ -214,9 +214,9 @@ function ShowModelInfo( pModel )
SendWXDebugInfo("Model Name: "+sModelName);
$('#ModelName').html(sModelName);
$('#ModelName').text(sModelName);
$('#ModelName').attr('title',sModelName);
$('#ModelAuthorName').html(sModelAuthor);
$('#ModelAuthorName').text(sModelAuthor);
switch(UploadType)
{
@@ -268,7 +268,7 @@ function ShowModelInfo( pModel )
break;
}
$('#Model_Desc').html( html_decode(sModelDesc) );
$('#Model_Desc').empty().append( SanitizeDescHtml( html_decode(sModelDesc) ) );
let ModelPreviewList=pModel.preview_img;
let TotalPreview=ModelPreviewList.length;
@@ -281,16 +281,15 @@ function ShowModelInfo( pModel )
if(TotalPreview>0)
{
let htmlPreview='';
$('#ModelPreviewList').empty();
for(let pn=0;pn<TotalPreview;pn++)
{
//let FTmpPath=decodeURIComponent(ModelPreviewList[pn]);
let FTmpPath=ModelPreviewList[pn]['filepath'];
htmlPreview+='<div class="swiper-slide"><img class="Model_PrevImg" src="'+FTmpPath+'" /></div>';
$('#ModelPreviewList').append( $('<div class="swiper-slide"></div>').append( $('<img class="Model_PrevImg" />').attr('src',FTmpPath) ) );
}
$('#ModelPreviewList').html(htmlPreview);
$('#Model_Preview_Image').viewer({
title: false,
fullsreen: false,
@@ -410,7 +409,8 @@ function ConstructFileHtml( ID, pItem )
{
let fTotal=pItem.length;
let strHtml='';
let pBoard=$('#'+ID+' .FileListBoard');
pBoard.empty();
for( let f=0;f<fTotal;f++ )
{
let pOne=pItem[f];
@@ -443,39 +443,139 @@ function ConstructFileHtml( ID, pItem )
ImgPath='img/default.png';
}
//Add html
//Add html. File names come from the 3MF, so build the nodes rather than concatenating markup.
let pIconImg=$('<img />').attr('src',ImgPath);
let pMenu=$('<div class="FileMenu"><img src="img/s.svg" /></div>');
if( strClass!='ImageIcon' )
{
strHtml+='<div class="FileItem">'+
' <div class="'+strClass+'"><img src="'+ImgPath+'" /></div>'+
' <div class="FileText">'+
' <div class="FileName">'+tName+'</div>'+
' </div>'+
' <div class="FileMenu" onClick="OnClickOpenFile(\''+tPath+'\')"><img src="img/s.svg" /></div>'+
'</div>';
pMenu.on('click', function(){ OnClickOpenFile(tPath); });
}
else
{
ImgID++;
let TmpImgID="AF"+ImgID;
strHtml+='<div class="FileItem">'+
' <div class="'+strClass+'"><img id="'+TmpImgID+'" src="'+ImgPath+'" /></div>'+
' <div class="FileText">'+
' <div class="FileName">'+tName+'</div>'+
' </div>'+
' <div class="FileMenu" onClick="OnClickOpenImage(\''+TmpImgID+'\')"><img src="img/s.svg" /></div>'+
'</div>';
pIconImg.attr('id',TmpImgID);
pMenu.on('click', function(){ OnClickOpenImage(TmpImgID); });
}
let pFileItem=$('<div class="FileItem"></div>');
pFileItem.append( $('<div></div>').addClass(strClass).append(pIconImg) );
pFileItem.append( $('<div class="FileText"></div>').append( $('<div class="FileName"></div>').text(tName).attr('title',tName) ) );
pFileItem.append( pMenu );
pBoard.append( pFileItem );
}
$('#'+ID+' .FileListBoard').html(strHtml);
if( fTotal>0 )
$('#'+ID).show();
}
// Descriptions are untrusted 3MF metadata that may carry rich-text HTML (e.g. from MakerWorld).
// Rebuild them from an inert parse, keeping only plain formatting tags and http(s) links and images.
var DescAllowedTags=['P','BR','B','STRONG','I','EM','U','S','STRIKE','DEL','INS','SUB','SUP','SMALL','MARK',
'Q','ABBR','KBD','WBR','H1','H2','H3','H4','H5','H6','UL','OL','LI','DL','DT','DD','BLOCKQUOTE','PRE','CODE',
'HR','SPAN','DIV','FIGURE','FIGCAPTION','TABLE','CAPTION','THEAD','TBODY','TFOOT','TR','TH','TD','A','IMG'];
// Plain attributes kept per tag; the numeric ones must be plain non-negative integers.
var DescAllowedAttrs={'IMG':['alt','title','width','height'],'TD':['colspan','rowspan'],'TH':['colspan','rowspan'],'OL':['start']};
var DescNumericAttrs=['width','height','colspan','rowspan','start'];
// Dropped together with their content; any other unknown tag is unwrapped to its children.
var DescDroppedTags=['SCRIPT','STYLE','TEMPLATE','NOSCRIPT','TEXTAREA','TITLE','IFRAME','FRAME','OBJECT','EMBED','SVG','MATH'];
function IsHttpUrl( strUrl )
{
// The scheme must be written as is, so nothing the URL parser would strip can precede or split it.
if( typeof strUrl!='string' || !/^https?:/i.test(strUrl) )
return false;
try
{
let sProtocol=new URL(strUrl).protocol;
return sProtocol=='http:' || sProtocol=='https:';
}
catch(e)
{
return false;
}
}
// Images load as soon as the page opens, so only https sources are kept: no plain-http requests to the local network.
function IsHttpsUrl( strUrl )
{
return IsHttpUrl(strUrl) && new URL(strUrl).protocol=='https:';
}
// Embedded YouTube players become a plain link to the video.
function GetYouTubeEmbedUrl( pNode )
{
let sSrc=pNode.getAttribute('src');
if( !IsHttpUrl(sSrc) )
return null;
let pUrl=new URL(sSrc);
return ( pUrl.origin=='https://www.youtube.com' && pUrl.pathname.indexOf('/embed/')==0 ) ? pUrl.href : null;
}
function CopyDescNodes( pSrc, pDst )
{
for( let pNode=pSrc.firstChild;pNode!=null;pNode=pNode.nextSibling )
{
if( pNode.nodeType==Node.TEXT_NODE )
{
pDst.appendChild( document.createTextNode(pNode.nodeValue) );
continue;
}
if( pNode.nodeType!=Node.ELEMENT_NODE )
continue;
let sTag=pNode.nodeName.toUpperCase();
if( sTag=='IFRAME' )
{
let sVideoUrl=GetYouTubeEmbedUrl(pNode);
if( sVideoUrl!=null )
{
let pLink=document.createElement('A');
pLink.setAttribute('href',sVideoUrl);
pLink.textContent=sVideoUrl;
pDst.appendChild(pLink);
}
continue;
}
if( $.inArray(sTag,DescDroppedTags)>=0 )
continue;
if( $.inArray(sTag,DescAllowedTags)<0 )
{
CopyDescNodes(pNode,pDst);
continue;
}
let pElem=document.createElement(sTag);
if( sTag=='A' && IsHttpUrl(pNode.getAttribute('href')) )
pElem.setAttribute('href',pNode.getAttribute('href'));
else if( sTag=='IMG' )
{
if( !IsHttpsUrl(pNode.getAttribute('src')) )
continue;
pElem.setAttribute('src',pNode.getAttribute('src'));
}
$.each( DescAllowedAttrs[sTag]||[], function(i,sAttr){
let sValue=pNode.getAttribute(sAttr);
if( sValue!=null && ( $.inArray(sAttr,DescNumericAttrs)<0 || /^\d+$/.test(sValue) ) )
pElem.setAttribute(sAttr,sValue);
});
CopyDescNodes(pNode,pElem);
pDst.appendChild(pElem);
}
}
function SanitizeDescHtml( strHtml )
{
let pFragment=document.createDocumentFragment();
// A DOMParser document is inert: it runs no scripts and loads no resources.
let pDoc=new DOMParser().parseFromString(strHtml,'text/html');
if( pDoc && pDoc.body )
CopyDescNodes(pDoc.body,pFragment);
return pFragment;
}
function ShowProfilelInfo( pProfile )
{
//==========Profile Info==========
@@ -483,10 +583,10 @@ function ShowProfilelInfo( pProfile )
let sProfileAuthor=decodeURIComponent(pProfile.author);
let sProfileDesc=decodeURIComponent(pProfile.description);
$('#ProfileName').html(sProfileName);
$('#ProfileAuthor').html(sProfileAuthor);
$('#ProfileName').text(sProfileName);
$('#ProfileAuthor').text(sProfileAuthor);
$('#Profile_Desc').html( html_decode(sProfileDesc) );
$('#Profile_Desc').empty().append( SanitizeDescHtml( html_decode(sProfileDesc) ) );
let ProfilePreviewList=pProfile.preview_img;
let TotalPreview=ProfilePreviewList.length;
@@ -499,15 +599,14 @@ function ShowProfilelInfo( pProfile )
if(TotalPreview>0)
{
let htmlPreview='';
$('#ProfilePreviewList').empty();
for(let pn=0;pn<TotalPreview;pn++)
{
let FTmpPath=ProfilePreviewList[pn]['filepath'];
htmlPreview+='<div class="swiper-slide"><img class="Model_PrevImg" src="'+FTmpPath+'" /></div>';
$('#ProfilePreviewList').append( $('<div class="swiper-slide"></div>').append( $('<img class="Model_PrevImg" />').attr('src',FTmpPath) ) );
}
$('#ProfilePreviewList').html(htmlPreview);
$('#Profile_Preview_Image').viewer({
title: false,
fullsreen: false,
+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;
+5
View File
@@ -260,6 +260,11 @@ extern bool is_json_file(const std::string& path);
// Both '/' and '\\' are treated as separators on every platform, so an archive rejected on one OS
// is rejected on all of them.
extern bool is_path_within_root(const std::string &rel_path, const boost::filesystem::path &root);
// True if path names an entry strictly inside root: it must be spelled with root as its prefix,
// and must still resolve inside root once symlinks are followed.
extern bool is_absolute_path_within_root(const boost::filesystem::path &path, const boost::filesystem::path &root);
// True if opening a file with this name through the desktop would run it as a program or script.
extern bool is_executable_file_name(const std::string &file_name);
// Orca: custom protocal support utils
inline bool is_orca_open(const std::string& url) { return boost::starts_with(url, "orcaslicer://open"); }
+29
View File
@@ -70,6 +70,7 @@
#include <boost/shared_ptr.hpp>
#include <boost/algorithm/string/predicate.hpp>
#include <boost/algorithm/string/case_conv.hpp>
#include <boost/filesystem.hpp>
#include <boost/filesystem/path.hpp>
#include <boost/nowide/fstream.hpp>
@@ -1112,6 +1113,34 @@ bool is_path_within_root(const std::string &rel_path, const boost::filesystem::p
}
}
bool is_absolute_path_within_root(const boost::filesystem::path &path, const boost::filesystem::path &root)
{
const boost::filesystem::path rel = path.lexically_relative(root);
return !rel.empty() && rel != "." && is_path_within_root(rel.string(), root);
}
bool is_executable_file_name(const std::string &file_name)
{
static const std::vector<std::string> executable_extensions = {
// Windows
"exe", "com", "bat", "cmd", "scr", "pif", "msi", "msp", "msc", "cpl", "lnk", "url", "hta", "js", "jse", "vbs",
"vbe", "wsf", "wsh", "ps1", "psm1", "reg", "jar", "appref-ms", "scf", "inf", "py", "pyw",
// macOS
"app", "command", "pkg", "terminal", "workflow",
// Linux
"sh", "bash", "run", "desktop", "appimage"};
// Windows ignores trailing dots and spaces, so "setup.exe." still runs as an .exe.
const size_t end = file_name.find_last_not_of(". ");
if (end == std::string::npos)
return false;
const std::string name = file_name.substr(0, end + 1);
const size_t dot = name.find_last_of('.');
if (dot == std::string::npos || name.find_first_of("/\\", dot) != std::string::npos)
return false;
const std::string extension = boost::algorithm::to_lower_copy(name.substr(dot + 1));
return std::find(executable_extensions.begin(), executable_extensions.end(), extension) != executable_extensions.end();
}
bool is_img_file(const std::string &path)
{
return boost::iends_with(path, ".png") || boost::iends_with(path, ".svg");
+18 -2
View File
@@ -27,6 +27,7 @@
#include "GUI_App.hpp"
#include "GUI_ObjectList.hpp"
#include "MainFrame.hpp"
#include "MsgDialog.hpp"
#include <slic3r/GUI/Widgets/WebView.hpp>
namespace Slic3r { namespace GUI {
@@ -293,9 +294,24 @@ void ProjectPanel::OnScriptMessage(wxWebViewEvent& evt)
if (!accessory_path.empty()) {
std::string decode_path = wxGetApp().url_decode(accessory_path.ToStdString());
fs::path path(decode_path);
// Only open the project's own extracted auxiliary files, with the root built as in Reload().
fs::path aux_root(encode_path(wxGetApp().plater()->model().get_auxiliary_file_temp_path().c_str()));
if (fs::exists(path)) {
wxLaunchDefaultApplication(path.wstring(), 0);
if (is_absolute_path_within_root(path, aux_root) && fs::is_regular_file(path)) {
// Attachments come with the project, so ask before running one that is a program or script.
bool open = true;
if (is_executable_file_name(path.filename().string())) {
MessageDialog dlg(this,
wxString::Format(_L("\"%s\" is a program or script. Opening it will run it on this computer.\n\n"
"Only open attachments from projects you trust. Open it anyway?"),
from_path(path.filename())),
_L("Open attachment"), wxICON_WARNING | wxYES_NO);
open = dlg.ShowModal() == wxID_YES;
}
if (open)
wxLaunchDefaultApplication(path.wstring(), 0);
} else {
BOOST_LOG_TRIVIAL(warning) << "open_3mf_accessory: ignoring path outside the project auxiliary directory: " << decode_path;
}
}
}
+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; }));
}
+62
View File
@@ -152,3 +152,65 @@ TEST_CASE("resolve_cli_input_path leaves inputs that must not be completed uncha
REQUIRE(resolve_cli_input_path("").empty());
}
}
TEST_CASE("is_absolute_path_within_root accepts only entries inside the root", "[utils]") {
namespace fs = boost::filesystem;
ScopedTemporaryDir outer;
const fs::path root = outer.path() / "Auxiliaries";
fs::create_directories(root / "Others");
const fs::path inside = root / "Others" / "note.txt";
const fs::path outside = outer.path() / "secret.txt";
std::ofstream(inside.string()) << "inside";
std::ofstream(outside.string()) << "outside";
SECTION("a file inside the root") {
REQUIRE(is_absolute_path_within_root(inside, root));
}
SECTION("a path inside the root whose file does not exist yet") {
REQUIRE(is_absolute_path_within_root(root / "Others" / "missing.txt", root));
}
SECTION("the root itself") {
REQUIRE_FALSE(is_absolute_path_within_root(root, root));
}
SECTION("a parent-directory escape spelled under the root") {
REQUIRE_FALSE(is_absolute_path_within_root(root / "Others" / ".." / ".." / "secret.txt", root));
}
SECTION("an absolute path elsewhere") {
REQUIRE_FALSE(is_absolute_path_within_root(outside, root));
}
SECTION("a sibling directory sharing the root's name as a prefix") {
const fs::path sibling = outer.path() / "Auxiliaries2" / "note.txt";
REQUIRE_FALSE(is_absolute_path_within_root(sibling, root));
}
SECTION("a relative path") {
REQUIRE_FALSE(is_absolute_path_within_root(fs::path("Others") / "note.txt", root));
}
SECTION("an empty path") {
REQUIRE_FALSE(is_absolute_path_within_root(fs::path(), root));
}
#ifndef _WIN32
// Creating symlinks on Windows needs elevated rights or developer mode.
SECTION("a symlink inside the root that points outside") {
const fs::path link = root / "Others" / "link.txt";
fs::create_symlink(outside, link);
REQUIRE_FALSE(is_absolute_path_within_root(link, root));
}
#endif
}
TEST_CASE("is_executable_file_name flags attachments that would run as programs", "[utils]") {
const std::string executable = GENERATE(as<std::string>{},
"setup.exe", "SETUP.EXE", "Manual.pdf.exe", "run.bat", "start.cmd", "shortcut.lnk", "site.url", "script.ps1",
"macro.vbs", "tool.jar", "script.py", "Install.command", "Tool.app", "installer.pkg", "install.sh",
"launcher.desktop", "Printer.AppImage", "setup.exe.", "setup.exe ", "setup.exe. .", ".exe");
INFO(executable);
CHECK(is_executable_file_name(executable));
}
TEST_CASE("is_executable_file_name leaves documents and models alone", "[utils]") {
const std::string document = GENERATE(as<std::string>{},
"Manual.pdf", "BOM.xlsx", "notes.txt", "photo.JPG", "assembly.step", "part.stl", "project.3mf", "readme",
"exe", "setup.exe.pdf", "", "...", "dir.exe/readme");
INFO(document);
CHECK_FALSE(is_executable_file_name(document));
}