Files
Sunshine/tests/unit/test_process.cpp

715 lines
22 KiB
C++

/**
* @file tests/unit/test_process.cpp
* @brief Test src/process.* functions.
*/
// test includes
#include "../tests_common.h"
// standard includes
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <optional>
#include <thread>
// lib imports
#include <boost/process/v1.hpp>
// local includes
#include <src/process.h>
#include <src/utility.h>
namespace fs = std::filesystem;
namespace proc {
/**
* @brief Resolve a command's working directory, returning an empty path for URLs.
*
* @param cmd Command line to inspect.
* @param env Environment used for executable lookup.
* @return Working directory, or an empty path when the command is a URL.
*/
boost::filesystem::path find_working_directory(const std::string &cmd, boost::process::v1::environment &env);
/**
* @brief Expand environment references while preserving Windows variable-name casing.
*
* @param env Environment whose variables are expanded.
* @param val_raw Text containing variable references.
* @return Text with environment references expanded.
*/
std::string parse_env_val(boost::process::v1::native_environment &env, const std::string_view &val_raw);
} // namespace proc
#ifdef _WIN32
TEST(ProcessTest, ExpandsEnvironmentNamesWithoutChangingTheirCase) {
auto env = boost::this_process::environment();
const std::string key = "SUNSHINE_RANGE_SEARCH_REGRESSION";
const auto original = env.find(key);
const std::optional<std::string> saved = original == env.end() ? std::nullopt : std::optional {original->to_string()};
const auto restore = util::fail_guard([&env, &key, &saved]() {
if (saved.has_value()) {
env[key] = *saved;
} else {
env[key].clear();
}
});
env[key] = "case-preserved";
EXPECT_EQ(proc::parse_env_val(env, "before-$(sunshine_range_search_regression)-after"), "before-case-preserved-after");
EXPECT_EQ(proc::parse_env_val(env, "$(SUNSHINE_RANGE_SEARCH_REGRESSION)"), "case-preserved");
}
#endif
TEST(ProcessTest, UrlCommandsDoNotResolveAnExecutableWorkingDirectory) {
boost::process::v1::environment env;
EXPECT_TRUE(proc::find_working_directory("https://example.com", env).empty());
EXPECT_TRUE(proc::find_working_directory("steam://run/123", env).empty());
EXPECT_TRUE(proc::find_working_directory("custom+scheme://host/path", env).empty());
}
#ifndef _WIN32
TEST(ProcessTest, UnavailableLocaleDoesNotAbortCommandLookup) {
const char *original = std::getenv("LC_ALL");
const std::optional<std::string> saved = original ? std::optional<std::string> {original} : std::nullopt;
const auto restore = util::fail_guard([&saved]() {
if (saved.has_value()) {
setenv("LC_ALL", saved->c_str(), 1);
} else {
unsetenv("LC_ALL");
}
});
setenv("LC_ALL", "xx_INVALID.UTF-8", 1);
boost::process::v1::environment env;
env["PATH"] = "/usr/bin:/bin";
boost::filesystem::path working_dir;
EXPECT_NO_THROW(working_dir = proc::find_working_directory("sh -c true", env));
EXPECT_FALSE(working_dir.empty());
}
TEST(ProcessTest, ResolvesRelativeCommandsFromTheChildPath) {
const auto bin_dir = fs::temp_directory_path() / "sunshine_find_working_directory_test"; // NOSONAR(cpp:S5443): safe for tests
fs::create_directories(bin_dir);
const auto cleanup = util::fail_guard([&bin_dir]() {
std::error_code ec;
fs::remove_all(bin_dir, ec);
});
const auto executable = bin_dir / "sunshine-path-probe";
std::ofstream(executable) << "#!/bin/sh\n";
fs::permissions(executable, fs::perms::owner_all);
boost::process::v1::environment env;
env["PATH"] = bin_dir.string();
EXPECT_EQ(proc::find_working_directory("sunshine-path-probe --flag", env), boost::filesystem::path(bin_dir.string()));
EXPECT_TRUE(proc::find_working_directory("sunshine-missing-probe", env).empty());
}
#endif
TEST(ProcessTest, PrepareCommand) {
#ifdef SUNSHINE_BUILD_FLATPAK
EXPECT_EQ(proc::prepare_command("steam"), "flatpak-spawn --host steam");
EXPECT_EQ(proc::prepare_command("flatpak-spawn --host steam"), "flatpak-spawn --host steam");
EXPECT_EQ(proc::prepare_command(" flatpak-spawn --host steam "), "flatpak-spawn --host steam");
EXPECT_EQ(proc::prepare_command(" steam "), "flatpak-spawn --host steam");
EXPECT_EQ(proc::prepare_command(""), "");
EXPECT_EQ(proc::prepare_command(" \t"), "");
#else
EXPECT_EQ(proc::prepare_command("steam"), "steam");
EXPECT_EQ(proc::prepare_command(" steam "), " steam ");
EXPECT_EQ(proc::prepare_command("flatpak-spawn --host steam"), "flatpak-spawn --host steam");
EXPECT_EQ(proc::prepare_command(""), "");
#endif
}
class ProcessPNGTest: public BaseTest { // NOSONAR(cpp:S3656): protected members are intentional for test fixture subclassing
protected:
void SetUp() override {
BaseTest::SetUp();
// Create test directory
test_dir = fs::temp_directory_path() / "sunshine_process_png_test"; // NOSONAR(cpp:S5443): safe for tests
fs::create_directories(test_dir);
}
void TearDown() override {
// Clean up test directory
if (fs::exists(test_dir)) {
fs::remove_all(test_dir);
}
BaseTest::TearDown();
}
// Helper function to create a file with specific content
void createTestFile(const fs::path &path, const std::vector<unsigned char> &content) const {
std::ofstream file(path, std::ios::binary);
file.write(reinterpret_cast<const char *>(content.data()), content.size());
file.close();
}
fs::path test_dir;
};
// Tests for check_valid_png function
TEST_F(ProcessPNGTest, CheckValidPNG_ValidSignature) {
// Valid PNG signature
const std::vector<unsigned char> valid_png_data = {
0x89,
0x50,
0x4E,
0x47,
0x0D,
0x0A,
0x1A,
0x0A, // PNG signature
// Add some dummy data to make it more realistic
0x00,
0x00,
0x00,
0x0D,
0x49,
0x48,
0x44,
0x52
};
const fs::path test_file = test_dir / "valid.png";
createTestFile(test_file, valid_png_data);
EXPECT_TRUE(proc::check_valid_png(test_file));
}
TEST_F(ProcessPNGTest, CheckValidPNG_WrongSignature) {
// Invalid PNG signature (wrong magic bytes)
const std::vector<unsigned char> invalid_png_data = {
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00
};
const fs::path test_file = test_dir / "invalid.png";
createTestFile(test_file, invalid_png_data);
EXPECT_FALSE(proc::check_valid_png(test_file));
}
TEST_F(ProcessPNGTest, CheckValidPNG_TooShort) {
// File too short (less than 8 bytes)
const std::vector<unsigned char> short_data = {
0x89,
0x50,
0x4E,
0x47
};
const fs::path test_file = test_dir / "short.png";
createTestFile(test_file, short_data);
EXPECT_FALSE(proc::check_valid_png(test_file));
}
TEST_F(ProcessPNGTest, CheckValidPNG_EmptyFile) {
// Empty file
const std::vector<unsigned char> empty_data = {};
const fs::path test_file = test_dir / "empty.png";
createTestFile(test_file, empty_data);
EXPECT_FALSE(proc::check_valid_png(test_file));
}
TEST_F(ProcessPNGTest, CheckValidPNG_NonExistentFile) {
// File doesn't exist
const fs::path test_file = test_dir / "nonexistent.png";
EXPECT_FALSE(proc::check_valid_png(test_file));
}
TEST_F(ProcessPNGTest, CheckValidPNG_RealFile) {
// Test with the actual sunshine.png from the project root
// Only run this test if the file exists
if (const fs::path sunshine_png = fs::path(SUNSHINE_SOURCE_DIR) / "sunshine.png"; fs::exists(sunshine_png)) {
EXPECT_TRUE(proc::check_valid_png(sunshine_png));
} else {
GTEST_SKIP() << "sunshine.png not found in project root";
}
}
TEST_F(ProcessPNGTest, CheckValidPNG_JPEGFile) {
// JPEG signature (not PNG)
const std::vector<unsigned char> jpeg_data = {
0xFF,
0xD8,
0xFF,
0xE0,
0x00,
0x10,
0x4A,
0x46
};
const fs::path test_file = test_dir / "fake.png";
createTestFile(test_file, jpeg_data);
EXPECT_FALSE(proc::check_valid_png(test_file));
}
TEST_F(ProcessPNGTest, CheckValidPNG_PartialSignature) {
// Partial PNG signature (first 4 bytes correct, rest wrong)
const std::vector<unsigned char> partial_png_data = {
0x89,
0x50,
0x4E,
0x47,
0x00,
0x00,
0x00,
0x00
};
const fs::path test_file = test_dir / "partial.png";
createTestFile(test_file, partial_png_data);
EXPECT_FALSE(proc::check_valid_png(test_file));
}
// Tests for validate_app_image_path function
TEST_F(ProcessPNGTest, ValidateAppImagePath_EmptyPath) {
// Empty path should return default
const std::string result = proc::validate_app_image_path("");
EXPECT_EQ(result, DEFAULT_APP_IMAGE_PATH);
}
TEST_F(ProcessPNGTest, ValidateAppImagePath_NonPNGExtension) {
// Non-PNG extension should return default
const std::string result = proc::validate_app_image_path("image.jpg");
EXPECT_EQ(result, DEFAULT_APP_IMAGE_PATH);
}
TEST_F(ProcessPNGTest, ValidateAppImagePath_CaseInsensitiveExtension) {
// Test that .PNG (uppercase) is recognized
// Create a valid PNG file
const std::vector<unsigned char> valid_png_data = {
0x89,
0x50,
0x4E,
0x47,
0x0D,
0x0A,
0x1A,
0x0A,
0x00,
0x00,
0x00,
0x0D,
0x49,
0x48,
0x44,
0x52
};
const fs::path test_file = test_dir / "test.PNG";
createTestFile(test_file, valid_png_data);
const std::string result = proc::validate_app_image_path(test_file.string());
// Should accept uppercase .PNG extension
EXPECT_NE(result, DEFAULT_APP_IMAGE_PATH);
}
TEST_F(ProcessPNGTest, ValidateAppImagePath_NonExistentFile) {
// Non-existent PNG file should return default
const std::string result = proc::validate_app_image_path("/nonexistent/path/image.png");
EXPECT_EQ(result, DEFAULT_APP_IMAGE_PATH);
}
TEST_F(ProcessPNGTest, ValidateAppImagePath_InvalidPNGSignature) {
// File with .png extension but invalid signature should return default
const std::vector<unsigned char> invalid_data = {
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00
};
const fs::path test_file = test_dir / "invalid.png";
createTestFile(test_file, invalid_data);
const std::string result = proc::validate_app_image_path(test_file.string());
EXPECT_EQ(result, DEFAULT_APP_IMAGE_PATH);
}
TEST_F(ProcessPNGTest, ValidateAppImagePath_ValidPNG) {
// Valid PNG file should return the path
const std::vector<unsigned char> valid_png_data = {
0x89,
0x50,
0x4E,
0x47,
0x0D,
0x0A,
0x1A,
0x0A,
0x00,
0x00,
0x00,
0x0D,
0x49,
0x48,
0x44,
0x52
};
const fs::path test_file = test_dir / "valid.png";
createTestFile(test_file, valid_png_data);
const std::string result = proc::validate_app_image_path(test_file.string());
EXPECT_EQ(result, test_file.string());
}
TEST_F(ProcessPNGTest, ValidateAppImagePath_OldSteamDefault) {
// Test the special case for old steam image path
const std::string result = proc::validate_app_image_path("./assets/steam.png");
EXPECT_EQ(result, SUNSHINE_ASSETS_DIR "/steam.png");
}
/**
* @brief Test fixture for proc_t::update_apps_and_env and proc::refresh.
*/
class ProcessRefreshTest: public BaseTest { // NOSONAR(cpp:S3656): protected members are intentional for test fixture subclassing
protected:
void SetUp() override {
BaseTest::SetUp();
test_dir = fs::temp_directory_path() / "sunshine_process_refresh_test"; // NOSONAR(cpp:S5443): safe for tests
fs::create_directories(test_dir);
}
void TearDown() override {
if (fs::exists(test_dir)) {
fs::remove_all(test_dir);
}
BaseTest::TearDown();
}
/**
* @brief Write a minimal valid apps.json file.
*
* @param path Target path for the JSON file.
* @param app_names List of application names to include.
*/
void writeAppsJson(const fs::path &path, const std::vector<std::string> &app_names) const {
std::ofstream file(path);
file << "{\n \"env\": {},\n \"apps\": [\n";
for (size_t i = 0; i < app_names.size(); ++i) {
file << R"( { "name": ")" << app_names[i] << R"(" })";
if (i + 1 < app_names.size()) {
file << ",";
}
file << "\n";
}
file << " ]\n}\n";
file.close();
// Artificially advance the modification time to guarantee strict monotonicity
// across tests. This prevents the static last_apps_file_update in refresh()
// from skipping parses when tests execute rapidly in the same clock tick.
static int test_time_offset = 1;
auto new_time = fs::file_time_type::clock::now() + std::chrono::seconds(test_time_offset++);
fs::last_write_time(path, new_time);
}
fs::path test_dir;
};
/**
* @brief Test fixture for testing updates to process apps and environment.
*/
class ProcessUpdateTest: public ProcessRefreshTest { // NOSONAR(cpp:S3656): protected members are intentional for test fixture subclassing
protected:
proc::proc_t saved_global_proc;
void SetUp() override {
ProcessRefreshTest::SetUp();
saved_global_proc = std::move(proc::proc);
boost::process::v1::environment env = boost::this_process::environment();
std::vector<proc::ctx_t> apps_initial;
proc::ctx_t ctx;
ctx.name = "Desktop";
ctx.id = "42";
apps_initial.push_back(std::move(ctx));
proc::proc_t target(std::move(env), std::move(apps_initial));
proc::proc = std::move(target);
auto launch_session = std::make_shared<rtsp_stream::launch_session_t>();
launch_session->width = 1920;
launch_session->height = 1080;
launch_session->fps = 60;
launch_session->gcmap = 0;
launch_session->enable_hdr = false;
launch_session->host_audio = false;
launch_session->enable_sops = false;
launch_session->surround_info = 2;
int rc = proc::proc.execute(42, launch_session);
ASSERT_EQ(rc, 0);
}
void TearDown() override {
proc::proc.terminate();
proc::proc = std::move(saved_global_proc);
ProcessRefreshTest::TearDown();
}
/**
* @brief Build a simulated source environment and app list for the update.
*
* @return A constructed proc_t containing the new applications and environment.
*/
proc::proc_t buildNewSource() const {
boost::process::v1::environment env2 = boost::this_process::environment();
std::vector<proc::ctx_t> apps_new;
proc::ctx_t ctx_new;
ctx_new.name = "NewApp";
ctx_new.id = "99";
apps_new.push_back(std::move(ctx_new));
return proc::proc_t(std::move(env2), std::move(apps_new));
}
};
TEST_F(ProcessRefreshTest, UpdateAppsAndEnv_UpdatesAppsList) {
// Build an initial proc_t with one app
boost::process::v1::environment env = boost::this_process::environment();
std::vector<proc::ctx_t> apps_initial;
proc::ctx_t ctx_a;
ctx_a.name = "AppA";
ctx_a.id = "100";
apps_initial.push_back(std::move(ctx_a));
proc::proc_t target(std::move(env), std::move(apps_initial));
ASSERT_EQ(target.get_apps().size(), 1u);
EXPECT_EQ(target.get_apps()[0].name, "AppA");
// Build a replacement proc_t with two apps
boost::process::v1::environment env2 = boost::this_process::environment();
std::vector<proc::ctx_t> apps_new;
proc::ctx_t ctx_b;
ctx_b.name = "AppB";
ctx_b.id = "200";
apps_new.push_back(std::move(ctx_b));
proc::ctx_t ctx_c;
ctx_c.name = "AppC";
ctx_c.id = "300";
apps_new.push_back(std::move(ctx_c));
proc::proc_t source(std::move(env2), std::move(apps_new));
// Act
target.update_apps_and_env(std::move(source));
// Assert — apps list must now contain the new apps
ASSERT_EQ(target.get_apps().size(), 2u);
EXPECT_EQ(target.get_apps()[0].name, "AppB");
EXPECT_EQ(target.get_apps()[1].name, "AppC");
}
TEST_F(ProcessUpdateTest, UpdateAppsAndEnv_PreservesRunningState) {
proc::proc.update_apps_and_env(buildNewSource());
ASSERT_EQ(proc::proc.get_apps().size(), 1u);
EXPECT_EQ(proc::proc.get_apps()[0].name, "NewApp");
EXPECT_EQ(proc::proc.running(), 42) << "update_apps_and_env must not reset _app_id or placebo";
}
TEST_F(ProcessUpdateTest, UpdateAppsAndEnv_PreservesSessionEnvironment) {
proc::proc.update_apps_and_env(buildNewSource());
const auto &active_env = proc::proc.get_env();
EXPECT_TRUE(active_env.count("SUNSHINE_APP_NAME") > 0)
<< "SUNSHINE_APP_NAME was wiped from the environment during update_apps_and_env";
if (active_env.count("SUNSHINE_APP_NAME") > 0) {
EXPECT_EQ(active_env.at("SUNSHINE_APP_NAME").to_string(), "Desktop");
}
}
TEST_F(ProcessRefreshTest, Refresh_ParsesFileOnFirstCall) {
// The first call to refresh() must always parse the file, regardless
// of what file_time_type{} compares to relative to the file timestamp.
//
// NOTE: Because refresh() uses static local variables (has_parsed, last_apps_file_update),
// this test exercises the very first call in this test process. Subsequent
// tests that call refresh() share the same static state.
const fs::path apps_file = test_dir / "apps_initial.json";
writeAppsJson(apps_file, {"TestApp1", "TestApp2"});
// Save and clear global proc
auto saved = std::move(proc::proc);
proc::proc = proc::proc_t {};
ASSERT_TRUE(proc::proc.get_apps().empty());
// Act
proc::refresh(apps_file.string());
// Assert — must have been parsed
ASSERT_EQ(proc::proc.get_apps().size(), 2u);
EXPECT_EQ(proc::proc.get_apps()[0].name, "TestApp1");
EXPECT_EQ(proc::proc.get_apps()[1].name, "TestApp2");
// Cleanup
proc::proc = std::move(saved);
}
TEST_F(ProcessRefreshTest, Refresh_SkipsUnchangedFile) {
const fs::path apps_file = test_dir / "apps_skip.json";
writeAppsJson(apps_file, {"OriginalApp"});
auto saved = std::move(proc::proc);
proc::proc = proc::proc_t {};
// First refresh — should parse
proc::refresh(apps_file.string());
ASSERT_EQ(proc::proc.get_apps().size(), 1u);
// Mutate apps in-memory to detect whether the second refresh re-parses
proc::proc.get_apps()[0].name = "Mutated";
// Second refresh with unchanged file — should be skipped
proc::refresh(apps_file.string());
EXPECT_EQ(proc::proc.get_apps()[0].name, "Mutated")
<< "refresh() should skip re-parse when the file timestamp has not changed";
// Cleanup
proc::proc = std::move(saved);
}
TEST_F(ProcessRefreshTest, Refresh_ReparseAfterFileModified) {
const fs::path apps_file = test_dir / "apps_modified.json";
writeAppsJson(apps_file, {"BeforeEdit"});
auto saved = std::move(proc::proc);
proc::proc = proc::proc_t {};
proc::refresh(apps_file.string());
ASSERT_EQ(proc::proc.get_apps().size(), 1u);
EXPECT_EQ(proc::proc.get_apps()[0].name, "BeforeEdit");
// Rewrite to ensure a new timestamp
writeAppsJson(apps_file, {"AfterEdit"});
proc::refresh(apps_file.string());
ASSERT_EQ(proc::proc.get_apps().size(), 1u);
EXPECT_EQ(proc::proc.get_apps()[0].name, "AfterEdit");
// Cleanup
proc::proc = std::move(saved);
}
TEST_F(ProcessRefreshTest, Refresh_ReparseAfterFileMtimeDecreased) {
const fs::path apps_file = test_dir / "apps_reverted.json";
writeAppsJson(apps_file, {"Initial"});
auto saved = std::move(proc::proc);
proc::proc = proc::proc_t {};
proc::refresh(apps_file.string());
ASSERT_EQ(proc::proc.get_apps().size(), 1u);
EXPECT_EQ(proc::proc.get_apps()[0].name, "Initial");
// Write a new file but artificially set its timestamp to the PAST
// simulating a backup restoration or git checkout
writeAppsJson(apps_file, {"OlderBackup"});
auto past_time = fs::file_time_type::clock::now() - std::chrono::hours(1);
fs::last_write_time(apps_file, past_time);
proc::refresh(apps_file.string());
ASSERT_EQ(proc::proc.get_apps().size(), 1u);
EXPECT_EQ(proc::proc.get_apps()[0].name, "OlderBackup")
<< "refresh() must re-parse if the modification time differs, even if it is strictly earlier";
proc::proc = std::move(saved);
}
TEST_F(ProcessRefreshTest, Refresh_PreservesRunningAppDuringReparse) {
// This is the critical regression test: refreshing the app list while an
// app is running must not lose the active process state.
const fs::path apps_file = test_dir / "apps_running.json";
writeAppsJson(apps_file, {"Desktop"});
auto saved = std::move(proc::proc);
proc::proc = proc::proc_t {};
// Initial parse
proc::refresh(apps_file.string());
ASSERT_FALSE(proc::proc.get_apps().empty());
// Find the ID of "Desktop" as assigned by parse
auto desktop_id = std::stoi(proc::proc.get_apps()[0].id);
// Execute it in placebo mode (empty cmd)
auto launch_session = std::make_shared<rtsp_stream::launch_session_t>();
launch_session->width = 1920;
launch_session->height = 1080;
launch_session->fps = 60;
launch_session->gcmap = 0;
launch_session->enable_hdr = false;
launch_session->host_audio = false;
launch_session->enable_sops = false;
launch_session->surround_info = 2;
int rc = proc::proc.execute(desktop_id, launch_session);
ASSERT_EQ(rc, 0);
ASSERT_EQ(proc::proc.running(), desktop_id);
// Simulate an external edit to the apps file
writeAppsJson(apps_file, {"Desktop", "NewApp"});
// Act — refresh while the app is "running"
proc::refresh(apps_file.string());
// Assert — app list is updated but the running state is preserved
ASSERT_EQ(proc::proc.get_apps().size(), 2u);
EXPECT_EQ(proc::proc.running(), desktop_id)
<< "refresh() during a running session must preserve active process state";
// Cleanup — terminate before restoring
proc::proc.terminate();
proc::proc = std::move(saved);
}
TEST_F(ProcessRefreshTest, FileTimeType_DefaultValueComparison) {
// This test documents the behavior that triggered the timestamp guard
// regression. On some toolchains (MSYS2 UCRT64 GCC 16.2), file timestamps
// and clock::now() can compare less-or-equal to file_time_type{}.
//
// The fix uses std::optional<file_time_type> instead of relying on
// file_time_type{} as a "never parsed" indicator.
const auto default_time = std::filesystem::file_time_type {};
const auto now_time = std::filesystem::file_time_type::clock::now();
// We cannot assert the comparison direction since it is platform-dependent.
// Instead, just log and document both cases.
if (now_time <= default_time) {
BOOST_LOG(info) << "file_time_type::clock::now() <= file_time_type{} is TRUE on this platform "
<< "(this is the UCRT64 case that requires the optional sentinel)";
} else {
BOOST_LOG(info) << "file_time_type::clock::now() > file_time_type{} on this platform "
<< "(default-init guard would have worked, but optional is still safer)";
}
// The actual correctness is verified by Refresh_ParsesFileOnFirstCall above —
// if that test passes, the optional fix works regardless of platform behavior.
SUCCEED();
}