mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-24 17:26:47 +00:00
The sweep's prefix form counted any <name>.<digits> file as a leftover of the old config writer, so a user's OrcaSlicer.conf.1 backup went at the next start. Only the <name>.<pid>.<n>.tmp form is a temporary now, and the name is tested before the entry is stat'ed. The read-only check went by mode bits, which misses a deny-write ACL on Windows and refuses a root-owned file root can write; it asks the OS with access() instead. The two-step rename fallback runs only for the errors a refused replace produces, never for an I/O error that would only lose the target, and says so if the second step fails after the old file is gone. The orphaned-.info scan on the sync thread reads each .info under the lock, so a remove_files() in another instance is seen whole or not at all, and queues the cloud delete after the lock is released, keeping the queue mutex outside it. AppConfig::save() assembles its text before it takes the lock, like Preset::save(). The two bundle loops share one metadata loader, and the two-thread write test counts leftover temporaries rather than every directory entry, since a scanner on Windows may hold the old file under another name for a moment.
287 lines
12 KiB
C++
287 lines
12 KiB
C++
#include <catch2/catch_all.hpp>
|
|
|
|
#include "libslic3r/Utils.hpp"
|
|
|
|
#include "test_utils.hpp"
|
|
|
|
#include <boost/filesystem.hpp>
|
|
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <ctime>
|
|
#include <fstream>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <system_error>
|
|
|
|
#ifndef _WIN32
|
|
#include <unistd.h> // getuid
|
|
#endif
|
|
|
|
using namespace Slic3r;
|
|
|
|
TEST_CASE("per_user_temp_dir composes a per-user temp root", "[Utils]") {
|
|
const std::string base = "/tmp";
|
|
|
|
SECTION("an empty id returns base unchanged") {
|
|
REQUIRE(per_user_temp_dir(base, "") == base);
|
|
}
|
|
SECTION("a non-empty id is appended at the top level") {
|
|
REQUIRE(per_user_temp_dir(base, "1000") == base + "/orcaslicer_1000");
|
|
}
|
|
SECTION("distinct ids produce distinct roots") {
|
|
REQUIRE(per_user_temp_dir(base, "1000") != per_user_temp_dir(base, "1001"));
|
|
}
|
|
}
|
|
|
|
TEST_CASE("per_user_temp_id follows the platform contract", "[Utils]") {
|
|
const std::string id = per_user_temp_id();
|
|
|
|
SECTION("stable across calls") {
|
|
REQUIRE(per_user_temp_id() == id);
|
|
}
|
|
#ifdef _WIN32
|
|
SECTION("empty on Windows (its temp dir is already per-user)") {
|
|
REQUIRE(id.empty());
|
|
}
|
|
#else
|
|
SECTION("the current uid on Linux/macOS") {
|
|
REQUIRE_FALSE(id.empty());
|
|
REQUIRE(id == std::to_string(static_cast<unsigned long>(::getuid())));
|
|
}
|
|
#endif
|
|
}
|
|
|
|
// The end-to-end contract callers depend on: the temp root is left alone on
|
|
// Windows and isolated per user on Linux/macOS.
|
|
TEST_CASE("per-user temp root is unchanged on Windows, isolated elsewhere", "[Utils]") {
|
|
const std::string base = "/tmp";
|
|
const std::string root = per_user_temp_dir(base, per_user_temp_id());
|
|
#ifdef _WIN32
|
|
REQUIRE(root == base);
|
|
#else
|
|
REQUIRE(root != base);
|
|
REQUIRE_THAT(root, Catch::Matchers::StartsWith(base + "/orcaslicer_"));
|
|
#endif
|
|
}
|
|
|
|
TEST_CASE("write_file_atomically replaces the target and leaves no temporary file", "[Utils]") {
|
|
ScopedTemporaryDir dir;
|
|
const boost::filesystem::path target = dir.path() / "preset.json";
|
|
|
|
REQUIRE_FALSE(write_file_atomically(target.string(), "first"));
|
|
REQUIRE_FALSE(write_file_atomically(target.string(), "second"));
|
|
|
|
std::string content;
|
|
load_string_file(target, content);
|
|
REQUIRE(content == "second");
|
|
size_t entries = 0;
|
|
for (auto &entry : boost::filesystem::directory_iterator(dir.path())) {
|
|
(void) entry;
|
|
++entries;
|
|
}
|
|
REQUIRE(entries == 1);
|
|
}
|
|
|
|
TEST_CASE("write_file_atomically reports a missing directory and writes nothing", "[Utils]") {
|
|
ScopedTemporaryDir dir;
|
|
const boost::filesystem::path target = dir.path() / "missing" / "preset.json";
|
|
|
|
const std::error_code ec = write_file_atomically(target.string(), "x");
|
|
REQUIRE(ec == std::errc::no_such_file_or_directory);
|
|
REQUIRE_FALSE(boost::filesystem::exists(target));
|
|
}
|
|
|
|
TEST_CASE("write_file_atomically refuses a read-only target and leaves it untouched", "[Utils]") {
|
|
ScopedTemporaryDir dir;
|
|
const boost::filesystem::path target = dir.path() / "pinned.json";
|
|
REQUIRE_FALSE(write_file_atomically(target.string(), "pinned"));
|
|
boost::filesystem::permissions(target, boost::filesystem::owner_read | boost::filesystem::group_read | boost::filesystem::others_read);
|
|
|
|
const std::error_code ec = write_file_atomically(target.string(), "replaced");
|
|
boost::filesystem::permissions(target, boost::filesystem::owner_read | boost::filesystem::owner_write | boost::filesystem::group_read | boost::filesystem::others_read);
|
|
|
|
REQUIRE(ec == std::errc::permission_denied);
|
|
std::string content;
|
|
load_string_file(target, content);
|
|
REQUIRE(content == "pinned");
|
|
}
|
|
|
|
TEST_CASE("write_file_atomically keeps bytes intact in binary mode", "[Utils]") {
|
|
ScopedTemporaryDir dir;
|
|
const boost::filesystem::path target = dir.path() / "blob.bin";
|
|
const std::string bytes("a\r\nb\0c", 6);
|
|
|
|
REQUIRE_FALSE(write_file_atomically(target.string(), bytes, /*binary=*/true));
|
|
REQUIRE(boost::filesystem::file_size(target) == bytes.size());
|
|
}
|
|
|
|
#ifndef _WIN32
|
|
TEST_CASE("write_file_atomically writes through a symlink and keeps the target's permissions", "[Utils]") {
|
|
ScopedTemporaryDir dir;
|
|
const boost::filesystem::path real = dir.path() / "real.json";
|
|
const boost::filesystem::path link = dir.path() / "link.json";
|
|
REQUIRE_FALSE(write_file_atomically(real.string(), "first"));
|
|
boost::filesystem::permissions(real, boost::filesystem::owner_read | boost::filesystem::owner_write);
|
|
boost::filesystem::create_symlink(real, link);
|
|
|
|
REQUIRE_FALSE(write_file_atomically(link.string(), "second"));
|
|
|
|
REQUIRE(boost::filesystem::is_symlink(boost::filesystem::symlink_status(link)));
|
|
std::string content;
|
|
load_string_file(real, content);
|
|
REQUIRE(content == "second");
|
|
|
|
REQUIRE_FALSE(write_file_atomically(real.string(), "third"));
|
|
const auto perms = boost::filesystem::status(real).permissions() & boost::filesystem::all_all;
|
|
REQUIRE(perms == (boost::filesystem::owner_read | boost::filesystem::owner_write));
|
|
}
|
|
#endif
|
|
|
|
TEST_CASE("write_file_atomically survives two threads writing one target", "[Utils]") {
|
|
ScopedTemporaryDir dir;
|
|
const boost::filesystem::path target = dir.path() / "shared.json";
|
|
const std::string a(20000, 'a'), b(20000, 'b');
|
|
|
|
std::thread other([&] {
|
|
for (int i = 0; i < 50; ++i)
|
|
write_file_atomically(target.string(), a);
|
|
});
|
|
for (int i = 0; i < 50; ++i)
|
|
write_file_atomically(target.string(), b);
|
|
other.join();
|
|
|
|
std::string content;
|
|
load_string_file(target, content);
|
|
const bool whole = content == a || content == b;
|
|
REQUIRE(whole);
|
|
// No temporary may be left; a scanner on Windows may briefly hold the old
|
|
// file under another name, so only the temporaries are counted.
|
|
size_t temporaries = 0;
|
|
for (auto &entry : boost::filesystem::directory_iterator(dir.path()))
|
|
if (entry.path().extension() == ".tmp")
|
|
++temporaries;
|
|
REQUIRE(temporaries == 0);
|
|
}
|
|
|
|
TEST_CASE("remove_stale_temp_files removes only old <name>.<pid>.tmp files", "[Utils]") {
|
|
ScopedTemporaryDir dir;
|
|
for (const char *name : { "a.json.123.tmp", "b.info.4.tmp", "c.json", "d.tmp", "e.json.x.tmp", "f.json..tmp", "a.json.99", "a.json.12.3.tmp" }) {
|
|
REQUIRE_FALSE(write_file_atomically((dir.path() / name).string(), "x"));
|
|
// An hour old: long past the age below which a temporary may still be in flight.
|
|
boost::filesystem::last_write_time(dir.path() / name, std::time(nullptr) - 3600);
|
|
}
|
|
// Just written: possibly another instance's in-flight save, so it stays.
|
|
REQUIRE_FALSE(write_file_atomically((dir.path() / "g.json.7.tmp").string(), "x"));
|
|
|
|
SECTION("with a name prefix only matching names go, and a numbered backup is not a temporary") {
|
|
REQUIRE(remove_stale_temp_files(dir.path(), "a.json") == 2);
|
|
REQUIRE_FALSE(boost::filesystem::exists(dir.path() / "a.json.123.tmp"));
|
|
REQUIRE_FALSE(boost::filesystem::exists(dir.path() / "a.json.12.3.tmp"));
|
|
REQUIRE(boost::filesystem::exists(dir.path() / "a.json.99"));
|
|
REQUIRE(boost::filesystem::exists(dir.path() / "b.info.4.tmp"));
|
|
}
|
|
SECTION("without a prefix every stale temporary goes and nothing else") {
|
|
REQUIRE(remove_stale_temp_files(dir.path()) == 3);
|
|
size_t entries = 0;
|
|
for (auto &entry : boost::filesystem::directory_iterator(dir.path())) {
|
|
(void) entry;
|
|
++entries;
|
|
}
|
|
REQUIRE(entries == 6);
|
|
REQUIRE(boost::filesystem::exists(dir.path() / "a.json.99"));
|
|
REQUIRE(boost::filesystem::exists(dir.path() / "g.json.7.tmp"));
|
|
}
|
|
}
|
|
|
|
TEST_CASE("copy_file reports the OS error when the destination cannot be written", "[Utils]") {
|
|
ScopedTemporaryFile source(".txt");
|
|
{
|
|
std::ofstream ofs(source.string(), std::ios::binary);
|
|
ofs << "orca";
|
|
}
|
|
REQUIRE(boost::filesystem::exists(source.path()));
|
|
|
|
// A directory that was never created, so the copy fails on every platform.
|
|
const boost::filesystem::path destination = source.path().parent_path() / "orca-missing-dir" / "copy.txt";
|
|
REQUIRE_FALSE(boost::filesystem::exists(destination.parent_path()));
|
|
|
|
std::string error_message;
|
|
REQUIRE(copy_file(source.string(), destination.string(), error_message) == FAIL_COPY_FILE);
|
|
REQUIRE_FALSE(error_message.empty());
|
|
|
|
#ifdef _WIN32
|
|
// The Windows branch formats GetLastError() itself. Writing that as
|
|
// "Error: " + errCode adds an integer to a string literal, which indexes into the
|
|
// literal instead of appending and runs off its end for any code above 7.
|
|
const std::string prefix = "Error: ";
|
|
REQUIRE(error_message.rfind(prefix, 0) == 0);
|
|
|
|
const std::string code = error_message.substr(prefix.size());
|
|
REQUIRE_FALSE(code.empty());
|
|
REQUIRE(std::all_of(code.begin(), code.end(), [](unsigned char c) { return std::isdigit(c) != 0; }));
|
|
#endif // _WIN32
|
|
}
|
|
|
|
TEST_CASE("A resolved input path still names the same file after the working directory changes", "[Utils]") {
|
|
ScopedTemporaryFile model(".3mf");
|
|
{ std::ofstream out(model.string()); out << "3mf"; }
|
|
const std::string name = model.path().filename().string();
|
|
|
|
// Resolve the bare name from the directory holding the file, then move away from it. The guard
|
|
// restores the directory the test started in, wherever this leaves it.
|
|
ScopedWorkingDirectory cwd(model.path().parent_path());
|
|
const std::string resolved = resolve_cli_input_path(name);
|
|
boost::filesystem::current_path(boost::filesystem::path(TEST_DATA_DIR));
|
|
|
|
REQUIRE(boost::filesystem::exists(resolved));
|
|
REQUIRE(boost::filesystem::equivalent(resolved, model.path()));
|
|
// Control: the bare name finds nothing from here, so resolving it this late would have failed.
|
|
REQUIRE_FALSE(boost::filesystem::exists(name));
|
|
}
|
|
|
|
TEST_CASE("resolve_cli_input_path completes a relative path against the working directory", "[Utils]") {
|
|
ScopedWorkingDirectory cwd(boost::filesystem::temp_directory_path());
|
|
// Read back rather than reusing temp_directory_path(): changing to it resolves any symlink.
|
|
const boost::filesystem::path here = boost::filesystem::current_path();
|
|
|
|
SECTION("a bare name") {
|
|
REQUIRE(resolve_cli_input_path("model.3mf") == (here / "model.3mf").make_preferred().string());
|
|
}
|
|
SECTION("a ./ prefix is dropped") {
|
|
REQUIRE(resolve_cli_input_path("./model.3mf") == (here / "model.3mf").make_preferred().string());
|
|
}
|
|
SECTION("a ../ traversal is collapsed") {
|
|
REQUIRE(resolve_cli_input_path("../model.3mf") == (here.parent_path() / "model.3mf").make_preferred().string());
|
|
}
|
|
}
|
|
|
|
TEST_CASE("resolve_cli_input_path leaves inputs that must not be completed unchanged", "[Utils]") {
|
|
SECTION("an absolute path") {
|
|
const boost::filesystem::path absolute = (boost::filesystem::temp_directory_path() / "model.3mf").make_preferred();
|
|
REQUIRE(resolve_cli_input_path(absolute.string()) == absolute.string());
|
|
}
|
|
#ifdef _WIN32
|
|
// Every absolute form Windows accepts opens today, so each must come back byte for byte:
|
|
// normalizing them would rewrite the forward slashes and rebuild the \\?\ and UNC prefixes.
|
|
SECTION("an absolute Windows path of any form") {
|
|
for (const std::string absolute : {R"(C:\models\model.3mf)",
|
|
R"(C:/models/model.3mf)",
|
|
R"(\\server\share\model.3mf)",
|
|
R"(\\?\C:\models\model.3mf)"})
|
|
REQUIRE(resolve_cli_input_path(absolute) == absolute);
|
|
}
|
|
#endif
|
|
// These are downloaded rather than opened, and completing one would produce a path, not a URL.
|
|
SECTION("a custom open protocol URL") {
|
|
for (const std::string url : {"orcaslicer://open/?file=https://example.com/model.3mf",
|
|
"prusaslicer://open/?file=https://example.com/model.3mf",
|
|
"bambustudio://open/?file=https://example.com/model.3mf",
|
|
"cura://open/?file=https://example.com/model.3mf"})
|
|
REQUIRE(resolve_cli_input_path(url) == url);
|
|
}
|
|
SECTION("an empty argument") {
|
|
REQUIRE(resolve_cli_input_path("").empty());
|
|
}
|
|
}
|