diff --git a/Modules/app-esp32-module/source/app_esp32_loader_service.cpp b/Modules/app-esp32-module/source/app_esp32_loader_service.cpp index 4f96855fd..2c32a9af3 100644 --- a/Modules/app-esp32-module/source/app_esp32_loader_service.cpp +++ b/Modules/app-esp32-module/source/app_esp32_loader_service.cpp @@ -3,6 +3,8 @@ #include #endif +#include +#include #include #include @@ -72,6 +74,28 @@ std::string resolve_elf_path(const std::string& path) { return path + "/elf/" + CONFIG_IDF_TARGET + ".elf"; } +constexpr ElfRequirements EXECUTABLE_REQUIREMENTS = { + .elf_class = ELF_CLASS_32, + .data = ELF_DATA_2LSB, + .type = ELF_TYPE_DYN, +#if defined(__XTENSA__) + .machine = ELF_MACHINE_XTENSA, +#elif defined(__riscv) + .machine = ELF_MACHINE_RISCV, +#else +#error "Unsupported ESP32 architecture for ELF machine check" +#endif +}; + +// Validates an already-resolved binary path (see resolve_elf_path()) before it's handed to +// esp_elf_relocate(), which performs no header validation of its own: the extension and +// directory checks are cheap string comparisons, so the file is only opened as a last resort. +bool is_executable_file(const std::string& resolved_path) { + return resolved_path.ends_with(".elf") + && app_exec_path_allowed(resolved_path.c_str()) + && elf_check_file(resolved_path.c_str(), &EXECUTABLE_REQUIREMENTS); +} + error_t api_load(AppLocation location, AppRuntime* out_runtime) { if (location.type != APP_LOCATION_PATH) { LOG_E(TAG, "Out of memory"); @@ -88,6 +112,12 @@ error_t api_load(AppLocation location, AppRuntime* out_runtime) { auto elf_path = resolve_elf_path(static_cast(location.location)); + if (!is_executable_file(elf_path)) { + LOG_E(TAG, "Not executable: %s", elf_path.c_str()); + delete runtime; + return ERROR_NOT_ALLOWED; + } + size_t size = 0; error_t read_result = read_file(elf_path.c_str(), &runtime->file_data, &size); if (read_result != ERROR_NONE) { @@ -127,10 +157,20 @@ void api_unload(AppRuntime runtime_ptr) { delete runtime; } +bool api_is_executable(AppLocation location) { + if (location.type != APP_LOCATION_PATH) { + return false; + } + + auto elf_path = resolve_elf_path(static_cast(location.location)); + return is_executable_file(elf_path); +} + AppLoaderApi loader_api = { .load = api_load, .run = api_run, .unload = api_unload, + .is_executable = api_is_executable, }; void* create_service(const ServiceManifest*) { diff --git a/Modules/app-module/include/app/elf_check.h b/Modules/app-module/include/app/elf_check.h new file mode 100644 index 000000000..1c23ba0cc --- /dev/null +++ b/Modules/app-module/include/app/elf_check.h @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// is not guaranteed to exist under the ESP32 newlib toolchain +#define ELF_CLASS_32 1 +#define ELF_CLASS_64 2 +#define ELF_DATA_2LSB 1 +#define ELF_TYPE_DYN 3 +#define ELF_MACHINE_XTENSA 94 +#define ELF_MACHINE_RISCV 243 +#define ELF_MACHINE_X86_64 62 +#define ELF_MACHINE_AARCH64 183 + +/** What a loader needs an ELF file's header to say before it will try to load it. */ +struct ElfRequirements { + uint8_t elf_class; /**< ELF_CLASS_32 / ELF_CLASS_64 */ + uint8_t data; /**< ELF_DATA_2LSB */ + uint16_t type; /**< ELF_TYPE_DYN */ + uint16_t machine; /**< ELF_MACHINE_XTENSA / _RISCV / _X86_64 / _AARCH64 */ +}; + +/** + * Reads the first 20 bytes of @a path (e_ident, e_type, e_machine; identical offsets for + * ELF32 and ELF64) and checks the magic number and every field in @a requirements match. + * @return false if @a path can't be opened, is too short, or doesn't match + */ +bool elf_check_file(const char* path, const struct ElfRequirements* requirements); + +#ifdef __cplusplus +} +#endif diff --git a/Modules/app-module/include/app/exec.h b/Modules/app-module/include/app/exec.h new file mode 100644 index 000000000..13d9660cd --- /dev/null +++ b/Modules/app-module/include/app/exec.h @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** Largest number of directories app_exec_path_add() can hold at once. */ +#define APP_EXEC_MAX_PATHS 8 + +/** + * Registers @a path as a directory whose contents may be executed. Applies recursively to + * subdirectories. Registering the same path twice is a no-op. + * @retval ERROR_OUT_OF_MEMORY the registry already holds APP_EXEC_MAX_PATHS entries + * @retval ERROR_NONE on success (including when @a path was already registered) + */ +error_t app_exec_path_add(const char* path); + +/** + * Unregisters a directory previously passed to app_exec_path_add(). + * @retval ERROR_NOT_FOUND @a path was not registered + * @retval ERROR_NONE on success + */ +error_t app_exec_path_remove(const char* path); + +/** + * True when @a file_path lives under a directory registered with app_exec_path_add(), at any + * depth. Rejects any path containing a ".." segment rather than normalising it. + */ +bool app_exec_path_allowed(const char* file_path); + +/** + * Asks the registered APP_LOADER_PATH_SERVICE_ID loader (see app/loader.h) whether @a path is + * runnable on this target, without loading it. + * @return false if @a path can't be run, or if no path loader is registered + */ +bool app_exec_is_executable_path(const char* path); + +/** + * Runs @a path directly, without it having to be pre-registered as an app (e.g. via + * app_manager_add() or app_install()), for a file a user picked in a file browser. Internally + * registers (app_manager_add(), APP_MANIFEST_FLAG_HIDDEN so it never shows up in app-browsing + * UIs) a manifest for @a path the first time it's run, reusing that same registration on + * subsequent calls for the same path, then starts it (app_manager_start_with_parameters()). + * @retval ERROR_NOT_ALLOWED app_exec_is_executable_path(path) is false + * @retval ERROR_NONE on success + */ +error_t app_exec_run_path(const char* path, int argc, const char* const argv[], AppInstanceId* out_app_instance_id); + +#ifdef __cplusplus +} +#endif diff --git a/Modules/app-module/include/app/loader.h b/Modules/app-module/include/app/loader.h index db2a12704..ec728fa03 100644 --- a/Modules/app-module/include/app/loader.h +++ b/Modules/app-module/include/app/loader.h @@ -3,6 +3,7 @@ #include #include +#include #include #include "location.h" @@ -18,15 +19,8 @@ extern "C" { * APP_LOCATION_PATH must register under. Implemented by a platform module (e.g. app-esp32-module). */ #define APP_LOADER_PATH_SERVICE_ID "app-loader-path" -/** - * Entry point signature for an APP_LOCATION_MEMORY app: a function linked directly into this - * firmware binary. Called on the dedicated task app-module's scheduler spawns for this instance, - * blocking for the app's whole lifetime - same contract as an external app's main(). Use - * app_scheduler_current_app_id() to identify this running instance (e.g. with - * app_event_subscribe()/window_manager_create()/etc.). The instance closes when this function - * returns - no separate call is needed. - * AppManifest::location.location holds this cast to void*. - */ +/** Entry point signature for an APP_LOCATION_MEMORY app. + * AppManifest::location.location holds this cast to void*. */ typedef int32_t (*AppMainFn)(int argc, char* argv[]); typedef void* AppRuntime; @@ -53,6 +47,11 @@ struct AppLoaderApi { /** Releases whatever load() allocated. Called after run() returns. */ void (*unload)(AppRuntime runtime); + + /** + * Reports whether this loader could load and run whatever @a location points at, without actually loading it. + */ + bool (*is_executable)(struct AppLocation location); }; #ifdef __cplusplus diff --git a/Modules/app-module/private/app/private/exec_internal.h b/Modules/app-module/private/app/private/exec_internal.h new file mode 100644 index 000000000..f4f879c26 --- /dev/null +++ b/Modules/app-module/private/app/private/exec_internal.h @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Registers app-module's default executable directories (data/bin, system/bin, the app install + * root) with app_exec_path_add(). Called once from app-module's own Module::start(). + */ +void app_exec_register_default_paths(void); + +#ifdef __cplusplus +} +#endif diff --git a/Modules/app-module/source/elf_check.cpp b/Modules/app-module/source/elf_check.cpp new file mode 100644 index 000000000..b08594da3 --- /dev/null +++ b/Modules/app-module/source/elf_check.cpp @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include +#include + +namespace { + +constexpr size_t ELF_HEADER_PREFIX_SIZE = 20; // e_ident[16] + e_type(2) + e_machine(2) +constexpr uint8_t ELF_MAGIC[4] = { 0x7f, 'E', 'L', 'F' }; + +uint16_t read_le16(const uint8_t* bytes) { + return static_cast(bytes[0] | (bytes[1] << 8)); +} + +} // namespace + +bool elf_check_file(const char* path, const struct ElfRequirements* requirements) { + FILE* file = fopen(path, "rb"); + if (file == nullptr) { + return false; + } + + uint8_t header[ELF_HEADER_PREFIX_SIZE]; + size_t read = fread(header, 1, sizeof(header), file); + fclose(file); + + if (read != sizeof(header)) { + return false; + } + + if (memcmp(header, ELF_MAGIC, sizeof(ELF_MAGIC)) != 0) { + return false; + } + + uint8_t elf_class = header[4]; // e_ident[EI_CLASS] + uint8_t data = header[5]; // e_ident[EI_DATA] + uint16_t type = read_le16(header + 16); // e_type + uint16_t machine = read_le16(header + 18); // e_machine + + return elf_class == requirements->elf_class + && data == requirements->data + && type == requirements->type + && machine == requirements->machine; +} diff --git a/Modules/app-module/source/exec.cpp b/Modules/app-module/source/exec.cpp new file mode 100644 index 000000000..224fa9fad --- /dev/null +++ b/Modules/app-module/source/exec.cpp @@ -0,0 +1,273 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include + +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +constexpr auto* TAG = "app_exec"; + +namespace { + +struct ExecPathRegistry { + std::string paths[APP_EXEC_MAX_PATHS]; + size_t count = 0; + Mutex mutex {}; + + ExecPathRegistry() { mutex_construct(&mutex); } +}; + +ExecPathRegistry& registry() { + static ExecPathRegistry instance; + return instance; +} + +// POSIX works with relative paths. The rest of the OS uses anchors relative paths. +// Resolving through getcwd() keeps every path callers pass in comparable, regardless of which convention they used. +// A path already starting with '/' passes through unchanged, so this is a no-op on ESP32. +std::string make_absolute(const std::string& path) { + if (path.empty() || path.front() == '/') { + return path; + } + char cwd[PATH_MAX]; + if (getcwd(cwd, sizeof(cwd)) == nullptr) { + return path; // best effort: falls back to today's (broken but not worse) behavior + } + return std::string(cwd) + "/" + path; +} + +// Resolves to absolute (see make_absolute()) and strips a trailing '/' so "/data/bin/" and +// "/data/bin" register/compare identically. +std::string normalize(const char* path) { + std::string result = make_absolute(path); + while (result.size() > 1 && result.back() == '/') { + result.pop_back(); + } + return result; +} + +bool has_dotdot_segment(const std::string& path) { + size_t start = 0; + while (start <= path.size()) { + size_t slash = path.find('/', start); + size_t length = (slash == std::string::npos ? path.size() : slash) - start; + if (path.compare(start, length, "..") == 0) { + return true; + } + if (slash == std::string::npos) { + break; + } + start = slash + 1; + } + return false; +} + +// True when @a file_path lives strictly under @a dir (a direct or nested child). Never equal to +// @a dir itself: a bare directory is never itself an executable candidate. +bool is_under_directory(const std::string& file_path, const std::string& dir) { + if (file_path.size() <= dir.size() || file_path.compare(0, dir.size(), dir) != 0) { + return false; + } + return file_path[dir.size()] == '/'; +} + +const AppLoaderApi* find_path_loader_api() { + ServiceInstance* instance = service_manager_find_instance(APP_LOADER_PATH_SERVICE_ID); + if (instance == nullptr) { + return nullptr; + } + return static_cast(service_instance_get_data(instance)); +} + +// region Ad hoc "run this path directly" registry: owns the AppManifest (and its id/name/path +// strings) that app_manager's own ledger only keeps a non-owning pointer to (see +// app_manager_add()'s contract; install.cpp's InstallRegistry exists for the same reason). +// Keyed by path so re-running the same file reuses its manifest instead of re-adding it. + +struct RunRecord { + std::string id; + std::string name; + std::string path; + AppManifest manifest {}; +}; + +struct RunRegistry { + std::unordered_map> apps; + Mutex mutex {}; + + RunRegistry() { mutex_construct(&mutex); } +}; + +RunRegistry& run_registry() { + static RunRegistry registry; + return registry; +} + +std::string last_path_segment(const std::string& path) { + auto index = path.find_last_of('/'); + return index == std::string::npos ? path : path.substr(index + 1); +} + +// A short, stable-per-path id that fits AppManifest::id's APP_ID_LENGTH limit regardless of how +// long @a path is. FNV-1a's collision odds are irrelevant at the scale of "distinct executables +// one user runs from the Files app". +std::string make_run_id(const std::string& path) { + uint32_t hash = 2166136261u; + for (unsigned char c: path) { + hash ^= c; + hash *= 16777619u; + } + char id[16]; + std::snprintf(id, sizeof(id), "run.%08x", static_cast(hash)); + return id; +} + +} // namespace + +extern "C" { + +error_t app_exec_path_add(const char* path) { + auto normalized = normalize(path); + + auto& reg = registry(); + mutex_lock(®.mutex); + + for (size_t i = 0; i < reg.count; i++) { + if (reg.paths[i] == normalized) { + mutex_unlock(®.mutex); + return ERROR_NONE; + } + } + + if (reg.count >= APP_EXEC_MAX_PATHS) { + mutex_unlock(®.mutex); + LOG_E(TAG, "Registry full, can't add %s", path); + return ERROR_OUT_OF_MEMORY; + } + + reg.paths[reg.count++] = normalized; + mutex_unlock(®.mutex); + return ERROR_NONE; +} + +error_t app_exec_path_remove(const char* path) { + auto normalized = normalize(path); + + auto& reg = registry(); + mutex_lock(®.mutex); + + for (size_t i = 0; i < reg.count; i++) { + if (reg.paths[i] == normalized) { + reg.paths[i] = reg.paths[reg.count - 1]; + reg.count--; + mutex_unlock(®.mutex); + return ERROR_NONE; + } + } + + mutex_unlock(®.mutex); + return ERROR_NOT_FOUND; +} + +bool app_exec_path_allowed(const char* file_path) { + std::string path = make_absolute(file_path); + if (has_dotdot_segment(path)) { + return false; + } + + auto& reg = registry(); + mutex_lock(®.mutex); + bool allowed = false; + for (size_t i = 0; i < reg.count; i++) { + if (is_under_directory(path, reg.paths[i])) { + allowed = true; + break; + } + } + mutex_unlock(®.mutex); + return allowed; +} + +bool app_exec_is_executable_path(const char* path) { + const AppLoaderApi* loader = find_path_loader_api(); + if (loader == nullptr || loader->is_executable == nullptr) { + return false; + } + + AppLocation location { APP_LOCATION_PATH, const_cast(path) }; + return loader->is_executable(location); +} + +error_t app_exec_run_path(const char* path, int argc, const char* const argv[], AppInstanceId* out_app_instance_id) { + if (!app_exec_is_executable_path(path)) { + return ERROR_NOT_ALLOWED; + } + + std::string path_str(path); + auto& reg = run_registry(); + mutex_lock(®.mutex); + + auto iterator = reg.apps.find(path_str); + std::string id; + if (iterator != reg.apps.end()) { + id = iterator->second->id; + mutex_unlock(®.mutex); + } else { + auto record = std::make_unique(); + record->id = make_run_id(path_str); + record->name = last_path_segment(path_str); + record->path = path_str; + record->manifest = AppManifest { + .id = record->id.c_str(), + .name = record->name.c_str(), + .category = APP_CATEGORY_USER, + .location = { APP_LOCATION_PATH, const_cast(record->path.c_str()) }, + .flags = APP_MANIFEST_FLAG_HIDDEN, + .stack = { .depth = 0, .desired_memory_capability = 0 }, + }; + + error_t add_result = app_manager_add(&record->manifest); + if (add_result != ERROR_NONE) { + mutex_unlock(®.mutex); + LOG_E(TAG, "Failed to register %s: %s", path, error_to_string(add_result)); + return add_result; + } + + id = record->id; + reg.apps[path_str] = std::move(record); + mutex_unlock(®.mutex); + } + + return app_manager_start_with_parameters(id.c_str(), argc, argv, out_app_instance_id); +} + +void app_exec_register_default_paths() { + char data_root[192]; + if (paths_get_data_path(data_root, sizeof(data_root)) == ERROR_NONE) { + app_exec_path_add((std::string(data_root) + "/bin").c_str()); + app_exec_path_add((std::string(data_root) + "/app").c_str()); + } else { + LOG_W(TAG, "Failed to resolve data path, not registering its executable directories"); + } +} + +} // extern "C" diff --git a/Modules/app-module/source/internal_loader.cpp b/Modules/app-module/source/internal_loader.cpp index 70d0e179d..390507859 100644 --- a/Modules/app-module/source/internal_loader.cpp +++ b/Modules/app-module/source/internal_loader.cpp @@ -24,10 +24,15 @@ int32_t api_run(AppRuntime runtime, uint32_t /*app_instance_id*/, int argc, char void api_unload(AppRuntime /*unused*/) { } +bool api_is_executable(AppLocation location) { + return location.type == APP_LOCATION_MEMORY && location.location != nullptr; +} + AppLoaderApi memory_loader_api = { .load = api_load, .run = api_run, .unload = api_unload, + .is_executable = api_is_executable, }; void* create_service(const ServiceManifest*) { diff --git a/Modules/app-module/source/module.cpp b/Modules/app-module/source/module.cpp index 9c3fb2f98..8f010c38b 100644 --- a/Modules/app-module/source/module.cpp +++ b/Modules/app-module/source/module.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 #include +#include #include #include #include @@ -9,6 +10,8 @@ #include #include +#include + #include #include @@ -73,7 +76,12 @@ static const ModuleSymbol SYMBOLS[] = { }; static error_t start() { - return service_manager_add(&app_internal_loader_service_manifest, /*auto_start=*/true); + error_t error = service_manager_add(&app_internal_loader_service_manifest, /*auto_start=*/true); + if (error != ERROR_NONE) { + return error; + } + app_exec_register_default_paths(); + return ERROR_NONE; } static error_t stop() { diff --git a/Modules/app-module/tests/source/exec_test.cpp b/Modules/app-module/tests/source/exec_test.cpp new file mode 100644 index 000000000..abd8f10d7 --- /dev/null +++ b/Modules/app-module/tests/source/exec_test.cpp @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "doctest.h" + +#include +#include +#include + +#include +#include + +#include +#include +#include +#include + +extern ServiceManifest app_internal_loader_service_manifest; + +namespace { + +const AppLoaderApi* memory_loader_api() { + if (service_manager_find_instance(APP_LOADER_MEMORY_SERVICE_ID) == nullptr) { + service_manager_add(&app_internal_loader_service_manifest, /*auto_start=*/true); + } + auto* instance = service_manager_find_instance(APP_LOADER_MEMORY_SERVICE_ID); + REQUIRE(instance != nullptr); + return static_cast(service_instance_get_data(instance)); +} + +int32_t fake_entry(int, char*[]) { + return 0; +} + +} // namespace + +TEST_CASE("app_exec_path_add/app_exec_path_allowed accept nested children of a registered directory") { + REQUIRE_EQ(app_exec_path_add("/exec-test/bin"), ERROR_NONE); + + CHECK(app_exec_path_allowed("/exec-test/bin/foo")); + CHECK(app_exec_path_allowed("/exec-test/bin/sub/foo")); + CHECK_FALSE(app_exec_path_allowed("/exec-test/binaries/foo")); + CHECK_FALSE(app_exec_path_allowed("/exec-test/bin")); + + app_exec_path_remove("/exec-test/bin"); +} + +TEST_CASE("app_exec_path_allowed rejects a '..' segment even under a registered directory") { + REQUIRE_EQ(app_exec_path_add("/exec-test/bin2"), ERROR_NONE); + + CHECK_FALSE(app_exec_path_allowed("/exec-test/bin2/../etc/passwd")); + + app_exec_path_remove("/exec-test/bin2"); +} + +TEST_CASE("app_exec_path_add is a no-op for a path already registered") { + REQUIRE_EQ(app_exec_path_add("/exec-test/dup"), ERROR_NONE); + REQUIRE_EQ(app_exec_path_add("/exec-test/dup"), ERROR_NONE); + + CHECK(app_exec_path_allowed("/exec-test/dup/foo")); + + app_exec_path_remove("/exec-test/dup"); +} + +TEST_CASE("app_exec_path_add does not register same item twice") { + REQUIRE_EQ(app_exec_path_add("/exec-test/dup"), ERROR_NONE); + REQUIRE_EQ(app_exec_path_add("/exec-test/dup"), ERROR_NONE); + app_exec_path_remove("/exec-test/dup"); + CHECK(!app_exec_path_allowed("/exec-test/dup/foo")); +} + +TEST_CASE("app_exec_path_remove reports ERROR_NOT_FOUND for a path that was never registered") { + CHECK_EQ(app_exec_path_remove("/exec-test/never-registered"), ERROR_NOT_FOUND); +} + +TEST_CASE("app_exec_path_remove makes a previously allowed path disallowed again") { + REQUIRE_EQ(app_exec_path_add("/exec-test/removable"), ERROR_NONE); + REQUIRE(app_exec_path_allowed("/exec-test/removable/foo")); + + REQUIRE_EQ(app_exec_path_remove("/exec-test/removable"), ERROR_NONE); + CHECK_FALSE(app_exec_path_allowed("/exec-test/removable/foo")); +} + +TEST_CASE("app_exec_path_add resolves a relative path to absolute, so it matches an absolute incoming path") { + char cwd[PATH_MAX]; + REQUIRE(getcwd(cwd, sizeof(cwd)) != nullptr); + + REQUIRE_EQ(app_exec_path_add("exec-test-relative/bin"), ERROR_NONE); + + std::string absolute_child = std::string(cwd) + "/exec-test-relative/bin/foo"; + CHECK(app_exec_path_allowed(absolute_child.c_str())); + + app_exec_path_remove("exec-test-relative/bin"); + CHECK_FALSE(app_exec_path_allowed(absolute_child.c_str())); +} + +TEST_CASE("app_exec_path_allowed resolves a relative incoming path to absolute, so it matches an absolute registered directory") { + char cwd[PATH_MAX]; + REQUIRE(getcwd(cwd, sizeof(cwd)) != nullptr); + + std::string absolute_dir = std::string(cwd) + "/exec-test-relative2/bin"; + REQUIRE_EQ(app_exec_path_add(absolute_dir.c_str()), ERROR_NONE); + + CHECK(app_exec_path_allowed("exec-test-relative2/bin/foo")); + + app_exec_path_remove(absolute_dir.c_str()); +} + +TEST_CASE("app_exec_path_add returns ERROR_OUT_OF_MEMORY once the registry is full") { + char path[32]; + int added = 0; + for (added = 0; added < APP_EXEC_MAX_PATHS + 1; added++) { + snprintf(path, sizeof(path), "/exec-test/overflow%d", added); + if (app_exec_path_add(path) != ERROR_NONE) { + break; + } + } + CHECK_LT(added, APP_EXEC_MAX_PATHS + 1); + + for (int i = 0; i < added; i++) { + snprintf(path, sizeof(path), "/exec-test/overflow%d", i); + app_exec_path_remove(path); + } +} + +TEST_CASE("memory loader's is_executable() reports a non-null AppLocation as executable, a null one as not") { + const AppLoaderApi* loader = memory_loader_api(); + REQUIRE(loader->is_executable != nullptr); + + AppLocation runnable { APP_LOCATION_MEMORY, reinterpret_cast(fake_entry) }; + CHECK(loader->is_executable(runnable)); + + AppLocation empty { APP_LOCATION_MEMORY, nullptr }; + CHECK_FALSE(loader->is_executable(empty)); + + AppLocation wrong_type { APP_LOCATION_PATH, reinterpret_cast(fake_entry) }; + CHECK_FALSE(loader->is_executable(wrong_type)); +} diff --git a/Modules/app-posix-module/source/app_posix_loader_service.cpp b/Modules/app-posix-module/source/app_posix_loader_service.cpp index 9103a821a..404eccc18 100644 --- a/Modules/app-posix-module/source/app_posix_loader_service.cpp +++ b/Modules/app-posix-module/source/app_posix_loader_service.cpp @@ -1,4 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 +#include +#include #include #include @@ -27,6 +29,36 @@ bool is_regular_file(const std::string& path) { return ::stat(path.c_str(), &path_stat) == 0 && S_ISREG(path_stat.st_mode); } +#ifndef __APPLE__ +constexpr ElfRequirements EXECUTABLE_REQUIREMENTS = { + .elf_class = ELF_CLASS_64, + .data = ELF_DATA_2LSB, + .type = ELF_TYPE_DYN, +#if defined(__x86_64__) + .machine = ELF_MACHINE_X86_64, +#elif defined(__aarch64__) + .machine = ELF_MACHINE_AARCH64, +#else +#error "Unsupported POSIX architecture for ELF machine check" +#endif +}; +#endif + +// Validates an already-resolved binary path (see resolve_app_path()) before it's handed to +// dlopen(): the extension and directory checks are cheap string comparisons, so the file is only +// opened as a last resort. +bool is_executable_file(const std::string& resolved_path) { + if (!resolved_path.ends_with(".so") || !app_exec_path_allowed(resolved_path.c_str())) { + return false; + } +#ifdef __APPLE__ + // The simulator's app binaries are Mach-O on macOS, not ELF, so there is no header to check. + return is_regular_file(resolved_path); +#else + return elf_check_file(resolved_path.c_str(), &EXECUTABLE_REQUIREMENTS); +#endif +} + // location.location can be either an app's install directory or the .so file directly; the // former resolves to the per-architecture binary at {dir}/elf/posix-{TACTILITY_POSIX_ARCH}.so, // mirroring app_esp32_loader_service.cpp's resolve_elf_path(). @@ -56,6 +88,11 @@ error_t api_load(AppLocation location, AppRuntime* out_runtime) { return error; } + if (!is_executable_file(app_path)) { + LOG_E(TAG, "Not executable: %s", app_path.c_str()); + return ERROR_NOT_ALLOWED; + } + LOG_I(TAG, "Loading %s", app_path.c_str()); // RTLD_NOW: a missing symbol fails here, not mid-run(). RTLD_LOCAL: this app's own exported @@ -100,10 +137,24 @@ void api_unload(AppRuntime runtime_ptr) { delete runtime; } +bool api_is_executable(AppLocation location) { + if (location.type != APP_LOCATION_PATH) { + return false; + } + + std::string app_path; + if (resolve_app_path(static_cast(location.location), app_path) != ERROR_NONE) { + return false; + } + + return is_executable_file(app_path); +} + AppLoaderApi loader_api = { .load = api_load, .run = api_run, .unload = api_unload, + .is_executable = api_is_executable, }; void* create_service(const ServiceManifest*) { diff --git a/Modules/app-posix-module/tests/CMakeLists.txt b/Modules/app-posix-module/tests/CMakeLists.txt index 11d2d6654..41f59cb0e 100644 --- a/Modules/app-posix-module/tests/CMakeLists.txt +++ b/Modules/app-posix-module/tests/CMakeLists.txt @@ -11,6 +11,10 @@ add_library(app_posix_module_test_fixture SHARED EXCLUDE_FROM_ALL ${CMAKE_CURREN target_include_directories(app_posix_module_test_fixture PRIVATE ${CMAKE_SOURCE_DIR}/Modules/app-module/include) set_target_properties(app_posix_module_test_fixture PROPERTIES POSITION_INDEPENDENT_CODE ON) +# A file with a ".so" extension but no ELF header, for is_executable() rejection tests. +set(NON_ELF_FIXTURE_PATH "${CMAKE_CURRENT_BINARY_DIR}/not-elf.so") +file(WRITE "${NON_ELF_FIXTURE_PATH}" "not an elf file") + file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS ${PROJECT_SOURCE_DIR}/source/*.cpp) add_executable(AppPosixModuleTests EXCLUDE_FROM_ALL ${TEST_SOURCES}) add_dependencies(AppPosixModuleTests app_posix_module_test_fixture) @@ -18,6 +22,7 @@ add_dependencies(AppPosixModuleTests app_posix_module_test_fixture) target_include_directories(AppPosixModuleTests PRIVATE ${DOCTESTINC}) target_compile_definitions(AppPosixModuleTests PRIVATE FIXTURE_APP_PATH="$" + FIXTURE_NON_ELF_PATH="${NON_ELF_FIXTURE_PATH}" ) add_test(NAME AppPosixModuleTests COMMAND AppPosixModuleTests) diff --git a/Modules/app-posix-module/tests/source/loader_test.cpp b/Modules/app-posix-module/tests/source/loader_test.cpp index 03e85dc76..d377c7387 100644 --- a/Modules/app-posix-module/tests/source/loader_test.cpp +++ b/Modules/app-posix-module/tests/source/loader_test.cpp @@ -2,6 +2,7 @@ #include "doctest.h" #include +#include #include #include #include @@ -11,6 +12,7 @@ #include #include +#include extern ServiceManifest loader_service_manifest; // app-posix-module's own extern ServiceManifest app_internal_loader_service_manifest; // app-module's real memory loader @@ -29,6 +31,13 @@ void ensure_memory_loader_registered() { } } +std::string directory_of(const std::string& path) { + auto slash = path.find_last_of('/'); + return slash == std::string::npos ? "." : path.substr(0, slash); +} + +const std::string FIXTURE_DIR = directory_of(FIXTURE_APP_PATH); + bool wait_for_state(AppInstanceId id, AppInstanceState target, uint32_t timeout_ms) { uint32_t waited = 0; while (waited < timeout_ms) { @@ -93,6 +102,7 @@ int32_t parent_app_main(int, char*[]) { TEST_CASE("app-posix-module's loader-path service dlopen()s a .so and calls its main(), which resolves a real Tactility symbol against the host") { ensure_path_loader_registered(); ensure_memory_loader_registered(); + REQUIRE_EQ(app_exec_path_add(FIXTURE_DIR.c_str()), ERROR_NONE); g_fixture_result.store(-1, std::memory_order_relaxed); g_fixture_result_received.store(false, std::memory_order_relaxed); @@ -111,3 +121,45 @@ TEST_CASE("app-posix-module's loader-path service dlopen()s a .so and calls its app_manager_remove("test.posix.parent"); } + +TEST_CASE("app_exec_is_executable_path() accepts a real .so in a registered directory") { + ensure_path_loader_registered(); + REQUIRE_EQ(app_exec_path_add(FIXTURE_DIR.c_str()), ERROR_NONE); + + CHECK(app_exec_is_executable_path(FIXTURE_APP_PATH)); +} + +TEST_CASE("app_exec_is_executable_path() rejects the same .so when its directory isn't registered") { + ensure_path_loader_registered(); + app_exec_path_remove(FIXTURE_DIR.c_str()); + + CHECK_FALSE(app_exec_is_executable_path(FIXTURE_APP_PATH)); + + // Restore for any later test case that relies on it being registered. + app_exec_path_add(FIXTURE_DIR.c_str()); +} + +TEST_CASE("app_exec_is_executable_path() rejects a .so-named file with no ELF header") { + ensure_path_loader_registered(); + auto non_elf_dir = directory_of(FIXTURE_NON_ELF_PATH); + REQUIRE_EQ(app_exec_path_add(non_elf_dir.c_str()), ERROR_NONE); + + CHECK_FALSE(app_exec_is_executable_path(FIXTURE_NON_ELF_PATH)); + + app_exec_path_remove(non_elf_dir.c_str()); +} + +TEST_CASE("app_exec_is_executable_path() rejects a nonexistent path in a registered directory") { + ensure_path_loader_registered(); + REQUIRE_EQ(app_exec_path_add(FIXTURE_DIR.c_str()), ERROR_NONE); + + CHECK_FALSE(app_exec_is_executable_path((FIXTURE_DIR + "/does-not-exist.so").c_str())); +} + +TEST_CASE("app_exec_is_executable_path() rejects an install-directory-shaped path missing its per-arch .so") { + ensure_path_loader_registered(); + REQUIRE_EQ(app_exec_path_add(FIXTURE_DIR.c_str()), ERROR_NONE); + + // FIXTURE_DIR itself has no elf/posix-.so under it, so resolution fails. + CHECK_FALSE(app_exec_is_executable_path(FIXTURE_DIR.c_str())); +} diff --git a/Tactility/Private/Tactility/app/files/View.h b/Tactility/Private/Tactility/app/files/View.h index ab3fa7f81..948c8a9d7 100644 --- a/Tactility/Private/Tactility/app/files/View.h +++ b/Tactility/Private/Tactility/app/files/View.h @@ -30,8 +30,10 @@ class View final { void showActionsForDirectory(); void showActionsForFile(); void showActionsForMountPoint(); + void addCommonFileActions(); void viewFile(const std::string&path, const std::string&filename); + void runFile(const std::string& file_path); void createDirEntryWidget(lv_obj_t* parent, dirent& dir_entry); void onNavigate(); @@ -54,6 +56,7 @@ public: void onCutPressed(); void onPastePressed(); void onEjectPressed(); + void onRunPressed(); void onDirEntryListScrollBegin(); void onResult(uint32_t launchId, int32_t result); void deinit(); diff --git a/Tactility/Source/app/files/View.cpp b/Tactility/Source/app/files/View.cpp index 7512be500..4cfdf5126 100644 --- a/Tactility/Source/app/files/View.cpp +++ b/Tactility/Source/app/files/View.cpp @@ -1,5 +1,6 @@ -#include #include +#include +#include #include #include @@ -102,6 +103,11 @@ static void onPastePressedCallback(lv_event_t* event) { view->onPastePressed(); } +static void onRunPressedCallback(lv_event_t* event) { + auto* view = static_cast(lv_event_get_user_data(event)); + view->onRunPressed(); +} + // endregion // region File helpers @@ -192,6 +198,8 @@ void View::viewFile(const std::string& path, const std::string& filename) { // Remove forward slash, because we need a relative path notes::start(file_path.substr(1)); } + } else if (app_exec_is_executable_path(file_path.c_str())) { + runFile(file_path); } else { LOG_W(TAG, "Opening files of this type is not supported"); } @@ -199,6 +207,15 @@ void View::viewFile(const std::string& path, const std::string& filename) { onNavigate(); } +void View::runFile(const std::string& file_path) { + LOG_I(TAG, "Running %s", file_path.c_str()); + AppInstanceId instance_id = 0; + if (app_exec_run_path(file_path.c_str(), 0, nullptr, &instance_id) != ERROR_NONE) { + LOG_W(TAG, "Failed to run %s", file_path.c_str()); + alertdialog::start(appInstanceId, "Run failed", "Could not run \"" + file::getLastPathSegment(file_path) + "\"."); + } +} + bool View::resolveDirentFromListIndex(int32_t list_index, dirent& out_entry) { const bool is_root = (state->getCurrentPath() == "/"); const bool has_back = (!is_root && current_start_index > 0); @@ -287,6 +304,8 @@ void View::createDirEntryWidget(lv_obj_t* list, dirent& dir_entry) { symbol = LV_SYMBOL_IMAGE; } else if (dir_entry.d_type == file::TT_DT_LNK) { symbol = LV_SYMBOL_LOOP; + } else if (app_exec_is_executable_path(file::getChildPath(state->getCurrentPath(), dir_entry.d_name).c_str())) { + symbol = LV_SYMBOL_PLAY; } else { symbol = LV_SYMBOL_FILE; } @@ -370,9 +389,7 @@ void View::onNewFolderPressed() { inputdialog::start(appInstanceId, "New Folder", "Enter folder name:", ""); } -void View::showActions() { - lv_obj_clean(action_list); - +void View::addCommonFileActions() { auto* copy_button = lv_list_add_button(action_list, LV_SYMBOL_COPY, "Copy"); lv_obj_add_event_cb(copy_button, onCopyPressedCallback, LV_EVENT_SHORT_CLICKED, this); auto* cut_button = lv_list_add_button(action_list, LV_SYMBOL_CUT, "Cut"); @@ -381,12 +398,27 @@ void View::showActions() { lv_obj_add_event_cb(rename_button, onRenamePressedCallback, LV_EVENT_SHORT_CLICKED, this); auto* delete_button = lv_list_add_button(action_list, LV_SYMBOL_TRASH, "Delete"); lv_obj_add_event_cb(delete_button, onDeletePressedCallback, LV_EVENT_SHORT_CLICKED, this); +} +void View::showActions() { + lv_obj_clean(action_list); + addCommonFileActions(); lv_obj_remove_flag(action_list, LV_OBJ_FLAG_HIDDEN); } void View::showActionsForDirectory() { showActions(); } -void View::showActionsForFile() { showActions(); } + +void View::showActionsForFile() { + lv_obj_clean(action_list); + + if (app_exec_is_executable_path(state->getSelectedChildPath().c_str())) { + auto* run_button = lv_list_add_button(action_list, LV_SYMBOL_PLAY, "Run"); + lv_obj_add_event_cb(run_button, onRunPressedCallback, LV_EVENT_SHORT_CLICKED, this); + } + + addCommonFileActions(); + lv_obj_remove_flag(action_list, LV_OBJ_FLAG_HIDDEN); +} void View::showActionsForMountPoint() { lv_obj_clean(action_list); @@ -397,6 +429,12 @@ void View::showActionsForMountPoint() { lv_obj_remove_flag(action_list, LV_OBJ_FLAG_HIDDEN); } +void View::onRunPressed() { + std::string file_path = state->getSelectedChildPath(); + onNavigate(); + runFile(file_path); +} + void View::onEjectPressed() { std::string mount_path = state->getSelectedChildPath(); LOG_I(TAG, "Ejecting %s", mount_path.c_str());