diff --git a/CMakeLists.txt b/CMakeLists.txt index 27440be4..0c1abe01 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -86,8 +86,8 @@ add_executable(${PROJECT_NAME}) target_link_libraries(${PROJECT_NAME} PUBLIC core-target) if (WIN32) - # A GUI program: a console program lives in a console window and exits with it, closing that window or a crash - # of Windows Terminal ended the app silently. --console attaches to the console it was started from. + # A GUI program, not tied to a console window. --console, the help and parse errors use the console it was + # started from. set_target_properties(${PROJECT_NAME} PROPERTIES WIN32_EXECUTABLE TRUE) include("${CMAKE_CURRENT_LIST_DIR}/_cmake/windows/icon.cmake") file(TO_CMAKE_PATH "${NUI_SFTP_ICO}" NUI_SFTP_ICO_FORWARD) @@ -205,6 +205,7 @@ else() add_subdirectory(shared_data/benchmark/shared_data) add_subdirectory(tar-archive/test/tar_archive) add_subdirectory(command-store/test/command-store) + add_subdirectory(frontend/test/frontend) add_test( NAME NarrowPathConversions COMMAND "${CMAKE_COMMAND}" "-DSOURCE_DIR=${CMAKE_SOURCE_DIR}" -P "${CMAKE_SOURCE_DIR}/_cmake/check_narrow_path_text.cmake" diff --git a/backend/include/backend/file_tracking/instance_bookkeeping.hpp b/backend/include/backend/file_tracking/instance_bookkeeping.hpp new file mode 100644 index 00000000..36cd76b8 --- /dev/null +++ b/backend/include/backend/file_tracking/instance_bookkeeping.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include + +namespace FileTracking +{ + /** + * @brief The hidden directory inside an instance directory that holds the instance lock and the metadata, so + * they never share a name with the tracked content next to them. + */ + inline constexpr std::string_view bookkeepingDirectoryName{".nui-sftp-tracking"}; + + /** + * @brief The lock file and the metadata file of one instance directory. + */ + struct BookkeepingPaths + { + std::filesystem::path lock; + std::filesystem::path metadata; + }; + + /** + * @brief Where an instance keeps its lock and metadata: inside the bookkeeping directory. + * @param instanceDirectory The instance directory. + */ + BookkeepingPaths bookkeepingPaths(std::filesystem::path const& instanceDirectory); + + /** + * @brief Where builds without the bookkeeping directory keep them: ".lock" and "metadata.json" + * directly in the instance directory. They are still read, and the metadata is mirrored into them. + * @param instanceDirectory The instance directory, named after the instance id. + */ + BookkeepingPaths legacyBookkeepingPaths(std::filesystem::path const& instanceDirectory); + + /** + * @brief Creates the bookkeeping directory of @p instanceDirectory (and the instance directory). On Windows it + * gets the hidden attribute, elsewhere the leading dot hides it. + */ + std::expected createBookkeepingDirectory(std::filesystem::path const& instanceDirectory); + + /** + * @brief Whether @p path is the bookkeeping directory of @p instanceDirectory or lies inside it. Compares + * lexically, so both paths must be spelled alike (both canonical, for one). + */ + bool isBookkeepingPath(std::filesystem::path const& instanceDirectory, std::filesystem::path const& path); + + /** + * @brief The lock file of @p instanceDirectory: the one in the bookkeeping directory, else the legacy one. + * @return Nothing when neither exists, the directory is then no instance directory. + */ + std::optional existingLockFile(std::filesystem::path const& instanceDirectory); + + /** + * @brief Reads the metadata of @p instanceDirectory: the file in the bookkeeping directory, else the legacy one + * of a legacy instance (a legacy lock and no current one; elsewhere "metadata.json" is tracked content). + * @return Nothing when neither exists, an error when the file cannot be read or parsed. + */ + std::expected, std::string> + readMetadata(std::filesystem::path const& instanceDirectory); + + /** + * @brief Writes @p metadata into the bookkeeping directory, creating it when missing, and into the legacy file of + * a legacy instance when it has one, so builds that only know the legacy location read the same. + */ + std::expected + writeMetadata(std::filesystem::path const& instanceDirectory, nlohmann::json const& metadata); +} diff --git a/backend/include/backend/file_tracking/instance_watch.hpp b/backend/include/backend/file_tracking/instance_watch.hpp index f748ff82..ec716c5c 100644 --- a/backend/include/backend/file_tracking/instance_watch.hpp +++ b/backend/include/backend/file_tracking/instance_watch.hpp @@ -10,9 +10,10 @@ namespace FileTracking /** * @brief Move-only RAII handle representing one efsw directory watch. * - * Destroying or moving-from an InstanceWatch automatically removes the - * underlying efsw watch via efsw::FileWatcher::removeWatch(). - * Instances of this type are produced by + * Destroying an InstanceWatch or assigning another one to it removes the + * underlying efsw watch via efsw::FileWatcher::removeWatch(). It does not keep + * the watcher alive: once its instance destroyed the watcher there is nothing + * left to remove. Instances of this type are produced by * TemporaryDirectoryInstance::addWatch(). */ class InstanceWatch @@ -26,13 +27,12 @@ namespace FileTracking /** * @brief Construct a valid watch handle. * - * @param watcher Shared owning pointer to the efsw::FileWatcher that - * registered this watch. + * @param watcher The efsw::FileWatcher that registered this watch. * @param watchId The watch ID returned by efsw::FileWatcher::addWatch(). * @param path The directory path being watched. */ InstanceWatch( - std::shared_ptr watcher, + std::weak_ptr watcher, efsw::WatchID watchId, std::filesystem::path path ); diff --git a/backend/include/backend/file_tracking/temp_dir_instance_manager.hpp b/backend/include/backend/file_tracking/temp_dir_instance_manager.hpp index 32f6b008..98b2055f 100644 --- a/backend/include/backend/file_tracking/temp_dir_instance_manager.hpp +++ b/backend/include/backend/file_tracking/temp_dir_instance_manager.hpp @@ -4,8 +4,10 @@ #include #include +#include #include +#include #include #include #include @@ -29,14 +31,47 @@ namespace FileTracking std::filesystem::path instanceDir; }; - // ------------------------------------------------------------------------- + namespace InstanceManagerDetail + { + /** + * @brief What the reply of FileTracking::fileStates lists: every regular file below @p instanceDirectory as + * {path, size, mtime} (seconds) and every directory as {path, directory: true}, paths canonical and + * UTF-8. Symbolic links and the bookkeeping directory are left out. + */ + nlohmann::json fileStatesOf(std::filesystem::path const& instanceDirectory); + + /** + * @brief @p path as content of @p instanceDirectory that may be removed: its parent directory resolved, + * its last component kept as it is, so a link there is removed itself. + * @return An error when @p path is not below @p instanceDirectory once its parent is resolved, is the + * instance directory itself or lies in the bookkeeping directory. + */ + std::expected + removableContentPath(std::filesystem::path const& instanceDirectory, std::filesystem::path const& path); + + /** + * @brief Whether @p instanceDirectory holds anything besides its bookkeeping directory. A directory that + * cannot be read counts as holding content. + */ + bool hasContent(std::filesystem::path const& instanceDirectory); + } + + /** + * @brief What TempDirInstanceManager::discardContent did with the instance after removing the content. + */ + enum class DiscardOutcome + { + InstanceKept, + InstanceDestroyed, + }; /** * @brief Owns and tracks all TemporaryDirectoryInstance objects for a session. * * Inherits RpcHelper::StrandRpc so that all RPC handlers it registers run on - * the shared strand. The manager also owns the dead-instance cleanup policy - * and exposes it both as a C++ API and as RPC endpoints. + * the shared strand. The manager also owns the dead-instance cleanup policy: + * the retention scan runs once at start (see scheduleStartupCleanup), the + * frontend's Clean Up removes every orphaned directory at once (forceCleanup). * * ### Registered RPC endpoints * All names are prefixed with @c "FileTracking::". @@ -47,7 +82,8 @@ namespace FileTracking * | destroyInstance | instanceId: string | {success} | * | listInstances | — | {success, instances: [...]} | * | getInstanceInfo | instanceId: string | {success, instanceId, instanceDir} | - * | manualCleanup | — | {success} | + * | fileStates | instanceId: string | {success, files: [...]}, see InstanceManagerDetail::fileStatesOf | + * | discardContent | instanceId: string, path: string | {success, instanceDestroyed: bool}, see discardContent | * | countOrphaned | — | {success, count: int} | * | forceCleanup | — | {success, removed: int} | * @@ -94,6 +130,13 @@ namespace FileTracking */ void registerRpc(); + /** + * @brief Runs manualCleanup() once on the strand, for the instance directories that earlier runs left. + * + * Called once at start, after registerRpc(), so the scan runs before the frontend's first request. + */ + void scheduleStartupCleanup(); + // ---- C++ API -------------------------------------------------------- /** @@ -106,7 +149,7 @@ namespace FileTracking TemporaryDirectoryInstance* createInstance(); /** - * @brief Destroy and remove the instance with the given ID. + * @brief Remove the watches of the instance with the given ID, then destroy it. * * If the instance does not exist this is a no-op. * @@ -114,6 +157,19 @@ namespace FileTracking */ void destroyInstance(std::string const& instanceId); + /** + * @brief Removes @p path, content of the instance @p instanceId that the user decided not to keep, after + * ending the instance's watches, so the removal is reported as no change. A link is removed itself, + * never followed. When nothing else is left in the instance directory, the instance is destroyed + * and, unlike destroyInstance(), its directory is removed as well (bookkeeping included, after its + * lock is released), so no orphan stays behind. + * + * @return An error when the instance does not exist, @p path is no content of it (see + * InstanceManagerDetail::removableContentPath) or cannot be removed. + */ + std::expected + discardContent(std::string const& instanceId, std::filesystem::path const& path); + /** * @brief Look up an instance by ID. * diff --git a/backend/include/backend/file_tracking/temp_dir_instancing.hpp b/backend/include/backend/file_tracking/temp_dir_instancing.hpp index 7b676486..e0658ca0 100644 --- a/backend/include/backend/file_tracking/temp_dir_instancing.hpp +++ b/backend/include/backend/file_tracking/temp_dir_instancing.hpp @@ -40,8 +40,9 @@ namespace FileTracking * @brief Manages one temporary directory instance used for file-change tracking. * * On construction a UUID-named subdirectory is created under @p tempRootDir, - * an exclusive instance lock file is acquired inside it, and a metadata file is - * written. + * and an exclusive instance lock file is acquired and a metadata file written + * in its bookkeeping directory (see bookkeepingPaths()). Events inside the + * bookkeeping directory are not forwarded. * * On destruction the instance does **not** delete its directory. It writes a * "no longer alive" timestamp into the metadata file so a future scan (performed @@ -56,7 +57,8 @@ namespace FileTracking * "action": "Added" | "Modified" | "Deleted" | "Moved", * "directory": "/abs/path/to/dir/", * "filename": "changed-file.txt", - * "oldFilename": "previous-name.txt" // only populated for Moved + * "oldFilename": "previous-name.txt", // only populated for Moved + * "isDirectory": false // the changed path is a directory now * } * @endcode * diff --git a/backend/include/backend/main.hpp b/backend/include/backend/main.hpp index b4055ec4..4f13657c 100644 --- a/backend/include/backend/main.hpp +++ b/backend/include/backend/main.hpp @@ -66,6 +66,10 @@ class Main Nui::Window window_; Nui::RpcHub hub_; std::unique_ptr opener_; + /** + * @brief The saved openRiskyFiles setting, written on the thread that saves the state and read by RpcFilesystem. + */ + std::atomic riskyFilePolicy_; std::unique_ptr rpcFilesystem_; std::unique_ptr rpcSystem_; ProcessStore processes_; diff --git a/backend/include/backend/opener_risk.hpp b/backend/include/backend/opener_risk.hpp new file mode 100644 index 00000000..48af635a --- /dev/null +++ b/backend/include/backend/opener_risk.hpp @@ -0,0 +1,88 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace OpenerDetail +{ + /** + * @brief Types Windows runs, installs or follows on top of what AssocIsDangerous reports. + */ + inline constexpr std::array windowsRunnableExtensions{ + ".jar", + ".py", + ".pyw", + ".pyz", + ".application", + ".appref-ms", + ".appx", + ".msix", + ".appxbundle", + ".msixbundle", + ".appinstaller", + ".settingcontent-ms", + }; + + /** + * @brief Types LaunchServices runs, installs or follows instead of showing them. + */ + inline constexpr std::array macRunnableExtensions{ + ".command", + ".tool", + ".terminal", + ".pkg", + ".mpkg", + ".workflow", + ".scpt", + ".applescript", + ".jar", + ".webloc", + ".inetloc", + ".fileloc", + ".prefpane", + ".saver", + ".mobileconfig", + }; + + /** + * @brief Types a Linux desktop starts as programs or installs instead of showing them. + */ + inline constexpr std::array linuxRunnableExtensions{ + ".desktop", + ".appimage", + ".run", + ".jar", + ".deb", + ".rpm", + ".flatpakref", + ".flatpakrepo", + ".snap", + }; + + /** + * @brief True when the extension of @p path, compared ignoring ASCII case, is one of @p extensions (each written + * in lower case with its dot). + */ + bool hasExtensionIn(std::filesystem::path const& path, std::span extensions); + + /** + * @brief True when owner, group or others may execute. + */ + bool hasExecuteBit(std::filesystem::perms permissions); + + /** + * @brief True when @p path starts like a program: the ELF magic or a "#!" interpreter line. + */ + bool hasExecutableContent(std::filesystem::path const& path); + + /** + * @brief The Linux classification of @p path, see Opener::riskOf. A file with an execute bit counts as a program + * only when its content is one, since mounts without permissions (FAT, NTFS, SMB) mark every file + * executable. A symlink is judged by its own name and by its target's. + */ + std::optional linuxRiskOf(std::filesystem::path const& path); +} diff --git a/backend/include/backend/program_options.hpp b/backend/include/backend/program_options.hpp index 705cfaba..4ea553c4 100644 --- a/backend/include/backend/program_options.hpp +++ b/backend/include/backend/program_options.hpp @@ -1,10 +1,27 @@ #pragma once -#include +#include +#include struct ProgramOptions { bool enableDevTools = false; }; -std::optional parseProgramOptions(int argc, char const* const* argv); \ No newline at end of file +/** + * @brief What the program prints instead of starting: the help, or a parse error followed by the help. + */ +struct ProgramOptionsExit +{ + std::string message; + /** + * @brief True for a parse error, which goes to the error stream and exits with 1. + */ + bool isError = false; +}; + +/** + * @brief The options of the command line, or what to print and exit with when the help is requested or an argument + * is not understood. + */ +std::expected parseProgramOptions(int argc, char const* const* argv); diff --git a/backend/include/backend/sftp/bulk_transfer_operation.hpp b/backend/include/backend/sftp/bulk_transfer_operation.hpp index a530dbde..33121788 100644 --- a/backend/include/backend/sftp/bulk_transfer_operation.hpp +++ b/backend/include/backend/sftp/bulk_transfer_operation.hpp @@ -230,7 +230,7 @@ class BulkTransferOperation : public Operation /** * @brief Both ends of the entry at @p entryIndex. In tree mode the entry's names are joined below the roots - * with @ref Detail::treeEntryPaths. + * with @ref BulkTransferDetail::treeEntryPaths. */ std::expected entryPaths(std::size_t entryIndex) const; @@ -404,7 +404,7 @@ class BulkTransferOperation : public Operation std::chrono::steady_clock::time_point lastEmit_{}; }; -namespace Detail +namespace BulkTransferDetail { /** * @brief Both ends of the entry @p entryIndex of a scanned tree: its names on the way from the root joined below diff --git a/backend/include/backend/sftp/download_operation.hpp b/backend/include/backend/sftp/download_operation.hpp index 3ced0e97..140f81a1 100644 --- a/backend/include/backend/sftp/download_operation.hpp +++ b/backend/include/backend/sftp/download_operation.hpp @@ -33,8 +33,8 @@ class DownloadOperation : public Operation std::string tempFileSuffix{".filepart"}; bool mayOverwrite{false}; bool reserveSpace{false}; - bool tryContinue{false}; - bool inheritPermissions{false}; + bool tryContinue{Persistence::defaultTryContinue}; + bool inheritPermissions{Persistence::defaultInheritPermissions}; bool doCleanup{true}; bool bigFileOptimized{false}; std::optional filePermissions{std::nullopt}; @@ -48,7 +48,7 @@ class DownloadOperation : public Operation */ bool createMissingDirectories{false}; /** @brief Mark the file as downloaded from the internet, see Utility::markAsDownloaded. */ - bool markAsDownloaded{true}; + bool markAsDownloaded{Persistence::defaultMarkAsDownloaded}; /** * @brief When set, a local path running through a symlink or junction below this directory is refused, so * a link planted inside a downloaded tree cannot redirect the download out of it. diff --git a/backend/include/backend/sftp/sequential_scan_progress.hpp b/backend/include/backend/sftp/sequential_scan_progress.hpp index d0298310..3685c47b 100644 --- a/backend/include/backend/sftp/sequential_scan_progress.hpp +++ b/backend/include/backend/sftp/sequential_scan_progress.hpp @@ -6,7 +6,7 @@ #include #include -namespace Detail +namespace ScanProgressDetail { using ScanProgressFunction = std::function; diff --git a/backend/include/backend/sftp/upload_operation.hpp b/backend/include/backend/sftp/upload_operation.hpp index 8ffdc1e2..91985c2e 100644 --- a/backend/include/backend/sftp/upload_operation.hpp +++ b/backend/include/backend/sftp/upload_operation.hpp @@ -30,8 +30,8 @@ class UploadOperation : public Operation Paths::LocalPath localPath{}; std::string tempFileSuffix{".filepart"}; bool mayOverwrite{false}; - bool tryContinue{false}; - bool inheritPermissions{false}; + bool tryContinue{Persistence::defaultTryContinue}; + bool inheritPermissions{Persistence::defaultInheritPermissions}; bool bigFileOptimized{false}; std::optional filePermissions{std::nullopt}; std::optional directoryPermissions{std::nullopt}; @@ -145,6 +145,11 @@ class UploadOperation : public Operation std::expected ensureRemoteDirectoryExistsInStrand(SecureShell::SftpSession& sftp, Paths::RemotePath const& dir); + /** + * @brief The mode the remote file is created with, always with owner write: the sanitized @p localPerms when + * inheriting (0444 when unknown), otherwise the configured file mode or 0660. The server's umask applies + * to it like to any new file. + */ std::filesystem::perms determinePerms(std::filesystem::perms localPerms) const; /** diff --git a/backend/source/backend/CMakeLists.txt b/backend/source/backend/CMakeLists.txt index 7d7b8500..c013189a 100644 --- a/backend/source/backend/CMakeLists.txt +++ b/backend/source/backend/CMakeLists.txt @@ -12,6 +12,7 @@ add_library( rpc_system.cpp theme_finder.cpp dropped_entry.cpp + opener_risk.cpp program_options.cpp inspector_environment.cpp password/password_prompter.cpp @@ -20,6 +21,7 @@ add_library( process/process_store.cpp process/environment.cpp file_tracking/change_filter.cpp + file_tracking/instance_bookkeeping.cpp file_tracking/instance_lock.cpp file_tracking/instance_watch.cpp file_tracking/temp_dir_instancing.cpp diff --git a/backend/source/backend/file_tracking/instance_bookkeeping.cpp b/backend/source/backend/file_tracking/instance_bookkeeping.cpp new file mode 100644 index 00000000..fd93bfe7 --- /dev/null +++ b/backend/source/backend/file_tracking/instance_bookkeeping.cpp @@ -0,0 +1,150 @@ +#include +#include +#include + +#include + +#include +#include + +#ifdef _WIN32 +# include +#endif + +namespace FileTracking +{ + namespace + { + std::filesystem::path bookkeepingDirectory(std::filesystem::path const& instanceDirectory) + { + return instanceDirectory / std::filesystem::path{bookkeepingDirectoryName}; + } + + bool fileExists(std::filesystem::path const& path) + { + std::error_code statusError; + return std::filesystem::is_regular_file(path, statusError); + } + + /** + * @brief An instance directory of a build without the bookkeeping directory: only those have their lock and + * metadata next to the content, elsewhere files of those names are tracked content. + */ + bool isLegacyInstance(std::filesystem::path const& instanceDirectory) + { + return !fileExists(bookkeepingPaths(instanceDirectory).lock) && + fileExists(legacyBookkeepingPaths(instanceDirectory).lock); + } + + std::expected readJsonFile(std::filesystem::path const& path) + { + std::ifstream input{path}; + if (!input) + return std::unexpected(fmt::format("cannot open '{}'", Paths::nativeToUtf8(path))); + auto parsed = nlohmann::json::parse(input, nullptr, false); + if (parsed.is_discarded()) + return std::unexpected(fmt::format("'{}' is not valid JSON", Paths::nativeToUtf8(path))); + return parsed; + } + + std::expected writeJsonFile(std::filesystem::path const& path, nlohmann::json const& json) + { + std::ofstream output{path, std::ios::trunc}; + output << json.dump(4); + output.close(); + if (!output) + return std::unexpected(fmt::format("cannot write '{}'", Paths::nativeToUtf8(path))); + return {}; + } + } + + BookkeepingPaths bookkeepingPaths(std::filesystem::path const& instanceDirectory) + { + const auto directory = bookkeepingDirectory(instanceDirectory); + return {.lock = directory / "instance.lock", .metadata = directory / "metadata.json"}; + } + + BookkeepingPaths legacyBookkeepingPaths(std::filesystem::path const& instanceDirectory) + { + const auto instanceId = Paths::nativeToUtf8(instanceDirectory.filename()); + return { + .lock = instanceDirectory / Paths::nativeFromUtf8(fmt::format("{}.lock", instanceId)), + .metadata = instanceDirectory / "metadata.json", + }; + } + + std::expected createBookkeepingDirectory(std::filesystem::path const& instanceDirectory) + { + const auto directory = bookkeepingDirectory(instanceDirectory); + std::error_code createError; + std::filesystem::create_directories(directory, createError); + if (createError) + return std::unexpected(fmt::format( + "cannot create '{}': {}", Paths::nativeToUtf8(directory), Utility::errorMessage(createError) + )); +#ifdef _WIN32 + if (const auto attributes = GetFileAttributesW(directory.c_str()); attributes != INVALID_FILE_ATTRIBUTES) + SetFileAttributesW(directory.c_str(), attributes | FILE_ATTRIBUTE_HIDDEN); +#endif + return {}; + } + + bool isBookkeepingPath(std::filesystem::path const& instanceDirectory, std::filesystem::path const& path) + { + const auto relative = path.lexically_relative(instanceDirectory); + return !relative.empty() && *relative.begin() == std::filesystem::path{bookkeepingDirectoryName}; + } + + std::optional existingLockFile(std::filesystem::path const& instanceDirectory) + { + if (auto lock = bookkeepingPaths(instanceDirectory).lock; fileExists(lock)) + return lock; + if (auto lock = legacyBookkeepingPaths(instanceDirectory).lock; fileExists(lock)) + return lock; + return std::nullopt; + } + + std::expected, std::string> + readMetadata(std::filesystem::path const& instanceDirectory) + { + const auto metadata = [&instanceDirectory]() -> std::optional + { + if (auto current = bookkeepingPaths(instanceDirectory).metadata; fileExists(current)) + return current; + if (auto legacy = legacyBookkeepingPaths(instanceDirectory).metadata; + fileExists(legacy) && isLegacyInstance(instanceDirectory)) + return legacy; + return std::nullopt; + }(); + if (!metadata) + return std::optional{}; + return readJsonFile(*metadata).transform( + [](nlohmann::json json) + { + return std::optional{std::move(json)}; + } + ); + } + + std::expected + writeMetadata(std::filesystem::path const& instanceDirectory, nlohmann::json const& metadata) + { + const bool mirrorLegacy = isLegacyInstance(instanceDirectory) && + fileExists(legacyBookkeepingPaths(instanceDirectory).metadata); + return createBookkeepingDirectory(instanceDirectory) + .and_then( + [&]() + { + return writeJsonFile(bookkeepingPaths(instanceDirectory).metadata, metadata); + } + ) + .and_then( + [&]() -> std::expected + { + if (!mirrorLegacy) + return {}; + return writeJsonFile(legacyBookkeepingPaths(instanceDirectory).metadata, metadata); + } + ); + } +} diff --git a/backend/source/backend/file_tracking/instance_watch.cpp b/backend/source/backend/file_tracking/instance_watch.cpp index 22848785..842069f8 100644 --- a/backend/source/backend/file_tracking/instance_watch.cpp +++ b/backend/source/backend/file_tracking/instance_watch.cpp @@ -6,7 +6,7 @@ namespace FileTracking { struct InstanceWatch::Implementation { - std::shared_ptr watcher; + std::weak_ptr watcher; efsw::WatchID watchId; std::filesystem::path path; }; @@ -14,7 +14,7 @@ namespace FileTracking InstanceWatch::InstanceWatch() = default; InstanceWatch::InstanceWatch( - std::shared_ptr watcher, + std::weak_ptr watcher, efsw::WatchID watchId, std::filesystem::path path ) @@ -31,7 +31,15 @@ namespace FileTracking } InstanceWatch::InstanceWatch(InstanceWatch&&) noexcept = default; - InstanceWatch& InstanceWatch::operator=(InstanceWatch&&) noexcept = default; + InstanceWatch& InstanceWatch::operator=(InstanceWatch&& other) noexcept + { + if (this != &other) + { + release(); + impl_ = std::move(other.impl_); + } + return *this; + } std::filesystem::path const& InstanceWatch::path() const { @@ -57,8 +65,8 @@ namespace FileTracking { if (!impl_) return; - if (impl_->watcher && impl_->watchId >= 0) - impl_->watcher->removeWatch(impl_->watchId); + if (const auto watcher = impl_->watcher.lock(); watcher && impl_->watchId >= 0) + watcher->removeWatch(impl_->watchId); impl_.reset(); } } diff --git a/backend/source/backend/file_tracking/temp_dir_instance_manager.cpp b/backend/source/backend/file_tracking/temp_dir_instance_manager.cpp index 448935bc..ad4a80d4 100644 --- a/backend/source/backend/file_tracking/temp_dir_instance_manager.cpp +++ b/backend/source/backend/file_tracking/temp_dir_instance_manager.cpp @@ -1,127 +1,74 @@ #include +#include #include #include #include #include - +#include +#include #include #include +#include #include -#include +#include #include +#include #include +#include namespace FileTracking { namespace { /** - * @brief Parse a metadata file, the stream is closed again on return. - * - * @param metaPath Path to the metadata.json file. - * @return The parsed json. + * @brief The subdirectories of @p tempRootDir, the candidates for instance directories. */ - nlohmann::json readMetadata(std::filesystem::path const& metaPath) + std::vector subdirectoriesOf(std::filesystem::path const& tempRootDir) { - std::ifstream metaIn{metaPath}; - nlohmann::json meta; - metaIn >> meta; - return meta; + std::vector directories; + std::error_code walkError; + for (std::filesystem::directory_iterator iter{tempRootDir, walkError}, end; !walkError && iter != end; + iter.increment(walkError)) + { + std::error_code typeError; + if (iter->is_directory(typeError) && !iter->is_symlink(typeError)) + directories.push_back(iter->path()); + } + return directories; } /** - * @brief Scan @p tempRootDir for dead instance directories and handle them. - * - * For each subdirectory whose name is not in @p skipIds: - * - If its lock file is held by another live process: skip. - * - Otherwise (dead): - * - If no death timestamp exists, write one now. - * - If a death timestamp exists and @c now - deadAt >= retentionHours, - * delete the directory recursively. - * - * @param tempRootDir Root directory to scan. - * @param skipIds Set of instance IDs whose directories must not be touched. - * @param retentionHours Minimum age a dead directory must reach before deletion. - */ - /** - * @brief Classify a single subdirectory as orphaned or not. - * - * A directory is considered orphaned if: - * - It is not in skipIds (not a live instance of this process), AND - * - Its lock file exists but is not held by any live process, OR its - * metadata already carries a @c deadAt timestamp. + * @brief Whether @p dirPath is the directory of an instance that no live process holds: it is not one of + * @p skipIds and has a lock file (in the bookkeeping directory or the legacy one) nobody holds. * * @param dirPath Path to the candidate subdirectory. * @param skipIds IDs of live instances that must not be touched. - * @return true if the directory is orphaned, false otherwise. */ - bool isOrphanedDir( - std::filesystem::path const& dirPath, - std::vector const& skipIds - ) + bool isOrphanedDir(std::filesystem::path const& dirPath, std::vector const& skipIds) { - namespace fs = std::filesystem; - - auto const dirName = Paths::nativeToUtf8(dirPath.filename()); - for (auto const& sid : skipIds) - if (sid == dirName) - return false; - - auto const lockPath = dirPath / (dirName + ".lock"); - if (!fs::exists(lockPath)) - return false; - - if (InstanceLock::isLockedByAnother(lockPath)) + if (std::ranges::contains(skipIds, Paths::nativeToUtf8(dirPath.filename()))) return false; - - // Lock is not held — check if deadAt is already stamped - auto const metaPath = dirPath / "metadata.json"; - if (fs::exists(metaPath)) - { - try - { - std::ifstream metaIn{metaPath}; - nlohmann::json meta; - metaIn >> meta; - if (meta.contains("deadAt") && !meta["deadAt"].is_null()) - return true; // deadAt stamp present → orphaned - } - catch (...) {} - } - - // No deadAt yet but lock is free → also orphaned (dead process, no stamp written) - return true; + const auto lock = existingLockFile(dirPath); + return lock && !InstanceLock::isLockedByAnother(*lock); } /** - * @brief Count orphaned instance directories under @p tempRootDir. - * - * @param tempRootDir Root directory to scan. - * @param skipIds IDs of live instances to skip. - * @return Number of orphaned directories found. + * @brief The orphaned instance directories under @p tempRootDir, see isOrphanedDir. */ - int countOrphanedInstanceDirs( - std::filesystem::path const& tempRootDir, - std::vector const& skipIds - ) + std::vector + orphanedInstanceDirs(std::filesystem::path const& tempRootDir, std::vector const& skipIds) { - namespace fs = std::filesystem; - - if (!fs::exists(tempRootDir)) - return 0; - - int count = 0; - std::error_code iterErr; - for (auto const& entry : fs::directory_iterator(tempRootDir, iterErr)) - { - if (!entry.is_directory()) - continue; - if (isOrphanedDir(entry.path(), skipIds)) - ++count; - } - return count; + auto directories = subdirectoriesOf(tempRootDir); + std::erase_if( + directories, + [&skipIds](std::filesystem::path const& directory) + { + return !isOrphanedDir(directory, skipIds); + } + ); + return directories; } /** @@ -137,21 +84,9 @@ namespace FileTracking std::vector const& skipIds ) { - namespace fs = std::filesystem; - - if (!fs::exists(tempRootDir)) - return 0; - int removed = 0; - std::error_code iterErr; - for (auto const& entry : fs::directory_iterator(tempRootDir, iterErr)) + for (auto const& dirPath : orphanedInstanceDirs(tempRootDir, skipIds)) { - if (!entry.is_directory()) - continue; - auto const& dirPath = entry.path(); - if (!isOrphanedDir(dirPath, skipIds)) - continue; - Log::info( "forceCleanOrphanedInstanceDirs: removing orphaned instance '{}'", Paths::nativeToUtf8(dirPath.filename()) @@ -170,109 +105,61 @@ namespace FileTracking return removed; } + /** + * @brief The death timestamp of an orphaned instance, nothing when it has none or its metadata is unreadable. + */ + std::optional deadAtOf(std::optional const& meta) + { + if (!meta || !meta->contains("deadAt") || !(*meta)["deadAt"].is_number_integer()) + return std::nullopt; + return std::chrono::system_clock::from_time_t( + static_cast((*meta)["deadAt"].get()) + ); + } + + /** + * @brief Scan @p tempRootDir for dead instance directories and handle them. + * + * For each orphaned directory (see isOrphanedDir): + * - If no death timestamp exists, write one now. + * - If a death timestamp exists and @c now - deadAt >= retentionHours, + * delete the directory recursively. + * + * @param tempRootDir Root directory to scan. + * @param skipIds Set of instance IDs whose directories must not be touched. + * @param retentionHours Minimum age a dead directory must reach before deletion. + */ void scanAndCleanDeadInstanceDirs( std::filesystem::path const& tempRootDir, std::vector const& skipIds, std::chrono::hours retentionHours ) { - namespace fs = std::filesystem; - - if (!fs::exists(tempRootDir)) - return; - - auto const now = std::chrono::system_clock::now(); - - std::error_code iterErr; - for (auto const& entry : fs::directory_iterator(tempRootDir, iterErr)) + const auto now = std::chrono::system_clock::now(); + for (auto const& dirPath : orphanedInstanceDirs(tempRootDir, skipIds)) { - if (!entry.is_directory()) - continue; + const auto dirName = Paths::nativeToUtf8(dirPath.filename()); + const auto meta = readMetadata(dirPath); + if (!meta) + Log::warn("scanAndCleanDeadInstanceDirs: error reading metadata for '{}': {}", dirName, meta.error()); - auto const& dirPath = entry.path(); - auto const dirName = Paths::nativeToUtf8(dirPath.filename()); - - bool skip = false; - for (auto const& sid : skipIds) - { - if (sid == dirName) - { - skip = true; - break; - } - } - if (skip) - continue; - - auto const lockPath = dirPath / (dirName + ".lock"); - if (!fs::exists(lockPath)) - continue; - - if (InstanceLock::isLockedByAnother(lockPath)) - continue; // still alive, held by another process - - // ---- Dead instance ---- - auto const metaPath = dirPath / "metadata.json"; - - auto writeDeadAt = [&]() + const auto deadAt = deadAtOf(meta.value_or(std::nullopt)); + if (!deadAt) { - try - { - nlohmann::json meta; - if (fs::exists(metaPath)) - { - std::ifstream metaIn{metaPath}; - metaIn >> meta; - } - if (!meta.contains("instanceId")) - meta["instanceId"] = dirName; - meta["deadAt"] = - std::chrono::system_clock::to_time_t(now); - std::ofstream metaOut{metaPath}; - metaOut << meta.dump(4); - } - catch (std::exception const& exc) - { + auto stamped = meta.value_or(std::nullopt).value_or(nlohmann::json::object()); + if (!stamped.is_object()) + stamped = nlohmann::json::object(); + if (!stamped.contains("instanceId")) + stamped["instanceId"] = dirName; + stamped["deadAt"] = std::chrono::system_clock::to_time_t(now); + if (const auto written = writeMetadata(dirPath, stamped); !written) Log::warn( - "scanAndCleanDeadInstanceDirs: could not write deadAt for '{}': {}", - dirName, - Utility::errorMessage(exc) + "scanAndCleanDeadInstanceDirs: could not write deadAt for '{}': {}", dirName, written.error() ); - } - }; - - if (!fs::exists(metaPath)) - { - writeDeadAt(); - continue; - } - - // The metadata stream must be closed before remove_all, Windows refuses to delete open files. - std::optional deadAt; - try - { - auto const meta = readMetadata(metaPath); - if (meta.contains("deadAt") && !meta["deadAt"].is_null()) - deadAt = std::chrono::system_clock::from_time_t( - static_cast(meta["deadAt"].get()) - ); - } - catch (std::exception const& exc) - { - Log::warn( - "scanAndCleanDeadInstanceDirs: error reading metadata for '{}': {}", - dirName, - Utility::errorMessage(exc) - ); - } - - if (!deadAt) - { - writeDeadAt(); continue; } - auto const elapsed = std::chrono::duration_cast(now - *deadAt); + const auto elapsed = std::chrono::duration_cast(now - *deadAt); if (elapsed < retentionHours) continue; @@ -285,6 +172,100 @@ namespace FileTracking } } // namespace + namespace InstanceManagerDetail + { + nlohmann::json fileStatesOf(std::filesystem::path const& instanceDirectory) + { + nlohmann::json files = nlohmann::json::array(); + std::error_code walkError{}; + for (std::filesystem::recursive_directory_iterator iter{instanceDirectory, walkError}, end; + !walkError && iter != end; + iter.increment(walkError)) + { + std::error_code entryError{}; + if (iter->is_symlink(entryError)) + continue; + if (iter.depth() == 0 && iter->path().filename() == std::filesystem::path{bookkeepingDirectoryName}) + { + iter.disable_recursion_pending(); + continue; + } + // Canonical like the watched directory, so the paths match the change events. + const auto path = Paths::nativeToUtf8(std::filesystem::weakly_canonical(iter->path(), entryError)); + if (iter->is_directory(entryError)) + { + files.push_back({{"path", path}, {"directory", true}}); + continue; + } + if (!iter->is_regular_file(entryError)) + continue; + const auto size = iter->file_size(entryError); + const auto modified = iter->last_write_time(entryError); + if (entryError) + continue; + const auto seconds = std::chrono::duration_cast( + std::chrono::file_clock::to_sys(modified).time_since_epoch() + ); + files.push_back({ + {"path", path}, + {"size", size}, + {"mtime", seconds.count()}, + }); + } + if (walkError) + Log::warn( + "FileTracking::fileStates: walking '{}' failed: {}", + Paths::nativeToUtf8(instanceDirectory), + Utility::errorMessage(walkError) + ); + return files; + } + + std::expected + removableContentPath(std::filesystem::path const& instanceDirectory, std::filesystem::path const& path) + { + const auto refused = [&path](std::string_view reason) + { + return std::unexpected{fmt::format("Cannot discard '{}': {}", Paths::nativeToUtf8(path), reason)}; + }; + const auto name = path.filename(); + if (name.empty() || name == "." || name == "..") + return refused("it names no file"); + + std::error_code canonicalError; + const auto directory = std::filesystem::weakly_canonical(instanceDirectory, canonicalError); + if (canonicalError) + return refused(Utility::errorMessage(canonicalError)); + const auto parent = std::filesystem::weakly_canonical(path.parent_path(), canonicalError); + if (canonicalError) + return refused(Utility::errorMessage(canonicalError)); + + const auto content = parent / name; + const auto relative = content.lexically_relative(directory); + if (relative.empty() || relative == "." || *relative.begin() == "..") + return refused("it is not in the instance directory"); + if (*relative.begin() == std::filesystem::path{bookkeepingDirectoryName}) + return refused("it belongs to the bookkeeping of the instance"); + return content; + } + + bool hasContent(std::filesystem::path const& instanceDirectory) + { + std::error_code walkError; + std::filesystem::directory_iterator iter{instanceDirectory, walkError}; + if (walkError) + return true; + return std::any_of( + iter, + std::filesystem::directory_iterator{}, + [](std::filesystem::directory_entry const& entry) + { + return entry.path().filename() != std::filesystem::path{bookkeepingDirectoryName}; + } + ); + } + } + // ------------------------------------------------------------------------- // TempDirInstanceManager // ------------------------------------------------------------------------- @@ -314,10 +295,13 @@ namespace FileTracking reply.error("Failed to create instance"); return; } + // Canonical like the paths of the change events and of fileStates, which the frontend maps. + std::error_code canonicalError; + const auto canonical = std::filesystem::weakly_canonical(inst->instanceDir(), canonicalError); reply({ {"success", true}, {"instanceId", inst->instanceId()}, - {"instanceDir", Paths::nativeToUtf8(inst->instanceDir())}, + {"instanceDir", Paths::nativeToUtf8(canonicalError ? inst->instanceDir() : canonical)}, }); } ); @@ -326,14 +310,12 @@ namespace FileTracking "FileTracking::destroyInstance", [this](RpcHelper::RpcOnce&& reply, std::string instanceId) { - auto iter = instances_.find(instanceId); - if (iter == instances_.end()) + if (!findInstance(instanceId)) { - reply.error("Instance not found: " + instanceId); + reply.error(fmt::format("Instance not found: {}", instanceId)); return; } - watches_.erase(instanceId); - instances_.erase(iter); + destroyInstance(instanceId); reply({{"success", true}}); } ); @@ -351,7 +333,7 @@ namespace FileTracking auto* inst = findInstance(instanceId); if (!inst) { - reply.error("Instance not found: " + instanceId); + reply.error(fmt::format("Instance not found: {}", instanceId)); return; } const auto watchedPath = Paths::LocalPath::fromUtf8(path); @@ -363,7 +345,7 @@ namespace FileTracking auto watch = inst->addWatch(watchedPath->native(), recursive, std::move(downloadTempSuffix)); if (!watch) { - reply.error("Failed to add watch on path: " + path); + reply.error(fmt::format("Failed to add watch on path: {}", path)); return; } watches_[instanceId].push_back(std::move(*watch)); @@ -394,7 +376,7 @@ namespace FileTracking auto* inst = findInstance(instanceId); if (!inst) { - reply.error("Instance not found: " + instanceId); + reply.error(fmt::format("Instance not found: {}", instanceId)); return; } reply({ @@ -405,9 +387,6 @@ namespace FileTracking } ); - // Size and modification time of every file in an instance, taken right after the download: downloads keep - // the server's modification time, so this is what the server had. File tracking compares against it - // before replacing the server's copy. registerOnStrand( "FileTracking::fileStates", [this](RpcHelper::RpcOnce&& reply, std::string instanceId) @@ -415,48 +394,31 @@ namespace FileTracking auto* inst = findInstance(instanceId); if (!inst) { - reply.error("Instance not found: " + instanceId); + reply.error(fmt::format("Instance not found: {}", instanceId)); return; } - nlohmann::json files = nlohmann::json::array(); - std::error_code walkError{}; - for (std::filesystem::recursive_directory_iterator iter{inst->instanceDir(), walkError}, end; - !walkError && iter != end; - iter.increment(walkError)) - { - std::error_code entryError{}; - if (!iter->is_regular_file(entryError) || iter->is_symlink(entryError)) - continue; - const auto size = iter->file_size(entryError); - const auto modified = iter->last_write_time(entryError); - if (entryError) - continue; - const auto seconds = std::chrono::duration_cast( - std::chrono::file_clock::to_sys(modified).time_since_epoch() - ); - // Canonical like the watched directory, so the paths match the change events. - files.push_back({ - {"path", Paths::nativeToUtf8(std::filesystem::weakly_canonical(iter->path(), entryError))}, - {"size", size}, - {"mtime", seconds.count()}, - }); - } - if (walkError) - Log::warn( - "FileTracking::fileStates: walking '{}' failed: {}", - instanceId, - Utility::errorMessage(walkError) - ); - reply({{"success", true}, {"files", std::move(files)}}); + reply({{"success", true}, {"files", InstanceManagerDetail::fileStatesOf(inst->instanceDir())}}); } ); registerOnStrand( - "FileTracking::manualCleanup", - [this](RpcHelper::RpcOnce&& reply) + "FileTracking::discardContent", + [this](RpcHelper::RpcOnce&& reply, std::string instanceId, std::string path) { - manualCleanup(); - reply({{"success", true}}); + const auto content = Paths::LocalPath::fromUtf8(path); + if (!content) + { + reply.error(fmt::format("Cannot discard '{}': {}", path, content.error().toString())); + return; + } + const auto outcome = discardContent(instanceId, content->native()); + if (!outcome) + { + Log::error("FileTracking::discardContent: {}", outcome.error()); + reply.error(outcome.error()); + return; + } + reply({{"success", true}, {"instanceDestroyed", *outcome == DiscardOutcome::InstanceDestroyed}}); } ); @@ -464,7 +426,7 @@ namespace FileTracking "FileTracking::countOrphaned", [this](RpcHelper::RpcOnce&& reply) { - const int count = countOrphanedInstanceDirs(tempRootDir_, listInstanceIds()); + const auto count = orphanedInstanceDirs(tempRootDir_, listInstanceIds()).size(); reply({{"success", true}, {"count", count}}); } ); @@ -479,6 +441,17 @@ namespace FileTracking ); } + void TempDirInstanceManager::scheduleStartupCleanup() + { + boost::asio::post( + *strand_, + [this]() + { + manualCleanup(); + } + ); + } + TemporaryDirectoryInstance* TempDirInstanceManager::createInstance() { try @@ -502,9 +475,36 @@ namespace FileTracking void TempDirInstanceManager::destroyInstance(std::string const& instanceId) { + watches_.erase(instanceId); instances_.erase(instanceId); } + std::expected + TempDirInstanceManager::discardContent(std::string const& instanceId, std::filesystem::path const& path) + { + auto* instance = findInstance(instanceId); + if (!instance) + return std::unexpected{fmt::format("Instance not found: {}", instanceId)}; + const auto content = InstanceManagerDetail::removableContentPath(instance->instanceDir(), path); + if (!content) + return std::unexpected{content.error()}; + + watches_.erase(instanceId); + Log::info("Discarding '{}' of instance '{}'", Paths::nativeToUtf8(*content), instanceId); + std::error_code removeError; + Utility::removeAllNoFollow(*content, removeError); + if (removeError) + return std::unexpected{fmt::format( + "Cannot remove '{}': {}", Paths::nativeToUtf8(*content), Utility::errorMessage(removeError) + )}; + + if (InstanceManagerDetail::hasContent(instance->instanceDir())) + return DiscardOutcome::InstanceKept; + instance->cleanupNow(); + destroyInstance(instanceId); + return DiscardOutcome::InstanceDestroyed; + } + TemporaryDirectoryInstance* TempDirInstanceManager::findInstance(std::string const& instanceId) const { diff --git a/backend/source/backend/file_tracking/temp_dir_instancing.cpp b/backend/source/backend/file_tracking/temp_dir_instancing.cpp index a29c0088..57f80ed3 100644 --- a/backend/source/backend/file_tracking/temp_dir_instancing.cpp +++ b/backend/source/backend/file_tracking/temp_dir_instancing.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -14,7 +15,6 @@ #include #include -#include #include #include #include @@ -24,10 +24,7 @@ namespace FileTracking namespace { /** - * @brief Prepare the instance directory. - * - * Creates all required parent directories as a side effect so that the - * result can be used immediately to construct an InstanceLock inside it. + * @brief Creates the instance directory with its bookkeeping directory, so the lock can be created in it. * * @param dir Path to create. * @return The same path that was passed in. @@ -35,12 +32,8 @@ namespace FileTracking */ std::filesystem::path prepareInstanceDir(std::filesystem::path const& dir) { - std::error_code err; - std::filesystem::create_directories(dir, err); - if (err) - throw std::runtime_error{ - fmt::format("Failed to create instance directory: {}", Utility::errorMessage(err)) - }; + if (const auto created = createBookkeepingDirectory(dir); !created) + throw std::runtime_error{fmt::format("Failed to create instance directory: {}", created.error())}; return dir; } @@ -62,23 +55,26 @@ namespace FileTracking } } // namespace - // ------------------------------------------------------------------------- - // FileChangeListener — efsw callback, runs on efsw's internal thread - // ------------------------------------------------------------------------- + /** + * @brief Receives the efsw events of one instance on efsw's thread and forwards them to the frontend. + */ class FileChangeListener : public efsw::FileWatchListener { public: + /** + * @param watchedInstanceDirectory The instance directory spelled like the watch reports it (canonical). + */ FileChangeListener( - std::string instanceId, + std::string const& instanceId, + std::filesystem::path watchedInstanceDirectory, boost::asio::strand strand, Nui::RpcHub* hub, std::string downloadTempSuffix ) - : instanceId_{std::move(instanceId)} + : watchedInstanceDirectory_{std::move(watchedInstanceDirectory)} , strand_{std::move(strand)} , hub_{hub} - , lockFileName_{instanceId_ + ".lock"} - , eventName_{"FileTracking::" + instanceId_ + "::onFileChanged"} + , eventName_{fmt::format("FileTracking::{}::onFileChanged", instanceId)} , downloadTempSuffix_{Persistence::effectiveTempFileSuffix(downloadTempSuffix)} {} @@ -90,14 +86,13 @@ namespace FileTracking std::string oldFilename ) override { - if (filename == lockFileName_ || filename == "metadata.json") + // efsw reports UTF-8 on every platform. + const auto changedPath = Paths::nativeFromUtf8(dir) / Paths::nativeFromUtf8(filename); + if (isBookkeepingPath(watchedInstanceDirectory_, changedPath)) return; std::error_code statusError; - // efsw reports UTF-8 on every platform. - const bool isDirectory = std::filesystem::is_directory( - Paths::nativeFromUtf8(dir) / Paths::nativeFromUtf8(filename), statusError - ); + const bool isDirectory = std::filesystem::is_directory(changedPath, statusError); const auto change = filterFileChange( {toFileAction(action), filename, std::move(oldFilename)}, isDirectory, downloadTempSuffix_ ); @@ -109,6 +104,7 @@ namespace FileTracking {"directory", Paths::nativeToUtf8(Paths::nativeFromUtf8(dir))}, {"filename", Paths::nativeToUtf8(Paths::nativeFromUtf8(change->filename))}, {"oldFilename", Paths::nativeToUtf8(Paths::nativeFromUtf8(change->oldFilename))}, + {"isDirectory", isDirectory}, }; boost::asio::post( @@ -121,10 +117,9 @@ namespace FileTracking } private: - std::string instanceId_; + std::filesystem::path watchedInstanceDirectory_; boost::asio::strand strand_; Nui::RpcHub* hub_; - std::string lockFileName_; std::string eventName_; std::string downloadTempSuffix_; }; @@ -159,7 +154,7 @@ namespace FileTracking : config{std::move(cfg)} , instanceId{std::move(instId)} , instanceDir{prepareInstanceDir(config.tempRootDir / instanceId)} - , lock{std::in_place, instanceDir / (instanceId + ".lock")} + , lock{std::in_place, bookkeepingPaths(instanceDir).lock} , strand{std::move(strd)} , hub{hubPtr} , watcher{std::make_shared()} @@ -241,7 +236,11 @@ namespace FileTracking } impl_->listener = std::make_unique( - impl_->instanceId, impl_->strand, impl_->hub, std::move(downloadTempSuffix) + impl_->instanceId, + fs::weakly_canonical(impl_->instanceDir), + impl_->strand, + impl_->hub, + std::move(downloadTempSuffix) ); // efsw takes UTF-8, a narrow string is the ANSI code page on Windows. @@ -286,36 +285,28 @@ namespace FileTracking void TemporaryDirectoryInstance::writeMetadata() { - auto const metaPath = impl_->instanceDir / "metadata.json"; - nlohmann::json meta = { + const nlohmann::json meta = { {"instanceId", impl_->instanceId}, {"createdAt", std::chrono::system_clock::to_time_t(std::chrono::system_clock::now())}, {"deadAt", nullptr}, }; - std::ofstream out{metaPath}; - out << meta.dump(4); + if (const auto written = FileTracking::writeMetadata(impl_->instanceDir, meta); !written) + Log::error("writeMetadata: failed for instance '{}': {}", impl_->instanceId, written.error()); } void TemporaryDirectoryInstance::writeDeadTimestamp() { - auto const metaPath = impl_->instanceDir / "metadata.json"; - try - { - nlohmann::json meta; - if (std::filesystem::exists(metaPath)) - { - std::ifstream metaIn{metaPath}; - metaIn >> meta; - } - meta["deadAt"] = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now()); - std::ofstream out{metaPath}; - out << meta.dump(4); - } - catch (std::exception const& exc) - { - Log::error( - "writeDeadTimestamp: failed for instance '{}': {}", impl_->instanceId, Utility::errorMessage(exc) - ); - } + const auto written = + readMetadata(impl_->instanceDir) + .and_then( + [this](std::optional meta) + { + auto updated = std::move(meta).value_or(nlohmann::json{{"instanceId", impl_->instanceId}}); + updated["deadAt"] = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now()); + return FileTracking::writeMetadata(impl_->instanceDir, updated); + } + ); + if (!written) + Log::error("writeDeadTimestamp: failed for instance '{}': {}", impl_->instanceId, written.error()); } } diff --git a/backend/source/backend/linux/opener_linux.cpp b/backend/source/backend/linux/opener_linux.cpp index f33be552..35bfa943 100644 --- a/backend/source/backend/linux/opener_linux.cpp +++ b/backend/source/backend/linux/opener_linux.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -33,10 +34,6 @@ #include #include -#include -#include -#include -#include #include namespace @@ -411,20 +408,7 @@ Opener& Opener::operator=(Opener&&) noexcept = default; std::optional Opener::riskOf(std::filesystem::path const& path) const { - std::error_code statusError{}; - const auto status = std::filesystem::status(path, statusError); - if (statusError || !std::filesystem::is_regular_file(status)) - return std::nullopt; - using std::filesystem::perms; - if ((status.permissions() & (perms::owner_exec | perms::group_exec | perms::others_exec)) != perms::none) - return std::string{"backend.opener.risk.program"}; - // Types a desktop starts as programs instead of showing them. - static constexpr std::string_view runnable[] = {".desktop", ".appimage", ".run", ".jar"}; - auto extension = Paths::nativeToUtf8(path.extension()); - std::ranges::transform(extension, extension.begin(), [](unsigned char character) { return std::tolower(character); }); - if (std::ranges::find(runnable, std::string_view{extension}) != std::end(runnable)) - return std::string{"backend.opener.risk.dangerousType"}; - return std::nullopt; + return OpenerDetail::linuxRiskOf(path); } std::expected Opener::openFile(std::filesystem::path const& path, bool openWith) diff --git a/backend/source/backend/mac/opener_mac.mm b/backend/source/backend/mac/opener_mac.mm index b6d44451..974c4849 100644 --- a/backend/source/backend/mac/opener_mac.mm +++ b/backend/source/backend/mac/opener_mac.mm @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -9,15 +10,22 @@ #include #include -#include -#include -#include -#include #include #include namespace { + /** + * @brief @p path with every symlink resolved, so a link is judged and opened as what it points to. @p path itself + * when it cannot be resolved. + */ + std::filesystem::path resolvedPath(std::filesystem::path const& path) + { + std::error_code resolveError{}; + const auto resolved = std::filesystem::canonical(path, resolveError); + return resolveError ? path : resolved; + } + NSURL* fileUrl(std::filesystem::path const& path, bool isDirectory) { NSString* const pathString = [NSString stringWithUTF8String:path.c_str()]; @@ -75,17 +83,12 @@ void chooseApplicationAndOpen(NSURL* url) std::optional Opener::riskOf(std::filesystem::path const& path) const { + const auto resolved = resolvedPath(path); std::error_code statusError{}; - const bool isDirectory = std::filesystem::is_directory(path, statusError); - if (isExecutable(path, isDirectory)) + const bool isDirectory = std::filesystem::is_directory(resolved, statusError); + if (isExecutable(resolved, isDirectory)) return std::string{isDirectory ? "backend.opener.risk.application" : "backend.opener.risk.program"}; - // Types LaunchServices runs or installs instead of showing. - static constexpr std::string_view runnable[] = { - ".command", ".tool", ".terminal", ".pkg", ".mpkg", ".workflow", ".scpt", ".applescript", ".jar", ".webloc", - }; - auto extension = Paths::nativeToUtf8(path.extension()); - std::ranges::transform(extension, extension.begin(), [](unsigned char character) { return std::tolower(character); }); - if (std::ranges::find(runnable, std::string_view{extension}) != std::end(runnable)) + if (OpenerDetail::hasExtensionIn(resolved, OpenerDetail::macRunnableExtensions)) return std::string{"backend.opener.risk.dangerousType"}; return std::nullopt; } @@ -111,12 +114,14 @@ void chooseApplicationAndOpen(NSURL* url) if (!path.is_absolute() || !std::filesystem::exists(path, existsError)) return std::unexpected{std::string{"backend.opener.mac.fileNotFound"}}; - // Programs, scripts and bundles are judged by riskOf and the user's policy before this is called. - const bool isDirectory = std::filesystem::is_directory(path, existsError); + // Programs, scripts and bundles are judged by riskOf and the user's policy before this is called, on the same + // resolved path. + const auto resolved = resolvedPath(path); + const bool isDirectory = std::filesystem::is_directory(resolved, existsError); @autoreleasepool { - NSURL* const url = fileUrl(path, isDirectory); + NSURL* const url = fileUrl(resolved, isDirectory); if (openWith) { chooseApplicationAndOpen(url); diff --git a/backend/source/backend/main.cpp b/backend/source/backend/main.cpp index 5b34c19e..1d1b6c14 100644 --- a/backend/source/backend/main.cpp +++ b/backend/source/backend/main.cpp @@ -160,7 +160,7 @@ namespace Log::debug("Serving file: '{}'", Paths::nativeToUtf8(file)); - // Stream the file body via bodyFile — avoids loading the whole file into memory + // Stream the file body via bodyFile, which avoids loading the whole file into memory // (matters for large assets like DWARF blobs). Empty files yield a 204. std::error_code sizeEc; const auto fileSize = std::filesystem::file_size(file, sizeEc); @@ -216,6 +216,7 @@ Main::Main(ProgramOptions options, ForkPool* forkPool) } , hub_{window_} , opener_{} + , riskyFilePolicy_{Persistence::RiskyFilePolicy::Warn} , rpcFilesystem_{} , rpcSystem_{} , processes_{window_.getExecutor(), window_, hub_, forkPool} @@ -286,6 +287,18 @@ void Main::registerRpc() initialPersistenceLoadWarnings_ = warnings; } + const auto riskyFilePolicyOf = [](Persistence::State const& state) + { + return state.localFilesystemOptions.openRiskyFiles.value_or(Persistence::RiskyFilePolicy::Warn); + }; + riskyFilePolicy_.store(riskyFilePolicyOf(holder.stateCache())); + stateHolder_.onSaved( + [this, riskyFilePolicyOf](Persistence::State const& state) + { + riskyFilePolicy_.store(riskyFilePolicyOf(state)); + } + ); + opener_ = std::make_unique(window_.getNativeWindow()); rpcSystem_ = std::make_unique(window_.getExecutor(), window_, hub_); tempDirInstanceManager_ = std::make_unique( @@ -297,6 +310,7 @@ void Main::registerRpc() )) ); tempDirInstanceManager_->registerRpc(); + tempDirInstanceManager_->scheduleStartupCleanup(); if (error) { @@ -328,9 +342,7 @@ void Main::registerRpc() *opener_, [this]() { - return stateHolder_.stateCache().localFilesystemOptions.openRiskyFiles.value_or( - Persistence::RiskyFilePolicy::Warn - ); + return riskyFilePolicy_.load(); } ); @@ -456,21 +468,26 @@ int main(int const argc, char const* const* argv) // Reading the umask changes it for a moment on macOS, so it is read before any thread creates files. static_cast(Utility::processUmask()); #ifdef _WIN32 - // A GUI program has no console; help and --console write to the one it was started from. + // A GUI program has no console; --console, the help and parse errors write to the one it was started from. const auto arguments = std::span{argv, static_cast(argc)} | std::views::drop(1); - const auto console = std::ranges::any_of( - arguments, - [](std::string_view argument) - { - return argument == "--help" || argument == "--console"; - } - ) - ? attachParentConsole() - : ConsoleAttachment{}; + const bool consoleRequested = std::ranges::any_of( + arguments, + [](std::string_view argument) + { + return argument == "--console"; + } + ); + const auto console = consoleRequested ? attachParentConsole() : ConsoleAttachment{}; #endif auto options = parseProgramOptions(argc, argv); if (!options) - return 0; + { +#ifdef _WIN32 + const auto exitConsole = consoleRequested ? ConsoleAttachment{} : attachParentConsole(); +#endif + (options.error().isError ? std::cerr : std::cout) << options.error().message << '\n'; + return options.error().isError ? 1 : 0; + } if (!options->enableDevTools) removeRemoteInspectorEnvironment(); diff --git a/backend/source/backend/opener_risk.cpp b/backend/source/backend/opener_risk.cpp new file mode 100644 index 00000000..853ab80f --- /dev/null +++ b/backend/source/backend/opener_risk.cpp @@ -0,0 +1,63 @@ +#include +#include + +#include +#include +#include +#include + +namespace OpenerDetail +{ + bool hasExtensionIn(std::filesystem::path const& path, std::span extensions) + { + const auto extension = [&path]() + { + auto text = Paths::nativeToUtf8(path.extension()); + std::ranges::transform( + text, + text.begin(), + [](char character) + { + return character >= 'A' && character <= 'Z' ? static_cast(character - 'A' + 'a') : character; + } + ); + return text; + }(); + if (extension.empty()) + return false; + return std::ranges::find(extensions, std::string_view{extension}) != extensions.end(); + } + + bool hasExecuteBit(std::filesystem::perms permissions) + { + using std::filesystem::perms; + return (permissions & (perms::owner_exec | perms::group_exec | perms::others_exec)) != perms::none; + } + + bool hasExecutableContent(std::filesystem::path const& path) + { + std::ifstream reader{path, std::ios::binary}; + std::array start{}; + reader.read(start.data(), start.size()); + const auto length = reader.gcount(); + const bool isElf = length == 4 && start[0] == '\x7f' && start[1] == 'E' && start[2] == 'L' && start[3] == 'F'; + const bool isScript = length >= 2 && start[0] == '#' && start[1] == '!'; + return isElf || isScript; + } + + std::optional linuxRiskOf(std::filesystem::path const& path) + { + std::error_code statusError{}; + const auto status = std::filesystem::status(path, statusError); + if (statusError || !std::filesystem::is_regular_file(status)) + return std::nullopt; + if (hasExecuteBit(status.permissions()) && hasExecutableContent(path)) + return std::string{"backend.opener.risk.program"}; + std::error_code resolveError{}; + const auto target = std::filesystem::canonical(path, resolveError); + if (hasExtensionIn(path, linuxRunnableExtensions) || + (!resolveError && hasExtensionIn(target, linuxRunnableExtensions))) + return std::string{"backend.opener.risk.dangerousType"}; + return std::nullopt; + } +} diff --git a/backend/source/backend/process/process.cpp b/backend/source/backend/process/process.cpp index 1dd5439b..cd575b3e 100644 --- a/backend/source/backend/process/process.cpp +++ b/backend/source/backend/process/process.cpp @@ -140,6 +140,39 @@ struct Process::Implementation { return child && child->running(); } + + /** + * @brief Starts @p executable with its standard streams on the pipes of this process. + */ + std::unique_ptr launchWithPipes( + boost::filesystem::path const& executable, + std::vector const& arguments, + std::unordered_map const& environment + ) + { + bp2::default_process_launcher systemLauncher{}; +#ifdef _WIN32 + // The app is a GUI program without a console, a console child would open a window of its own. + systemLauncher.creation_flags |= CREATE_NO_WINDOW; + const auto launchArguments = arguments | + std::views::transform( + [](std::string const& argument) + { + return Paths::utf8ToWide(argument); + } + ) | + std::ranges::to(); +#else + auto const& launchArguments = arguments; +#endif + return std::make_unique(systemLauncher( + executor, + executable, + launchArguments, + bp2::process_environment{environment}, + bp2::process_stdio(stdinPipe, stdoutPipe, stderrPipe) + )); + } }; Process::Process(boost::asio::any_io_executor executor, std::function onExit) @@ -317,63 +350,34 @@ void Process::spawn( } const boost::filesystem::path name{Paths::nativeFromUtf8(processName).native()}; - auto executable = bp2::environment::find_executable(name, env); - if (executable.empty()) - executable = name; + const auto executable = [&name, &env]() + { + auto found = bp2::environment::find_executable(name, env); + if (found.empty()) + found = name; #ifdef _WIN32 - // cmd.exe reads its own path as given: in "C:/Windows/System32/cmd.exe" it takes "/c" as a switch and runs - // "md.exe" instead of starting a shell. - executable.make_preferred(); + // cmd.exe takes a '/' in its own path on the command line for the start of a switch. + found.make_preferred(); #endif + return found; + }(); { std::scoped_lock lock{impl_->awaitExit}; impl_->exited = false; } - if (!launcher) - { -#ifdef _WIN32 - // The app is a GUI program without a console, a console child would open a window of its own. - bp2::windows::default_launcher windowlessLauncher{}; - windowlessLauncher.creation_flags |= CREATE_NO_WINDOW; - const auto wideArguments = arguments | - std::views::transform( - [](std::string const& argument) - { - return Paths::utf8ToWide(argument); - } - ) | - std::ranges::to(); - impl_->child = std::make_unique(windowlessLauncher( - impl_->executor, - executable, - wideArguments, - bp2::process_environment{env}, - bp2::process_stdio(impl_->stdinPipe, impl_->stdoutPipe, impl_->stderrPipe) - )); -#else - impl_->child = std::make_unique( - impl_->executor, - executable, - arguments, - bp2::process_environment{env}, - bp2::process_stdio(impl_->stdinPipe, impl_->stdoutPipe, impl_->stderrPipe) - ); -#endif - } - else - { - impl_->child = launcher(impl_->executor, executable, arguments, env); - } + impl_->child = launcher ? launcher(impl_->executor, executable, arguments, env) + : impl_->launchWithPipes(executable, arguments, env); #ifdef _WIN32 if (impl_->child->running()) { impl_->child->async_wait( - [self = shared_from_this()](auto ec, auto code) + [weak = weak_from_this()](auto ec, auto code) { - if (ec) + auto self = weak.lock(); + if (ec || !self) return; self->impl_->exitCode = code; diff --git a/backend/source/backend/process/process_store.cpp b/backend/source/backend/process/process_store.cpp index 34b89523..c388a1f6 100644 --- a/backend/source/backend/process/process_store.cpp +++ b/backend/source/backend/process/process_store.cpp @@ -9,6 +9,8 @@ #include #include +#include + #ifdef _WIN32 # include #else @@ -202,7 +204,9 @@ void ProcessStore::handleWorkerMessage(nlohmann::json const& msg) wnd_->runInJavascriptThread( [this, procId]() { - RpcHelper::callRemoteSafely(*hub_, "execTerminalExit_" + procId, nlohmann::json{{"id", procId}}); + RpcHelper::callRemoteSafely( + *hub_, fmt::format("execTerminalExit_{}", procId), nlohmann::json{{"id", procId}} + ); } ); } @@ -272,7 +276,7 @@ void ProcessStore::notifyChildExit(Nui::RpcHub& hub, std::string const& id) process->second->exit(); processes_.erase(process); - RpcHelper::callRemoteSafely(hub, "execTerminalExit_" + id, nlohmann::json{{"id", id}}); + RpcHelper::callRemoteSafely(hub, fmt::format("execTerminalExit_{}", id), nlohmann::json{{"id", id}}); } void ProcessStore::notifyChildExit(Nui::RpcHub& hub, long long pid) @@ -285,7 +289,7 @@ void ProcessStore::notifyChildExit(Nui::RpcHub& hub, long long pid) const auto idCopy = id; processes_.erase(id); RpcHelper::callRemoteSafely( - hub, "execTerminalExit_" + idCopy, nlohmann::json{{"id", idCopy}, {"pid", pid}} + hub, fmt::format("execTerminalExit_{}", idCopy), nlohmann::json{{"id", idCopy}, {"pid", pid}} ); return; } diff --git a/backend/source/backend/program_options.cpp b/backend/source/backend/program_options.cpp index 3e1cc18f..be302cfc 100644 --- a/backend/source/backend/program_options.cpp +++ b/backend/source/backend/program_options.cpp @@ -2,40 +2,46 @@ #include #include +#include -#include +#include -std::optional parseProgramOptions(int argc, char const* const* argv) +std::expected parseProgramOptions(int argc, char const* const* argv) { - try + boost::program_options::options_description description("Allowed options"); + description.add_options()("help", "produce help message")( + "enable-dev-tools", "Enable developer tools (DevTools in Browser)" + )("console", "Windows: write output to the console the program was started from"); + + const auto help = [&description]() { - boost::program_options::options_description desc("Allowed options"); - desc.add_options()("help", "produce help message")( - "enable-dev-tools", "Enable developer tools (DevTools in Browser)" - )("console", "Windows: write output to the console the program was started from"); + std::ostringstream stream; + stream << description; + return stream.str(); + }; - boost::program_options::variables_map vm; - boost::program_options::store(boost::program_options::parse_command_line(argc, argv, desc), vm); - boost::program_options::notify(vm); + try + { + boost::program_options::variables_map variables; + boost::program_options::store(boost::program_options::parse_command_line(argc, argv, description), variables); + boost::program_options::notify(variables); - if (vm.count("help")) - { - std::cout << desc << "\n"; - return std::nullopt; - } + if (variables.count("help")) + return std::unexpected{ProgramOptionsExit{.message = help(), .isError = false}}; ProgramOptions options; #ifdef NDEBUG - options.enableDevTools = vm.count("enable-dev-tools") > 0; + options.enableDevTools = variables.count("enable-dev-tools") > 0; #else options.enableDevTools = true; #endif - return options; } - catch (std::exception& e) + catch (std::exception const& exception) { - std::cerr << "Error parsing program options: " << Utility::errorMessage(e) << "\n"; - return std::nullopt; + return std::unexpected{ProgramOptionsExit{ + .message = fmt::format("Error parsing program options: {}\n\n{}", Utility::errorMessage(exception), help()), + .isError = true, + }}; } -} \ No newline at end of file +} diff --git a/backend/source/backend/session.cpp b/backend/source/backend/session.cpp index 09b21c70..ad0c236c 100644 --- a/backend/source/backend/session.cpp +++ b/backend/source/backend/session.cpp @@ -2,7 +2,6 @@ #include #include #include -#include #include #include diff --git a/backend/source/backend/sftp/bulk_transfer_operation.cpp b/backend/source/backend/sftp/bulk_transfer_operation.cpp index c8b17055..4fb00573 100644 --- a/backend/source/backend/sftp/bulk_transfer_operation.cpp +++ b/backend/source/backend/sftp/bulk_transfer_operation.cpp @@ -1,5 +1,4 @@ #include -#include #include #include @@ -585,10 +584,10 @@ BulkTransferOperation::entryPaths(std::size_t entryIndex) const { if (prescanned()) return prescannedPaths_[entryIndex]; - return Detail::treeEntryPaths(entries_, entryIndex, common_.remotePath, common_.localPath); + return BulkTransferDetail::treeEntryPaths(entries_, entryIndex, common_.remotePath, common_.localPath); } -namespace Detail +namespace BulkTransferDetail { std::expected treeEntryPaths( std::vector const& entries, diff --git a/backend/source/backend/sftp/bulk_upload_operation.cpp b/backend/source/backend/sftp/bulk_upload_operation.cpp index a54eaacf..5412e406 100644 --- a/backend/source/backend/sftp/bulk_upload_operation.cpp +++ b/backend/source/backend/sftp/bulk_upload_operation.cpp @@ -1,5 +1,4 @@ #include -#include #include #include #include diff --git a/backend/source/backend/sftp/delete_operation.cpp b/backend/source/backend/sftp/delete_operation.cpp index 6f049a50..09993b05 100644 --- a/backend/source/backend/sftp/delete_operation.cpp +++ b/backend/source/backend/sftp/delete_operation.cpp @@ -1,6 +1,5 @@ #include #include -#include #include diff --git a/backend/source/backend/sftp/operation_queue.cpp b/backend/source/backend/sftp/operation_queue.cpp index 17d4086a..6019c4e5 100644 --- a/backend/source/backend/sftp/operation_queue.cpp +++ b/backend/source/backend/sftp/operation_queue.cpp @@ -3,7 +3,6 @@ #include #include #include -#include #include #include #include @@ -216,8 +215,8 @@ auto OperationQueue::makeScanProgressCallback(std::string_view eventName, Ids::O auto self = weak.lock(); if (!self) return; - // May be invoked from the SFTP processing strand (batched umbrella) — marshal the - // RPC call back onto the asio strand that owns the hub. + // May be invoked from the SFTP processing strand (batched umbrella), so the RPC call goes back onto the asio + // strand that owns the hub. self->within_strand_do( [weak, name, operationId, totalBytes, currentIndex, totalScanned]() { @@ -340,7 +339,7 @@ void OperationQueue::moveOperation(Ids::OperationId operationId, std::size_t new // Pessimistic resolution: every exit path emits onOperationsReordered // so the frontend always gets a definitive "this move was/was not - // applied" — never silently dropped. The frontend's listener relies + // applied", never silently dropped. The frontend's listener relies // on this to apply or discard the user's intended reorder. auto emitResolution = [&self, &operationId](std::size_t resolvedIndex, bool applied) { @@ -360,7 +359,7 @@ void OperationQueue::moveOperation(Ids::OperationId operationId, std::size_t new // and reordering would alter the indices it iterates. if (!self->paused_) { - Log::warn("OperationQueue::moveOperation refused — queue not paused (op '{}')", operationId.value()); + Log::warn("OperationQueue::moveOperation refused, the queue is not paused (op '{}')", operationId.value()); return emitResolution(newIndex, false); } @@ -369,7 +368,7 @@ void OperationQueue::moveOperation(Ids::OperationId operationId, std::size_t new { if (pid == operationId) { - Log::warn("OperationQueue::moveOperation refused — '{}' is a priority op", operationId.value()); + Log::warn("OperationQueue::moveOperation refused, '{}' is a priority op", operationId.value()); return emitResolution(newIndex, false); } } @@ -388,7 +387,7 @@ void OperationQueue::moveOperation(Ids::OperationId operationId, std::size_t new } if (!found) { - // Op completed/canceled between click and strand tick — silent + // Op completed/canceled between click and strand tick: silent // resolution; the frontend already removed the card via // onOperationCompleted, so applied=false is purely informational. return emitResolution(newIndex, false); @@ -430,7 +429,7 @@ void OperationQueue::completeOperation(SharedData::OperationCompleted&& operatio if (operationCompleted.error) Log::error("Operation failed: {}", operationCompleted.error->toString()); - // Drop any resume backup tied to this operation — it has run to + // Drop any resume backup tied to this operation, it has run to // completion (success, failure, or cancel) and no longer needs // a backup for reconnect adoption. self->bulkResumeStash_.erase(operationCompleted.operationId); @@ -1095,7 +1094,9 @@ std::expected OperationQueue::addArchiveDownloadOperatio opts.compression = compression; opts.compressionOptions = compressionOptionsFromLevel(compression, compressionLevel); opts.mayOverwrite = mayOverwrite; - opts.markAsDownloaded = sftpOpts_.downloadOptions.value_or(Persistence::DownloadOptions{}).markAsDownloaded.value_or(true); + opts.markAsDownloaded = sftpOpts_.downloadOptions.value_or(Persistence::DownloadOptions{}).markAsDownloaded.value_or( + Persistence::defaultMarkAsDownloaded + ); opts.createMissingDirectories = true; opts.futureTimeout = sftpOpts_.operationTimeout.value_or(defaultFutureTimeout); opts.progressCallback = [weak = weak_from_this(), operationId, name = rpcName("onArchiveDownloadProgress")]( @@ -1207,7 +1208,7 @@ std::expected OperationQueue::addArchiveUploadOperation( // Local directory roots get a visible LocalScanOperation each; the // archive op itself never recurses and instead consumes each scan's - // output via setScanResultForRoot — keyed by the root's local path. + // output via setScanResultForRoot, keyed by the root's local path. std::vector directoryRootPaths; for (auto const& localPath : localPaths) { @@ -1437,7 +1438,7 @@ std::expected OperationQueue::addUploadOperation( const auto resolvedTimeout = sftpOpts_.operationTimeout.value_or(defaultFutureTimeout); // Symlinks always go through the single-file UploadOperation path so handleSymlink() can - // recreate them on the remote — regardless of whether the target exists or is a directory. + // recreate them on the remote, whether or not the target exists or is a directory. if (isSymlink || stat.type() == std::filesystem::file_type::regular) { auto opts = resolveUploadOptions(transferOptions, resolvedTimeout); @@ -1973,7 +1974,7 @@ std::size_t OperationQueue::addBulkDeleteOperation( }; // The roots are scanned one after another, their counts add up on the card. const auto scanProgressOfNextRoot = - Detail::sequentialScanProgress(makeScanProgressCallback("onScanProgress", bulkOperationId)); + ScanProgressDetail::sequentialScanProgress(makeScanProgressCallback("onScanProgress", bulkOperationId)); for (auto const& entry : request.entries) { if (auto allowed = guard.check(entry.target); !allowed) @@ -2126,7 +2127,7 @@ void OperationQueue::addSyncScanOperation( syncSessionId, session, remoteScanId, /*isLocal=*/true, &SyncSession::setLocalTreeInStrand ); - // Queue remote scan — build-tree mode. + // Queue remote scan in build-tree mode. // Temp files of transfers on either side and the asides of replaced destinations, a sync neither copies nor // deletes them. const auto uploadSuffix = Persistence::effectiveTempFileSuffix( @@ -2168,7 +2169,7 @@ void OperationQueue::addSyncScanOperation( } ); - // Queue local scan — build-tree mode. + // Queue local scan in build-tree mode. auto localScan = std::make_unique(LocalScanOperation::ScanOperationOptions{ .progressCallback = makeScanProgressCallback("onLocalScanProgress", localScanId), .localPath = localPath, diff --git a/backend/source/backend/sftp/upload_operation.cpp b/backend/source/backend/sftp/upload_operation.cpp index 82348c4c..dcc42af6 100644 --- a/backend/source/backend/sftp/upload_operation.cpp +++ b/backend/source/backend/sftp/upload_operation.cpp @@ -854,7 +854,7 @@ std::filesystem::perms UploadOperation::determinePerms(std::filesystem::perms lo { if (options_.filePermissions) return options_.filePermissions.value(); - // inherit execute permissions anyway: + // Without a configured mode: 0660, no execute bits, whatever the local file has. return ( std::filesystem::perms::owner_read | std::filesystem::perms::owner_write | std::filesystem::perms::group_read | std::filesystem::perms::group_write @@ -862,6 +862,6 @@ std::filesystem::perms UploadOperation::determinePerms(std::filesystem::perms lo } }(); - // Owner has to write obviously for the upload: + // The upload writes the file as its owner. return raw | std::filesystem::perms::owner_write; } \ No newline at end of file diff --git a/backend/source/backend/windows/opener_windows.cpp b/backend/source/backend/windows/opener_windows.cpp index bcfa3a71..9c12b59e 100644 --- a/backend/source/backend/windows/opener_windows.cpp +++ b/backend/source/backend/windows/opener_windows.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -59,9 +60,11 @@ std::optional Opener::riskOf(std::filesystem::path const& path) con if (GetBinaryTypeW(path.c_str(), &binaryType)) return std::string{"backend.opener.risk.program"}; // The shell's own list of types that run code: scripts, installers, shortcuts and the like. - const auto extension = path.extension().wstring(); + const auto extension = path.extension().native(); if (!extension.empty() && AssocIsDangerous(extension.c_str())) return std::string{"backend.opener.risk.dangerousType"}; + if (OpenerDetail::hasExtensionIn(path, OpenerDetail::windowsRunnableExtensions)) + return std::string{"backend.opener.risk.dangerousType"}; return std::nullopt; } diff --git a/backend/source/backend/windows/windows_drag_drop.cpp b/backend/source/backend/windows/windows_drag_drop.cpp index 57b4a129..cc195d9c 100644 --- a/backend/source/backend/windows/windows_drag_drop.cpp +++ b/backend/source/backend/windows/windows_drag_drop.cpp @@ -2,7 +2,6 @@ #include #include #include -#include #include #include #include diff --git a/backend/test/backend/main.cpp b/backend/test/backend/main.cpp index 3e5c05d0..29b44869 100644 --- a/backend/test/backend/main.cpp +++ b/backend/test/backend/main.cpp @@ -16,6 +16,7 @@ #include "test_archive_download_operation.hpp" #include "test_archive_upload_operation.hpp" #include "test_file_tracking.hpp" +#include "test_instance_bookkeeping.hpp" #include "test_shell_integration.hpp" #include "test_pseudo_terminal.hpp" #include "test_ui_options.hpp" @@ -33,6 +34,8 @@ #include "test_operation_queue.hpp" #include "test_existence_check.hpp" #include "test_opener_risk.hpp" +#include "test_process.hpp" +#include "test_program_options.hpp" #include "test_rpc_helper.hpp" #include "test_rpc_path_parameters.hpp" diff --git a/backend/test/backend/test_bulk_download_operation.hpp b/backend/test/backend/test_bulk_download_operation.hpp index c8002b67..4597cdd8 100644 --- a/backend/test/backend/test_bulk_download_operation.hpp +++ b/backend/test/backend/test_bulk_download_operation.hpp @@ -32,29 +32,29 @@ namespace Test const auto remoteRoot = remote("/home/test/tree"); const auto localRoot = toLocal(temporaryRoot / "tree"); - const auto root = Detail::treeEntryPaths(entries, 0, remoteRoot, localRoot); + const auto root = BulkTransferDetail::treeEntryPaths(entries, 0, remoteRoot, localRoot); ASSERT_TRUE(root.has_value()) << root.error().toString(); EXPECT_EQ(root->remote, remoteRoot); EXPECT_EQ(root->local, localRoot); - const auto file = Detail::treeEntryPaths(entries, 2, remoteRoot, localRoot); + const auto file = BulkTransferDetail::treeEntryPaths(entries, 2, remoteRoot, localRoot); ASSERT_TRUE(file.has_value()) << file.error().toString(); EXPECT_EQ(file->remote.value(), "/home/test/tree/sub/file.txt"); EXPECT_EQ(file->local.native(), (temporaryRoot / "tree" / "sub" / "file.txt")); - const auto invalid = Detail::treeEntryPaths(entries, 3, remoteRoot, localRoot); + const auto invalid = BulkTransferDetail::treeEntryPaths(entries, 3, remoteRoot, localRoot); ASSERT_FALSE(invalid.has_value()); EXPECT_EQ(invalid.error().type, Operation::ErrorType::InvalidName); - const auto lost = Detail::treeEntryPaths(entries, 4, remoteRoot, localRoot); + const auto lost = BulkTransferDetail::treeEntryPaths(entries, 4, remoteRoot, localRoot); ASSERT_FALSE(lost.has_value()); EXPECT_EQ(lost.error().type, Operation::ErrorType::ImplementationError); - const auto outside = Detail::treeEntryPaths(entries, 5, remoteRoot, localRoot); + const auto outside = BulkTransferDetail::treeEntryPaths(entries, 5, remoteRoot, localRoot); ASSERT_FALSE(outside.has_value()); EXPECT_EQ(outside.error().type, Operation::ErrorType::ImplementationError); - const auto noRoot = Detail::treeEntryPaths(entries, 2, Paths::RemotePath{}, localRoot); + const auto noRoot = BulkTransferDetail::treeEntryPaths(entries, 2, Paths::RemotePath{}, localRoot); ASSERT_FALSE(noRoot.has_value()); EXPECT_EQ(noRoot.error().type, Operation::ErrorType::ImplementationError); } @@ -1151,7 +1151,7 @@ namespace Test CREATE_SERVER_AND_JOINER(sftpServer); auto [_, sftp] = createSftpSession(serverStartResult->port); - // The scan root carries no permissions, applying "unknown" used to chmod it to 07777. + // The scan root carries no permissions, applying "unknown" must not chmod it to 07777. const auto localRoot = downloadDir_.path() / "inherited"; BulkDownloadOperation operation{ sftp, diff --git a/backend/test/backend/test_file_tracking.hpp b/backend/test/backend/test_file_tracking.hpp index 4b09a684..e610b19b 100644 --- a/backend/test/backend/test_file_tracking.hpp +++ b/backend/test/backend/test_file_tracking.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -17,6 +18,7 @@ #include #include #include +#include #include #include @@ -49,7 +51,63 @@ namespace Test } /** - * @brief Simulate the on-disk remnants of a dead TemporaryDirectoryInstance. + * @brief Reads a JSON file, an empty json when it cannot be read. + */ + inline nlohmann::json readJson(fs::path const& path) + { + std::ifstream input{path}; + return nlohmann::json::parse(input, nullptr, false); + } + + /** + * @brief The content of a text file. + */ + inline std::string readText(fs::path const& path) + { + std::ifstream input{path}; + return std::string{std::istreambuf_iterator{input}, std::istreambuf_iterator{}}; + } + + /** + * @brief Writes a metadata file with the given @p deadAt (or null if absent). + */ + inline void writeTestMetadata( + fs::path const& path, + std::string const& instanceId, + std::optional deadAt + ) + { + nlohmann::json meta = { + {"instanceId", instanceId}, + {"createdAt", std::chrono::system_clock::to_time_t(std::chrono::system_clock::now())}, + {"deadAt", nullptr}, + }; + if (deadAt) + meta["deadAt"] = std::chrono::system_clock::to_time_t(*deadAt); + std::ofstream{path} << meta.dump(4); + } + + /** + * @brief Simulate the on-disk remnants of a dead TemporaryDirectoryInstance of this build. + * + * Creates @p root / @p instanceId / with an unowned lock file and a metadata file in the bookkeeping + * directory, see bookkeepingPaths(). + */ + inline void makeDeadBookkeepingInstanceDir( + fs::path const& root, + std::string const& instanceId, + std::optional deadAt = std::nullopt + ) + { + const auto paths = bookkeepingPaths(root / instanceId); + fs::create_directories(paths.lock.parent_path()); + std::ofstream{paths.lock}.flush(); + writeTestMetadata(paths.metadata, instanceId, deadAt); + } + + /** + * @brief Simulate the on-disk remnants of a dead TemporaryDirectoryInstance of a build without the bookkeeping + * directory. * * Creates @p root / @p instanceId / with: * - @c \.lock — an unowned plain file (isLockedByAnother returns false) @@ -70,17 +128,7 @@ namespace Test // Lock file exists but is not held by any process std::ofstream{dir / (instanceId + ".lock")}.flush(); - - nlohmann::json meta = { - {"instanceId", instanceId}, - {"createdAt", std::chrono::system_clock::to_time_t(std::chrono::system_clock::now())}, - {"deadAt", nullptr}, - }; - if (deadAt) - meta["deadAt"] = std::chrono::system_clock::to_time_t(*deadAt); - - std::ofstream metaOut{dir / "metadata.json"}; - metaOut << meta.dump(4); + writeTestMetadata(dir / "metadata.json", instanceId, deadAt); } // ========================================================================= @@ -284,17 +332,42 @@ namespace Test EXPECT_FALSE(inst->instanceId().empty()); } - TEST_F(TemporaryDirectoryInstanceTest, LockFileExistsInInstanceDir) + TEST_F(TemporaryDirectoryInstanceTest, LockFileExistsInBookkeepingDir) { auto inst = makeInstance(); - auto lockPath = inst->instanceDir() / (inst->instanceId() + ".lock"); - EXPECT_TRUE(fs::exists(lockPath)); + EXPECT_TRUE(fs::exists(bookkeepingPaths(inst->instanceDir()).lock)); + EXPECT_FALSE(fs::exists(legacyBookkeepingPaths(inst->instanceDir()).lock)); + } + + TEST_F(TemporaryDirectoryInstanceTest, BookkeepingDirIsTheOnlyEntryOfANewInstance) + { + auto inst = makeInstance(); + std::vector entries; + for (auto const& entry : fs::directory_iterator{inst->instanceDir()}) + entries.push_back(entry.path().filename()); + ASSERT_EQ(entries.size(), 1u); + EXPECT_EQ(entries.front(), fs::path{bookkeepingDirectoryName}); + } + + TEST_F(TemporaryDirectoryInstanceTest, ContentNamedLikeLegacyBookkeepingIsLeftAlone) + { + fs::path dir; + { + auto inst = makeInstance(); + dir = inst->instanceDir(); + std::ofstream{dir / "metadata.json"} << "tracked content"; + std::ofstream{dir / (inst->instanceId() + ".lock")} << "tracked lock content"; + } + EXPECT_EQ(readText(dir / "metadata.json"), "tracked content"); + EXPECT_EQ(readText(dir / (dir.filename().string() + ".lock")), "tracked lock content"); + const auto meta = readJson(bookkeepingPaths(dir).metadata); + EXPECT_FALSE(meta["deadAt"].is_null()); } TEST_F(TemporaryDirectoryInstanceTest, LockFileIsHeldDuringLifetime) { auto inst = makeInstance(); - auto lockPath = inst->instanceDir() / (inst->instanceId() + ".lock"); + auto lockPath = bookkeepingPaths(inst->instanceDir()).lock; EXPECT_TRUE(InstanceLock::isLockedByAnother(lockPath)); } @@ -305,7 +378,7 @@ namespace Test { auto inst = makeInstance(); dir = inst->instanceDir(); - lockPath = dir / (inst->instanceId() + ".lock"); + lockPath = bookkeepingPaths(dir).lock; ASSERT_TRUE(InstanceLock::isLockedByAnother(lockPath)); } EXPECT_FALSE(InstanceLock::isLockedByAnother(lockPath)); @@ -315,22 +388,21 @@ namespace Test TEST_F(TemporaryDirectoryInstanceTest, MetadataFileCreatedOnConstruction) { auto inst = makeInstance(); - EXPECT_TRUE(fs::exists(inst->instanceDir() / "metadata.json")); + EXPECT_TRUE(fs::exists(bookkeepingPaths(inst->instanceDir()).metadata)); + EXPECT_FALSE(fs::exists(inst->instanceDir() / "metadata.json")); } TEST_F(TemporaryDirectoryInstanceTest, MetadataContainsCorrectInstanceId) { auto inst = makeInstance(); - std::ifstream fin{inst->instanceDir() / "metadata.json"}; - auto meta = nlohmann::json::parse(fin); + auto meta = readJson(bookkeepingPaths(inst->instanceDir()).metadata); EXPECT_EQ(meta.at("instanceId").get(), inst->instanceId()); } TEST_F(TemporaryDirectoryInstanceTest, MetadataHasCreatedAt) { auto inst = makeInstance(); - std::ifstream fin{inst->instanceDir() / "metadata.json"}; - auto meta = nlohmann::json::parse(fin); + auto meta = readJson(bookkeepingPaths(inst->instanceDir()).metadata); ASSERT_TRUE(meta.contains("createdAt")); EXPECT_FALSE(meta["createdAt"].is_null()); } @@ -338,8 +410,7 @@ namespace Test TEST_F(TemporaryDirectoryInstanceTest, MetadataHasNullDeadAtInitially) { auto inst = makeInstance(); - std::ifstream fin{inst->instanceDir() / "metadata.json"}; - auto meta = nlohmann::json::parse(fin); + auto meta = readJson(bookkeepingPaths(inst->instanceDir()).metadata); ASSERT_TRUE(meta.contains("deadAt")); EXPECT_TRUE(meta["deadAt"].is_null()); } @@ -366,13 +437,12 @@ namespace Test fs::path metaPath; { auto inst = makeInstance(); - metaPath = inst->instanceDir() / "metadata.json"; + metaPath = bookkeepingPaths(inst->instanceDir()).metadata; } ASSERT_TRUE(fs::exists(metaPath)); - auto const meta = nlohmann::json::parse(std::ifstream{metaPath}); + auto const meta = readJson(metaPath); ASSERT_TRUE(meta.contains("deadAt")); EXPECT_FALSE(meta["deadAt"].is_null()); - fs::remove_all(metaPath.parent_path()); } TEST_F(TemporaryDirectoryInstanceTest, CleanupNowDeletesDirectory) @@ -463,6 +533,25 @@ namespace Test EXPECT_FALSE(watch->isValid()); } + TEST_F(TemporaryDirectoryInstanceTest, WatchOutlivingItsInstanceReleasesSafely) + { + auto inst = makeInstance(); + auto watch = inst->addWatch(inst->instanceDir()); + ASSERT_TRUE(watch.has_value()); + inst.reset(); + EXPECT_NO_THROW(watch->release()); + EXPECT_FALSE(watch->isValid()); + } + + TEST_F(TemporaryDirectoryInstanceTest, WatchAfterCleanupNowReleasesSafely) + { + auto inst = makeInstance(); + auto watch = inst->addWatch(inst->instanceDir()); + ASSERT_TRUE(watch.has_value()); + inst->cleanupNow(); + EXPECT_NO_THROW(watch->release()); + } + TEST_F(TemporaryDirectoryInstanceTest, MoveFromWatchInvalidatesSource) { auto inst = makeInstance(); @@ -649,11 +738,11 @@ namespace Test mgr->manualCleanup(); - auto metaPath = dir / "metadata.json"; + // Added in the bookkeeping directory, no legacy metadata appears. + auto metaPath = bookkeepingPaths(dir).metadata; ASSERT_TRUE(fs::exists(metaPath)); - std::ifstream fin{metaPath}; - auto meta = nlohmann::json::parse(fin); - EXPECT_FALSE(meta["deadAt"].is_null()); + EXPECT_FALSE(readJson(metaPath)["deadAt"].is_null()); + EXPECT_FALSE(fs::exists(dir / "metadata.json")); } TEST_F(TempDirInstanceManagerTest, ManualCleanupWritesDeadAtWhenDeadAtIsNull) @@ -664,9 +753,48 @@ namespace Test mgr->manualCleanup(); - std::ifstream fin{root_ / deadId / "metadata.json"}; - auto meta = nlohmann::json::parse(fin); - EXPECT_FALSE(meta["deadAt"].is_null()); + // Migrated by adding the bookkeeping metadata, the legacy file gets the same stamp. + const auto added = readJson(bookkeepingPaths(root_ / deadId).metadata); + const auto legacy = readJson(root_ / deadId / "metadata.json"); + EXPECT_FALSE(added["deadAt"].is_null()); + EXPECT_EQ(added["deadAt"], legacy["deadAt"]); + EXPECT_EQ(added["instanceId"], deadId); + EXPECT_TRUE(fs::exists(root_ / deadId / (deadId + ".lock"))); + } + + TEST_F(TempDirInstanceManagerTest, ManualCleanupDeletesExpiredBookkeepingInstance) + { + auto mgr = makeManager(1h); + makeDeadBookkeepingInstanceDir(root_, "dead-new-expired", std::chrono::system_clock::now() - 2h); + makeDeadBookkeepingInstanceDir(root_, "dead-new-recent", std::chrono::system_clock::now() - 10min); + + mgr->manualCleanup(); + + EXPECT_FALSE(fs::exists(root_ / "dead-new-expired")); + EXPECT_TRUE(fs::exists(root_ / "dead-new-recent")); + } + + TEST_F(TempDirInstanceManagerTest, ManualCleanupKeepsInstanceWhoseBookkeepingLockIsHeld) + { + auto mgr = makeManager(0h); + makeDeadBookkeepingInstanceDir(root_, "held-elsewhere", std::chrono::system_clock::now() - 2h); + InstanceLock heldLock{bookkeepingPaths(root_ / "held-elsewhere").lock}; + + mgr->manualCleanup(); + + EXPECT_TRUE(fs::exists(root_ / "held-elsewhere")); + } + + TEST_F(TempDirInstanceManagerTest, ManualCleanupKeepsLegacyInstanceWhoseLockIsHeld) + { + auto mgr = makeManager(0h); + makeDeadInstanceDir(root_, "legacy-held", std::chrono::system_clock::now() - 2h); + InstanceLock heldLock{root_ / "legacy-held" / "legacy-held.lock"}; + + mgr->manualCleanup(); + + EXPECT_TRUE(fs::exists(root_ / "legacy-held")); + EXPECT_FALSE(fs::exists(root_ / "legacy-held" / fs::path{bookkeepingDirectoryName})); } TEST_F(TempDirInstanceManagerTest, ManualCleanupDeletesExpiredDeadInstance) @@ -739,6 +867,195 @@ namespace Test EXPECT_FALSE(fs::exists(dir)); } + TEST_F(TempDirInstanceManagerTest, StartupCleanupRunsOnTheStrand) + { + auto mgr = makeManager(); + makeDeadBookkeepingInstanceDir(root_, "dead-before-start", std::chrono::system_clock::now() - 25h); + + mgr->scheduleStartupCleanup(); + EXPECT_TRUE(fs::exists(root_ / "dead-before-start")); + + ioc_.poll(); + EXPECT_FALSE(fs::exists(root_ / "dead-before-start")); + } + + TEST_F(TempDirInstanceManagerTest, StartupCleanupRemovesOnlyInstancesDeadForTheRetention) + { + auto mgr = makeManager(); + makeDeadBookkeepingInstanceDir(root_, "dead-long-ago", std::chrono::system_clock::now() - 25h); + makeDeadInstanceDir(root_, "legacy-dead-long-ago", std::chrono::system_clock::now() - 48h); + makeDeadBookkeepingInstanceDir(root_, "dead-recently", std::chrono::system_clock::now() - 23h); + + mgr->scheduleStartupCleanup(); + ioc_.poll(); + + EXPECT_FALSE(fs::exists(root_ / "dead-long-ago")); + EXPECT_FALSE(fs::exists(root_ / "legacy-dead-long-ago")); + EXPECT_TRUE(fs::exists(root_ / "dead-recently")); + } + + TEST_F(TempDirInstanceManagerTest, StartupCleanupStampsAnUnstampedInstance) + { + auto mgr = makeManager(); + makeDeadBookkeepingInstanceDir(root_, "never-stamped"); + const auto before = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now()); + + mgr->scheduleStartupCleanup(); + ioc_.poll(); + + ASSERT_TRUE(fs::exists(root_ / "never-stamped")); + const auto deadAt = readJson(bookkeepingPaths(root_ / "never-stamped").metadata)["deadAt"]; + ASSERT_TRUE(deadAt.is_number_integer()); + EXPECT_GE(deadAt.get(), static_cast(before)); + } + + TEST_F(TempDirInstanceManagerTest, StartupCleanupLeavesLiveInstancesAlone) + { + auto mgr = makeManager(0h); + auto* live = mgr->createInstance(); + ASSERT_NE(live, nullptr); + std::ofstream{live->instanceDir() / "content.txt"} << "content"; + makeDeadBookkeepingInstanceDir(root_, "dead-long-ago", std::chrono::system_clock::now() - 25h); + + mgr->scheduleStartupCleanup(); + ioc_.poll(); + + EXPECT_FALSE(fs::exists(root_ / "dead-long-ago")); + EXPECT_TRUE(fs::exists(live->instanceDir() / "content.txt")); + EXPECT_TRUE(readJson(bookkeepingPaths(live->instanceDir()).metadata)["deadAt"].is_null()); + EXPECT_EQ(mgr->findInstance(live->instanceId()), live); + } + + // ---- discardContent behaviour ---- + + TEST_F(TempDirInstanceManagerTest, DiscardingTheOnlyContentRemovesTheInstanceAndItsDirectory) + { + auto mgr = makeManager(); + auto* instance = mgr->createInstance(); + auto* other = mgr->createInstance(); + ASSERT_NE(instance, nullptr); + ASSERT_NE(other, nullptr); + const auto directory = instance->instanceDir(); + const auto id = instance->instanceId(); + std::ofstream{directory / "script.sh"} << "#!/bin/sh"; + ASSERT_TRUE(instance->addWatch(directory).has_value()); + makeDeadBookkeepingInstanceDir(root_, "dead-elsewhere", std::chrono::system_clock::now() - 1h); + + const auto outcome = mgr->discardContent(id, directory / "script.sh"); + + ASSERT_TRUE(outcome.has_value()) << outcome.error(); + EXPECT_EQ(*outcome, DiscardOutcome::InstanceDestroyed); + EXPECT_FALSE(fs::exists(fs::symlink_status(directory))); + EXPECT_EQ(mgr->findInstance(id), nullptr); + EXPECT_TRUE(fs::exists(other->instanceDir())); + EXPECT_TRUE(fs::exists(root_ / "dead-elsewhere")); + + mgr->manualCleanup(); + EXPECT_FALSE(fs::exists(fs::symlink_status(directory))); + } + + TEST_F(TempDirInstanceManagerTest, DestroyingKeepsTheDeadInstanceDirectoryForTheRetention) + { + auto mgr = makeManager(); + auto* instance = mgr->createInstance(); + ASSERT_NE(instance, nullptr); + const auto directory = instance->instanceDir(); + std::ofstream{directory / "content.txt"} << "content"; + + mgr->destroyInstance(instance->instanceId()); + mgr->manualCleanup(); + + EXPECT_TRUE(fs::exists(directory / "content.txt")); + EXPECT_FALSE(readJson(bookkeepingPaths(directory).metadata)["deadAt"].is_null()); + } + + TEST_F(TempDirInstanceManagerTest, DiscardingKeepsAnInstanceWithOtherContent) + { + auto mgr = makeManager(); + auto* instance = mgr->createInstance(); + ASSERT_NE(instance, nullptr); + const auto directory = instance->instanceDir(); + std::ofstream{directory / "discarded.sh"} << "#!/bin/sh"; + std::ofstream{directory / "kept.txt"} << "kept"; + + const auto outcome = mgr->discardContent(instance->instanceId(), directory / "discarded.sh"); + + ASSERT_TRUE(outcome.has_value()) << outcome.error(); + EXPECT_EQ(*outcome, DiscardOutcome::InstanceKept); + EXPECT_FALSE(fs::exists(directory / "discarded.sh")); + EXPECT_TRUE(fs::exists(directory / "kept.txt")); + EXPECT_EQ(mgr->findInstance(instance->instanceId()), instance); + } + + TEST_F(TempDirInstanceManagerTest, DiscardingADirectoryRemovesItWhole) + { + auto mgr = makeManager(); + auto* instance = mgr->createInstance(); + ASSERT_NE(instance, nullptr); + const auto directory = instance->instanceDir(); + fs::create_directories(directory / "tree" / "below"); + std::ofstream{directory / "tree" / "below" / "file.txt"} << "content"; + + const auto outcome = mgr->discardContent(instance->instanceId(), directory / "tree"); + + ASSERT_TRUE(outcome.has_value()) << outcome.error(); + EXPECT_EQ(*outcome, DiscardOutcome::InstanceDestroyed); + EXPECT_FALSE(fs::exists(directory)); + } + + TEST_F(TempDirInstanceManagerTest, DiscardingALinkKeepsItsTarget) + { + auto mgr = makeManager(); + auto* instance = mgr->createInstance(); + ASSERT_NE(instance, nullptr); + const auto directory = instance->instanceDir(); + const auto outside = root_ / "outside"; + fs::create_directories(outside); + std::ofstream{outside / "target.txt"} << "target"; + std::error_code linkError; + fs::create_directory_symlink(outside, directory / "link", linkError); + if (linkError) + GTEST_SKIP() << "Cannot create symlinks here: " << linkError.message(); + + const auto outcome = mgr->discardContent(instance->instanceId(), directory / "link"); + + ASSERT_TRUE(outcome.has_value()) << outcome.error(); + EXPECT_FALSE(fs::exists(fs::symlink_status(directory / "link"))); + EXPECT_TRUE(fs::exists(outside / "target.txt")); + } + + TEST_F(TempDirInstanceManagerTest, DiscardingRefusesPathsThatAreNoContent) + { + auto mgr = makeManager(); + auto* instance = mgr->createInstance(); + ASSERT_NE(instance, nullptr); + const auto directory = instance->instanceDir(); + const auto id = instance->instanceId(); + const auto outside = root_ / "outside"; + fs::create_directories(outside); + std::ofstream{outside / "target.txt"} << "target"; + std::ofstream{directory / "content.txt"} << "content"; + + EXPECT_FALSE(mgr->discardContent(id, directory).has_value()); + EXPECT_FALSE(mgr->discardContent(id, directory / ".").has_value()); + EXPECT_FALSE(mgr->discardContent(id, directory / "..").has_value()); + EXPECT_FALSE(mgr->discardContent(id, directory / ".." / "outside" / "target.txt").has_value()); + EXPECT_FALSE(mgr->discardContent(id, outside / "target.txt").has_value()); + EXPECT_FALSE(mgr->discardContent(id, directory / fs::path{bookkeepingDirectoryName}).has_value()); + EXPECT_FALSE(mgr->discardContent(id, bookkeepingPaths(directory).metadata).has_value()); + EXPECT_FALSE(mgr->discardContent("nonexistent-id-xyz", directory / "content.txt").has_value()); + + std::error_code linkError; + fs::create_directory_symlink(outside, directory / "link", linkError); + if (!linkError) + EXPECT_FALSE(mgr->discardContent(id, directory / "link" / "target.txt").has_value()); + + EXPECT_TRUE(fs::exists(outside / "target.txt")); + EXPECT_TRUE(fs::exists(directory / "content.txt")); + EXPECT_TRUE(fs::exists(bookkeepingPaths(directory).metadata)); + EXPECT_EQ(mgr->findInstance(id), instance); + } + TEST_F(TempDirInstanceManagerTest, MultipleCleanupRunsAreIdempotent) { auto mgr = makeManager(1h); diff --git a/backend/test/backend/test_instance_bookkeeping.hpp b/backend/test/backend/test_instance_bookkeeping.hpp new file mode 100644 index 00000000..131ce08e --- /dev/null +++ b/backend/test/backend/test_instance_bookkeeping.hpp @@ -0,0 +1,196 @@ +#pragma once + +#include +#include +#include + +#include + +#include + +#include +#include +#include +#include +#include + +extern std::filesystem::path temporaryRoot; + +namespace Test +{ + class InstanceBookkeepingTest : public ::testing::Test + { + protected: + void SetUp() override + { + const auto* info = ::testing::UnitTest::GetInstance()->current_test_info(); + instanceDirectory_ = temporaryRoot / "instance_bookkeeping" / info->name() / "0123-instance"; + std::filesystem::remove_all(instanceDirectory_); + std::filesystem::create_directories(instanceDirectory_); + } + + void TearDown() override + { + std::filesystem::remove_all(instanceDirectory_.parent_path()); + } + + static void writeText(std::filesystem::path const& path, std::string const& text) + { + std::filesystem::create_directories(path.parent_path()); + std::ofstream{path} << text; + } + + static nlohmann::json readJson(std::filesystem::path const& path) + { + std::ifstream input{path}; + return nlohmann::json::parse(input, nullptr, false); + } + + std::filesystem::path instanceDirectory_; + }; + + TEST_F(InstanceBookkeepingTest, CurrentPathsLieInTheHiddenDirectory) + { + const auto paths = FileTracking::bookkeepingPaths(instanceDirectory_); + const auto hidden = instanceDirectory_ / std::filesystem::path{FileTracking::bookkeepingDirectoryName}; + EXPECT_EQ(paths.lock.parent_path(), hidden); + EXPECT_EQ(paths.metadata.parent_path(), hidden); + EXPECT_TRUE(Paths::nativeToUtf8(hidden.filename()).starts_with(".")); + } + + TEST_F(InstanceBookkeepingTest, LegacyPathsLieNextToTheContent) + { + const auto paths = FileTracking::legacyBookkeepingPaths(instanceDirectory_); + EXPECT_EQ(paths.lock, instanceDirectory_ / "0123-instance.lock"); + EXPECT_EQ(paths.metadata, instanceDirectory_ / "metadata.json"); + } + + TEST_F(InstanceBookkeepingTest, BookkeepingPathRecognition) + { + const auto hidden = instanceDirectory_ / std::filesystem::path{FileTracking::bookkeepingDirectoryName}; + EXPECT_TRUE(FileTracking::isBookkeepingPath(instanceDirectory_, hidden)); + EXPECT_TRUE(FileTracking::isBookkeepingPath(instanceDirectory_, hidden / "metadata.json")); + EXPECT_FALSE(FileTracking::isBookkeepingPath(instanceDirectory_, instanceDirectory_)); + EXPECT_FALSE(FileTracking::isBookkeepingPath(instanceDirectory_, instanceDirectory_ / "metadata.json")); + EXPECT_FALSE(FileTracking::isBookkeepingPath( + instanceDirectory_, instanceDirectory_ / "sub" / std::filesystem::path{FileTracking::bookkeepingDirectoryName} + )); + EXPECT_FALSE(FileTracking::isBookkeepingPath(instanceDirectory_, instanceDirectory_.parent_path())); + } + + TEST_F(InstanceBookkeepingTest, NoLockMeansNoInstance) + { + EXPECT_FALSE(FileTracking::existingLockFile(instanceDirectory_).has_value()); + } + + TEST_F(InstanceBookkeepingTest, LegacyLockIsFoundWhenTheCurrentOneIsMissing) + { + const auto legacy = FileTracking::legacyBookkeepingPaths(instanceDirectory_).lock; + writeText(legacy, ""); + EXPECT_EQ(FileTracking::existingLockFile(instanceDirectory_), legacy); + } + + TEST_F(InstanceBookkeepingTest, CurrentLockWinsOverTheLegacyOne) + { + writeText(FileTracking::legacyBookkeepingPaths(instanceDirectory_).lock, ""); + const auto current = FileTracking::bookkeepingPaths(instanceDirectory_).lock; + writeText(current, ""); + EXPECT_EQ(FileTracking::existingLockFile(instanceDirectory_), current); + } + + TEST_F(InstanceBookkeepingTest, MissingMetadataReadsAsNothing) + { + const auto metadata = FileTracking::readMetadata(instanceDirectory_); + ASSERT_TRUE(metadata.has_value()); + EXPECT_FALSE(metadata->has_value()); + } + + TEST_F(InstanceBookkeepingTest, LegacyMetadataIsReadWhenTheCurrentOneIsMissing) + { + writeText(FileTracking::legacyBookkeepingPaths(instanceDirectory_).lock, ""); + writeText(FileTracking::legacyBookkeepingPaths(instanceDirectory_).metadata, R"({"deadAt": 5})"); + const auto metadata = FileTracking::readMetadata(instanceDirectory_); + ASSERT_TRUE(metadata.has_value() && metadata->has_value()); + EXPECT_EQ((**metadata)["deadAt"], 5); + } + + TEST_F(InstanceBookkeepingTest, CurrentMetadataWinsOverTheLegacyOne) + { + writeText(FileTracking::legacyBookkeepingPaths(instanceDirectory_).metadata, R"({"deadAt": 5})"); + writeText(FileTracking::bookkeepingPaths(instanceDirectory_).metadata, R"({"deadAt": 7})"); + const auto metadata = FileTracking::readMetadata(instanceDirectory_); + ASSERT_TRUE(metadata.has_value() && metadata->has_value()); + EXPECT_EQ((**metadata)["deadAt"], 7); + } + + TEST_F(InstanceBookkeepingTest, BrokenMetadataIsAnError) + { + writeText(FileTracking::bookkeepingPaths(instanceDirectory_).metadata, "{not json"); + EXPECT_FALSE(FileTracking::readMetadata(instanceDirectory_).has_value()); + } + + TEST_F(InstanceBookkeepingTest, WriteCreatesTheHiddenDirectoryAndNoLegacyFile) + { + ASSERT_TRUE(FileTracking::writeMetadata(instanceDirectory_, {{"deadAt", 3}}).has_value()); + EXPECT_EQ(readJson(FileTracking::bookkeepingPaths(instanceDirectory_).metadata)["deadAt"], 3); + EXPECT_FALSE(std::filesystem::exists(FileTracking::legacyBookkeepingPaths(instanceDirectory_).metadata)); + } + + TEST_F(InstanceBookkeepingTest, ContentNamedMetadataIsNoMetadata) + { + writeText(FileTracking::bookkeepingPaths(instanceDirectory_).lock, ""); + writeText(FileTracking::legacyBookkeepingPaths(instanceDirectory_).metadata, R"({"deadAt": 5})"); + const auto metadata = FileTracking::readMetadata(instanceDirectory_); + ASSERT_TRUE(metadata.has_value()); + EXPECT_FALSE(metadata->has_value()); + } + + TEST_F(InstanceBookkeepingTest, WriteLeavesContentNamedMetadataAlone) + { + writeText(FileTracking::bookkeepingPaths(instanceDirectory_).lock, ""); + writeText(FileTracking::legacyBookkeepingPaths(instanceDirectory_).lock, "content"); + writeText(FileTracking::legacyBookkeepingPaths(instanceDirectory_).metadata, "content"); + ASSERT_TRUE(FileTracking::writeMetadata(instanceDirectory_, {{"deadAt", 9}}).has_value()); + std::ifstream input{FileTracking::legacyBookkeepingPaths(instanceDirectory_).metadata}; + const std::string content{std::istreambuf_iterator{input}, std::istreambuf_iterator{}}; + EXPECT_EQ(content, "content"); + } + + TEST_F(InstanceBookkeepingTest, WriteMirrorsIntoTheMetadataOfALegacyInstance) + { + writeText(FileTracking::legacyBookkeepingPaths(instanceDirectory_).lock, ""); + writeText(FileTracking::legacyBookkeepingPaths(instanceDirectory_).metadata, R"({"deadAt": null})"); + ASSERT_TRUE(FileTracking::writeMetadata(instanceDirectory_, {{"deadAt", 9}}).has_value()); + EXPECT_EQ(readJson(FileTracking::bookkeepingPaths(instanceDirectory_).metadata)["deadAt"], 9); + EXPECT_EQ(readJson(FileTracking::legacyBookkeepingPaths(instanceDirectory_).metadata)["deadAt"], 9); + } + + TEST_F(InstanceBookkeepingTest, FileStatesListFilesAndDirectoriesWithoutBookkeeping) + { + ASSERT_TRUE(FileTracking::createBookkeepingDirectory(instanceDirectory_).has_value()); + writeText(FileTracking::bookkeepingPaths(instanceDirectory_).metadata, "{}"); + writeText(instanceDirectory_ / "metadata.json", "tracked"); + writeText(instanceDirectory_ / "tree" / "inner.txt", "12345"); + + const auto states = FileTracking::InstanceManagerDetail::fileStatesOf(instanceDirectory_); + ASSERT_TRUE(states.is_array()); + const auto find = [&states](std::filesystem::path const& path) -> nlohmann::json + { + const auto wanted = Paths::nativeToUtf8(std::filesystem::weakly_canonical(path)); + const auto found = std::ranges::find_if( + states, + [&wanted](nlohmann::json const& state) + { + return state["path"] == wanted; + } + ); + return found == states.end() ? nlohmann::json{} : *found; + }; + EXPECT_EQ(states.size(), 3u); + EXPECT_EQ(find(instanceDirectory_ / "metadata.json")["size"], 7); + EXPECT_EQ(find(instanceDirectory_ / "tree" / "inner.txt")["size"], 5); + EXPECT_TRUE(find(instanceDirectory_ / "tree" / "inner.txt").contains("mtime")); + EXPECT_EQ(find(instanceDirectory_ / "tree")["directory"], true); + EXPECT_TRUE(find(FileTracking::bookkeepingPaths(instanceDirectory_).metadata).is_null()); + } +} diff --git a/backend/test/backend/test_opener_risk.hpp b/backend/test/backend/test_opener_risk.hpp index fb2b6043..6cdd7144 100644 --- a/backend/test/backend/test_opener_risk.hpp +++ b/backend/test/backend/test_opener_risk.hpp @@ -1,12 +1,14 @@ #pragma once #include +#include #include #include #include #include +#include extern std::filesystem::path temporaryRoot; @@ -15,13 +17,20 @@ namespace Test class OpenerRiskTests : public ::testing::Test { protected: - std::filesystem::path write(std::string const& name) + std::filesystem::path write(std::string const& name, std::string const& content = "content") { const auto path = directory_.path() / name; - std::ofstream{path} << "content"; + std::ofstream{path, std::ios::binary} << content; return path; } + static void makeExecutable(std::filesystem::path const& path) + { + std::filesystem::permissions( + path, std::filesystem::perms::owner_exec, std::filesystem::perm_options::add + ); + } + Opener opener_{}; Utility::TemporaryDirectory directory_{temporaryRoot, true}; }; @@ -32,11 +41,81 @@ namespace Test EXPECT_FALSE(opener_.riskOf(directory_.path())); } + TEST_F(OpenerRiskTests, ExtensionsCompareIgnoringCase) + { + EXPECT_TRUE(OpenerDetail::hasExtensionIn("/x/Setup.JAR", OpenerDetail::windowsRunnableExtensions)); + EXPECT_TRUE(OpenerDetail::hasExtensionIn("/x/tool.AppRef-MS", OpenerDetail::windowsRunnableExtensions)); + EXPECT_FALSE(OpenerDetail::hasExtensionIn("/x/notes.txt", OpenerDetail::windowsRunnableExtensions)); + EXPECT_FALSE(OpenerDetail::hasExtensionIn("/x/jar", OpenerDetail::windowsRunnableExtensions)) + << "a name without extension"; + EXPECT_FALSE(OpenerDetail::hasExtensionIn("/x/.jar", OpenerDetail::windowsRunnableExtensions)) + << "a dot file has no extension"; + EXPECT_FALSE(OpenerDetail::hasExtensionIn("/x/archive.jar.txt", OpenerDetail::windowsRunnableExtensions)); + } + + TEST_F(OpenerRiskTests, EveryPlatformListNamesItsRunnableTypes) + { + for (auto const* name : {"a.py", "a.pyw", "a.pyz", "a.application", "a.appx", "a.msix", "a.appxbundle", + "a.msixbundle", "a.appinstaller", "a.settingcontent-ms"}) + EXPECT_TRUE(OpenerDetail::hasExtensionIn(name, OpenerDetail::windowsRunnableExtensions)) << name; + for (auto const* name : + {"a.inetloc", "a.fileloc", "a.prefpane", "a.saver", "a.mobileconfig", "a.terminal", "a.command"}) + EXPECT_TRUE(OpenerDetail::hasExtensionIn(name, OpenerDetail::macRunnableExtensions)) << name; + for (auto const* name : + {"a.desktop", "a.AppImage", "a.run", "a.jar", "a.deb", "a.rpm", "a.flatpakref", "a.flatpakrepo", "a.snap"}) + EXPECT_TRUE(OpenerDetail::hasExtensionIn(name, OpenerDetail::linuxRunnableExtensions)) << name; + } + + TEST_F(OpenerRiskTests, ExecuteBitOfAnyClassCounts) + { + using std::filesystem::perms; + EXPECT_FALSE(OpenerDetail::hasExecuteBit(perms::owner_read | perms::owner_write | perms::group_read)); + EXPECT_TRUE(OpenerDetail::hasExecuteBit(perms::owner_exec)); + EXPECT_TRUE(OpenerDetail::hasExecuteBit(perms::group_exec)); + EXPECT_TRUE(OpenerDetail::hasExecuteBit(perms::others_exec)); + } + + TEST_F(OpenerRiskTests, ExecutableContentIsElfOrInterpreterLine) + { + EXPECT_TRUE(OpenerDetail::hasExecutableContent(write("program", std::string{"\x7f" "ELF\x02\x01"}))); + EXPECT_TRUE(OpenerDetail::hasExecutableContent(write("script", "#!/bin/sh\necho hi\n"))); + EXPECT_FALSE(OpenerDetail::hasExecutableContent(write("text", "just words"))); + EXPECT_FALSE(OpenerDetail::hasExecutableContent(write("short", "#"))); + EXPECT_FALSE(OpenerDetail::hasExecutableContent(write("empty", ""))); + EXPECT_FALSE(OpenerDetail::hasExecutableContent(directory_.path() / "missing")); + } + +#ifndef _WIN32 + // Windows files carry no execute bit, every file reads as executable there. + TEST_F(OpenerRiskTests, LinuxRuleNeedsProgramContentForTheExecuteBit) + { + const auto document = write("photo.jpg", "not a program"); + makeExecutable(document); + EXPECT_FALSE(OpenerDetail::linuxRiskOf(document)) << "a mount that marks every file executable"; + + const auto script = write("tool.txt", "#!/bin/sh\n"); + makeExecutable(script); + EXPECT_EQ(OpenerDetail::linuxRiskOf(script), "backend.opener.risk.program"); + + EXPECT_FALSE(OpenerDetail::linuxRiskOf(write("unmarked.sh", "#!/bin/sh\n"))) + << "a script without execute bit opens in an editor"; + EXPECT_EQ(OpenerDetail::linuxRiskOf(write("installer.deb")), "backend.opener.risk.dangerousType"); + EXPECT_EQ(OpenerDetail::linuxRiskOf(write("start.desktop")), "backend.opener.risk.dangerousType"); + EXPECT_FALSE(OpenerDetail::linuxRiskOf(directory_.path())); + + const auto link = directory_.path() / "readme.txt"; + std::filesystem::create_symlink(write("launcher.desktop"), link); + EXPECT_EQ(OpenerDetail::linuxRiskOf(link), "backend.opener.risk.dangerousType") << "judged by its target too"; + } +#endif + #ifdef _WIN32 TEST_F(OpenerRiskTests, ProgramsAndScriptsAreRisks) { EXPECT_EQ(opener_.riskOf(write("run.bat")), "backend.opener.risk.dangerousType"); EXPECT_EQ(opener_.riskOf(write("run.vbs")), "backend.opener.risk.dangerousType"); + EXPECT_EQ(opener_.riskOf(write("tool.py")), "backend.opener.risk.dangerousType"); + EXPECT_EQ(opener_.riskOf(write("App.appref-ms")), "backend.opener.risk.dangerousType"); // Judged by content, a renamed program is still one. const auto program = directory_.path() / "renamed.txt"; std::filesystem::copy_file("C:\\Windows\\System32\\whoami.exe", program); @@ -45,13 +124,18 @@ namespace Test #else TEST_F(OpenerRiskTests, ProgramsAndScriptsAreRisks) { - const auto script = write("tool.txt"); - std::filesystem::permissions(script, std::filesystem::perms::owner_exec, std::filesystem::perm_options::add); + const auto script = write("tool.txt", "#!/bin/sh\n"); + makeExecutable(script); EXPECT_EQ(opener_.riskOf(script), "backend.opener.risk.program") << "the execute bit counts, not the name"; # ifdef __APPLE__ EXPECT_EQ(opener_.riskOf(write("start.command")), "backend.opener.risk.dangerousType"); + EXPECT_EQ(opener_.riskOf(write("site.inetloc")), "backend.opener.risk.dangerousType"); + const auto link = directory_.path() / "notes.txt"; + std::filesystem::create_symlink(write("start.terminal"), link); + EXPECT_EQ(opener_.riskOf(link), "backend.opener.risk.dangerousType") << "a link is judged by its target"; # else EXPECT_EQ(opener_.riskOf(write("start.desktop")), "backend.opener.risk.dangerousType"); + EXPECT_EQ(opener_.riskOf(write("package.rpm")), "backend.opener.risk.dangerousType"); # endif } #endif diff --git a/backend/test/backend/test_operation_queue.hpp b/backend/test/backend/test_operation_queue.hpp index 266494bb..b5cf0f99 100644 --- a/backend/test/backend/test_operation_queue.hpp +++ b/backend/test/backend/test_operation_queue.hpp @@ -9,6 +9,7 @@ #include #include +#include #include #include @@ -53,7 +54,7 @@ namespace Test localQueue_.reset(); } - std::shared_ptr makeQueue() + std::shared_ptr makeQueue(Persistence::SftpOptions options = {}) { auto& env = NuiEnv::instance(); queue_ = std::make_shared( @@ -61,7 +62,7 @@ namespace Test strand_, *env.window, *env.hub, - Persistence::SftpOptions{}, + std::move(options), sessionId_, 2 ); @@ -188,6 +189,30 @@ namespace Test }; } + /** + * @brief The permission bits of @p path on the server, unknown when it cannot be read. + */ + static std::filesystem::perms remoteMode(SecureShell::SftpSession& sftp, std::string const& path) + { + auto future = sftp.stat(remote(path)); + if (future.wait_for(5s) != std::future_status::ready) + return std::filesystem::perms::unknown; + const auto information = future.get(); + return information ? information->permissions & std::filesystem::perms::mask + : std::filesystem::perms::unknown; + } + + /** + * @brief Options of a profile that turned inheriting permissions off for both directions. + */ + static Persistence::SftpOptions notInheritingOptions() + { + Persistence::SftpOptions options{}; + options.downloadOptions.emplace().inheritPermissions = false; + options.uploadOptions.emplace().inheritPermissions = false; + return options; + } + static std::set remoteNames(SecureShell::SftpSession& sftp, std::string const& directory) { auto future = sftp.listDirectory(remote(directory)); @@ -429,11 +454,11 @@ namespace Test } #endif - TEST_F(OperationQueueTests, DirectoryUploadCreatesDirectoriesWithTheDefaultMode) + TEST_F(OperationQueueTests, DirectoryUploadCreatesDirectoriesWithTheDefaultModeWhenNotInheriting) { CREATE_SERVER_AND_JOINER(sftpServer); auto [_, sftp] = createSftpSession(serverStartResult->port); - auto queue = makeQueue(); + auto queue = makeQueue(notInheritingOptions()); setPaused(false); const auto localPath = localDirectory_.path() / "modes"; @@ -451,18 +476,163 @@ namespace Test ASSERT_EQ(completions().size(), 1u); using std::filesystem::perms; - const auto modeOf = [&](std::string const& path) { - auto future = sftp->stat(remote(path)); - if (future.wait_for(5s) != std::future_status::ready) - return perms::unknown; - const auto information = future.get(); - return information ? information->permissions & perms::mask : perms::unknown; - }; - // Not inherited by default: owner everything, group read and enter, never world writable. + // Owner everything, group read and enter, never world writable. const auto expected = perms::owner_all | perms::group_read | perms::group_exec; - EXPECT_EQ(modeOf("/home/test/modes"), expected); - EXPECT_EQ(modeOf("/home/test/modes/sub"), expected); + EXPECT_EQ(remoteMode(*sftp, "/home/test/modes"), expected); + EXPECT_EQ(remoteMode(*sftp, "/home/test/modes/sub"), expected); + } + +#ifndef _WIN32 + TEST_F(OperationQueueTests, FilesKeepTheirModesThroughDownloadEditAndUploadWhenTheProfileDoesNotSetInheriting) + { + CREATE_SERVER_AND_JOINER(sftpServer); + ASSERT_TRUE(serverStartResult->command( + "createFile", {{"path", "/home/test/plain.txt"}, {"content", "plain"}, {"mode", 0644}} + )); + ASSERT_TRUE(serverStartResult->command( + "createFile", {{"path", "/home/test/script.sh"}, {"content", "#!/bin/sh\n"}, {"mode", 0755}} + )); + auto [_, sftp] = createSftpSession(serverStartResult->port); + auto queue = makeQueue(); + setPaused(false); + + using std::filesystem::perms; + const std::vector> files{ + {"plain.txt", static_cast(0644u)}, + {"script.sh", static_cast(0755u)}, + }; + for (auto const& [name, mode] : files) + { + ASSERT_TRUE(runOnStrand([&]() { + return queue->addDownloadOperation( + *sftp, + Ids::generateOperationId(), + toLocal(localDirectory_.path() / name), + remote(fmt::format("/home/test/{}", name)), + false, + false, + false, + false + ); + }).has_value()); + } + workUntilCompletions(files.size()); + + for (auto const& [name, mode] : files) + { + const auto localPath = localDirectory_.path() / name; + EXPECT_EQ(std::filesystem::status(localPath).permissions(), mode & ~Utility::processUmask()) << name; + std::ofstream{localPath, std::ios::app} << "edited\n"; + ASSERT_TRUE(runOnStrand([&]() { + return queue->addUploadOperation( + *sftp, + Ids::generateOperationId(), + toLocal(localPath), + remote(fmt::format("/home/test/{}", name)), + true, + false, + false, + false + ); + }).has_value()); + } + workUntilCompletions(files.size() * 2); + + for (auto const& finished : completions()) + EXPECT_EQ(finished.reason, SharedData::OperationCompletionReason::Completed); + for (auto const& [name, mode] : files) + EXPECT_EQ(remoteMode(*sftp, fmt::format("/home/test/{}", name)), mode & ~Utility::processUmask()) << name; + } + + TEST_F(OperationQueueTests, DirectoryUploadKeepsTheModesOfItsEntriesWhenTheProfileDoesNotSetInheriting) + { + CREATE_SERVER_AND_JOINER(sftpServer); + auto [_, sftp] = createSftpSession(serverStartResult->port); + auto queue = makeQueue(); + setPaused(false); + + using std::filesystem::perms; + const auto localPath = localDirectory_.path() / "tree"; + std::filesystem::create_directories(localPath / "sub"); + std::ofstream{localPath / "sub" / "plain.txt"} << "plain"; + std::ofstream{localPath / "sub" / "script.sh"} << "#!/bin/sh\n"; + std::filesystem::permissions(localPath / "sub" / "plain.txt", static_cast(0644u)); + std::filesystem::permissions(localPath / "sub" / "script.sh", static_cast(0755u)); + std::filesystem::permissions(localPath / "sub", static_cast(0750u)); + + ASSERT_TRUE(runOnStrand([&]() { + return queue->addUploadOperation( + *sftp, + Ids::generateOperationId(), + toLocal(localPath), + remote("/home/test/tree"), + false, + false, + false, + false + ); + }).has_value()); + workUntilCompletions(1); + ASSERT_EQ(completions().size(), 1u); + EXPECT_EQ(completions().front().reason, SharedData::OperationCompletionReason::Completed); + + EXPECT_EQ(remoteMode(*sftp, "/home/test/tree/sub"), static_cast(0750u)); + EXPECT_EQ(remoteMode(*sftp, "/home/test/tree/sub/plain.txt"), static_cast(0644u)); + EXPECT_EQ(remoteMode(*sftp, "/home/test/tree/sub/script.sh"), static_cast(0755u)); + } + + TEST_F(OperationQueueTests, TransfersUseTheDefaultModesWhenTheProfileTurnsInheritingOff) + { + CREATE_SERVER_AND_JOINER(sftpServer); + ASSERT_TRUE(serverStartResult->command( + "createFile", {{"path", "/home/test/script.sh"}, {"content", "#!/bin/sh\n"}, {"mode", 0755}} + )); + auto [_, sftp] = createSftpSession(serverStartResult->port); + auto queue = makeQueue(notInheritingOptions()); + setPaused(false); + + using std::filesystem::perms; + const auto downloaded = localDirectory_.path() / "script.sh"; + ASSERT_TRUE(runOnStrand([&]() { + return queue->addDownloadOperation( + *sftp, + Ids::generateOperationId(), + toLocal(downloaded), + remote("/home/test/script.sh"), + false, + false, + false, + false + ); + }).has_value()); + workUntilCompletions(1); + EXPECT_EQ(std::filesystem::status(downloaded).permissions(), Utility::newFilePermissions()); + + const auto uploaded = localDirectory_.path() / "local.sh"; + std::ofstream{uploaded} << "#!/bin/sh\n"; + std::filesystem::permissions(uploaded, static_cast(0755u)); + ASSERT_TRUE(runOnStrand([&]() { + return queue->addUploadOperation( + *sftp, + Ids::generateOperationId(), + toLocal(uploaded), + remote("/home/test/local.sh"), + false, + false, + false, + false + ); + }).has_value()); + workUntilCompletions(2); + + for (auto const& finished : completions()) + EXPECT_EQ(finished.reason, SharedData::OperationCompletionReason::Completed); + EXPECT_EQ( + remoteMode(*sftp, "/home/test/local.sh"), + perms::owner_read | perms::owner_write | perms::group_read | perms::group_write + ); } +#endif TEST_F(OperationQueueTests, BulkDownloadQueuesFilesAsOneCardAndDirectoriesUnderTheirIds) { @@ -1154,7 +1324,7 @@ namespace Test TEST(SequentialScanProgressTests, CountsOfLaterScansAddToTheFinishedOnes) { std::vector> reports; - const auto next = Detail::sequentialScanProgress( + const auto next = ScanProgressDetail::sequentialScanProgress( [&reports](std::uint64_t bytes, std::uint64_t index, std::uint64_t scanned) { reports.push_back({bytes, index, scanned}); diff --git a/backend/test/backend/test_process.hpp b/backend/test/backend/test_process.hpp new file mode 100644 index 00000000..697ef9e9 --- /dev/null +++ b/backend/test/backend/test_process.hpp @@ -0,0 +1,96 @@ +#pragma once + +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace Test +{ + class ProcessTests : public ::testing::Test + { + protected: + void TearDown() override + { + work_.reset(); + context_.stop(); + runner_.join(); + } + + /** + * @brief Waits until @p done holds for what was read, at most 10 seconds. + */ + template + bool waitFor(PredicateT done) + { + std::unique_lock lock{mutex_}; + return changed_.wait_for( + lock, + std::chrono::seconds{10}, + [&]() + { + return done(); + } + ); + } + + bool append(std::string& target, std::string_view data) + { + { + std::scoped_lock lock{mutex_}; + target.append(data); + } + changed_.notify_all(); + return true; + } + + boost::asio::io_context context_{}; + boost::asio::executor_work_guard work_{context_.get_executor()}; + std::thread runner_{[this]() + { + context_.run(); + }}; + std::mutex mutex_{}; + std::condition_variable changed_{}; + std::string output_{}; + std::string errors_{}; + }; + +#ifndef _WIN32 + TEST_F(ProcessTests, ChildWithoutLauncherTalksThroughThePipes) + { + auto process = std::make_shared(context_.get_executor(), []() {}); + Environment environment{}; + environment.loadFromCurrent(); + process->spawn("sh", {"-c", "read line; echo \"got $line\"; echo oops >&2"}, environment); + process->startReading( + [this](std::string_view data) + { + return append(output_, data); + }, + [this](std::string_view data) + { + return append(errors_, data); + } + ); + process->write("hello\n"); + + EXPECT_TRUE(waitFor( + [this]() + { + return output_.find("got hello\n") != std::string::npos && errors_.find("oops\n") != std::string::npos; + } + )) << "stdout: '" << output_ << "', stderr: '" << errors_ << "'"; + EXPECT_EQ(process->exitSync(std::chrono::seconds{5}), 0); + } +#endif +} diff --git a/backend/test/backend/test_program_options.hpp b/backend/test/backend/test_program_options.hpp new file mode 100644 index 00000000..c3d13712 --- /dev/null +++ b/backend/test/backend/test_program_options.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include + +#include + +#include + +namespace Test +{ + namespace + { + std::expected parse(std::vector arguments) + { + arguments.insert(arguments.begin(), "nui-sftp"); + return parseProgramOptions(static_cast(arguments.size()), arguments.data()); + } + } + + TEST(ProgramOptionsTests, NoArgumentsStartTheProgram) + { + EXPECT_TRUE(parse({}).has_value()); + EXPECT_TRUE(parse({"--console"}).has_value()); + EXPECT_TRUE(parse({"--enable-dev-tools"}).has_value()); + } + + TEST(ProgramOptionsTests, HelpIsPrintedWithoutError) + { + const auto result = parse({"--help"}); + ASSERT_FALSE(result.has_value()); + EXPECT_FALSE(result.error().isError); + EXPECT_NE(result.error().message.find("--enable-dev-tools"), std::string::npos); + } + + TEST(ProgramOptionsTests, UnknownOptionIsAnErrorWithTheHelp) + { + const auto result = parse({"--hepl"}); + ASSERT_FALSE(result.has_value()); + EXPECT_TRUE(result.error().isError); + EXPECT_NE(result.error().message.find("--hepl"), std::string::npos); + EXPECT_NE(result.error().message.find("--console"), std::string::npos) << "the help follows the error"; + } +} diff --git a/command-store/test/command-store/command_store.test.cpp b/command-store/test/command-store/command_store.test.cpp index ec391dbb..e58e2f3f 100644 --- a/command-store/test/command-store/command_store.test.cpp +++ b/command-store/test/command-store/command_store.test.cpp @@ -35,6 +35,16 @@ namespace using CommandStore::SortOrder; using CommandStore::Store; + /** @brief Root ignores file permissions, Windows has no root. */ + bool runningAsRoot() + { +#ifdef _WIN32 + return false; +#else + return geteuid() == 0; +#endif + } + class CommandStoreTests : public ::testing::Test { protected: @@ -774,7 +784,7 @@ namespace TEST_F(CommandStoreTests, ReadOnlyDatabaseFileFailsToOpen) { - if (geteuid() == 0) + if (runningAsRoot()) GTEST_SKIP() << "root ignores file permissions"; openStore(); closeStore(); @@ -790,7 +800,7 @@ namespace TEST_F(CommandStoreTests, ReadOnlySharedMemoryFileFailsToOpen) { - if (geteuid() == 0) + if (runningAsRoot()) GTEST_SKIP() << "root ignores file permissions"; openStore(); closeStore(); diff --git a/frontend/include/frontend/dialog/archive_transfer_dialog.hpp b/frontend/include/frontend/dialog/archive_transfer_dialog.hpp index c591ed5a..aab32070 100644 --- a/frontend/include/frontend/dialog/archive_transfer_dialog.hpp +++ b/frontend/include/frontend/dialog/archive_transfer_dialog.hpp @@ -36,6 +36,12 @@ std::string archiveCodecExtension(ArchiveCodec codec); */ ArchiveCodec archiveCodecFromExtension(std::string const& extension); +/** + * @brief The value of TarArchive::Compression the backend expects for @p codec: None=1, Gzip=2, Bzip2=3, Zstd=4, + * Xz=5 (Auto=0 is never sent). + */ +int backendCompressionCode(ArchiveCodec codec); + /** * @brief Structured result handed to the caller when the dialog is confirmed. * diff --git a/frontend/include/frontend/dialog/confirm_dialog.hpp b/frontend/include/frontend/dialog/confirm_dialog.hpp index 65b26c84..a8db3a48 100644 --- a/frontend/include/frontend/dialog/confirm_dialog.hpp +++ b/frontend/include/frontend/dialog/confirm_dialog.hpp @@ -54,6 +54,10 @@ class ConfirmDialog std::function buttonPressed)> onClose = [](auto) {}; }; + /** + * @brief Shows the dialog. A dialog still on screen is replaced, its onClose runs first without a button, so + * every onClose runs exactly once. + */ void open(OpenOptions const& options); private: diff --git a/frontend/include/frontend/file_explorer/local_side_model.hpp b/frontend/include/frontend/file_explorer/local_side_model.hpp index 0381147c..ca034259 100644 --- a/frontend/include/frontend/file_explorer/local_side_model.hpp +++ b/frontend/include/frontend/file_explorer/local_side_model.hpp @@ -11,7 +11,6 @@ #include #include -#include #include #include @@ -30,7 +29,6 @@ class LocalSideModel : public SideModel , public NuiFileExplorer::IPlacesProvider , public NuiFileExplorer::IDrivesProvider - , public NuiFileExplorer::IFavoritesProvider { public: LocalSideModel( @@ -41,14 +39,6 @@ class LocalSideModel ArchiveTransferDialog* archiveTransferDialog ); - /** - * @brief Sets the callback invoked whenever the favorites list changes. The callback - * receives the updated list as strings so the caller can persist it. - * - * @param callback Invoked on every add/remove. - */ - void setOnFavoritesChanged(std::function)> callback); - // --- IPlacesProvider --- NuiFileExplorer::IPlacesProvider* placesProvider() override { @@ -62,25 +52,14 @@ class LocalSideModel NuiFileExplorer::IDrivesProvider* drivesProvider() override; void requestDrives(std::function)> callback) override; - // --- IFavoritesProvider --- - NuiFileExplorer::IFavoritesProvider* favoritesProvider() override - { - return this; - } - std::shared_ptr>> favorites() const override; - void addFavorite(std::filesystem::path const& path) override; - void removeFavorite(std::filesystem::path const& path) override; - void onActivateItem(NuiFileExplorer::Item const& item) override; void onNewItem(NuiFileExplorer::Item::Type type) override; void onDelete(std::vector const& items) override; void onTransfer(std::vector const& items, std::optional const& subDir) override; /** - * @brief "Transfer as Archive" entry point: opens the archive-transfer dialog - * and, on confirm, kicks off an upload of the selected items packed into a - * single tar archive on the remote side. Backend wiring is still pending; for - * now the onConfirm just logs the intent. + * @brief Asks for the name, codec and level of an archive and queues an upload of @p items packed into one + * tar archive in the listed remote directory. */ void onTransferAsArchive(std::vector const& items); std::vector @@ -117,7 +96,7 @@ class LocalSideModel std::expected currentLocalPath() const; bool isComplete() const override; void navigateTo(std::filesystem::path const& path) override; - bool hasParent(std::filesystem::path const& directory) const override; + std::optional parentOf(std::filesystem::path const& directory) const override; void generatePathBoxSuggestions( std::filesystem::path const& path, @@ -126,33 +105,27 @@ class LocalSideModel ) override; private: - void enqueueRefresh(bool otherModel); - - /** - * @brief Renames @p from to @p to, asks before replacing an existing item and retries with @p overwrite. - */ - void renameItem(Paths::LocalPath const& from, Paths::LocalPath const& to, bool overwrite); - /** * @brief Where @p item is: a listed item below the directory it was listed from, a dropped one at its own * absolute path. A listed name that is no single path segment, like "..", names no item. */ std::expected localPathOf(NuiFileExplorer::Item const& item) const; + std::expected itemPathText(NuiFileExplorer::Item const& item) const override; + /** - * @brief Uploads @p candidates, asking per existing destination whether to overwrite it. + * @brief Queues the uploads the user accepted, a single file as a transfer of its own. */ - void uploadItemsConfirmed( - std::vector candidates, - std::shared_ptr> existsResults, - std::size_t index = 0, - bool overwriteNever = false, - bool overwriteAlways = false, - std::shared_ptr> accepted = nullptr - ); + void enqueueUploads(std::vector accepted); void enqueueSingleUpload(SharedData::BulkUploadEntry const& entry, bool allowOverwrite, bool insertRefresh); + /** + * @brief Creates the item at @p path through the backend @p function, which takes the path as @p pathArgument, + * shows the listing again on success and the failure under @p failedTitle otherwise. + */ + void createItem(char const* function, char const* pathArgument, Paths::LocalPath const& path, std::string failedTitle); + /** * @brief Deletes @p targets through the backend at once, each item by itself, and refreshes the listing. */ @@ -174,7 +147,5 @@ class LocalSideModel RemoteSideModel* remoteModel_{nullptr}; NuiFileExplorer::PathSuggestionCache pathSuggestionCache_; - std::shared_ptr>> favorites_; - std::function)> onFavoritesChanged_; SharedData::OpenerCapabilities openerCaps_{}; }; \ No newline at end of file diff --git a/frontend/include/frontend/file_explorer/name_rejection.hpp b/frontend/include/frontend/file_explorer/name_rejection.hpp new file mode 100644 index 00000000..6c5972ee --- /dev/null +++ b/frontend/include/frontend/file_explorer/name_rejection.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include + +namespace FileExplorer +{ + /** + * @brief The language key below "fileExplorer.nameRejection" that explains why a new name was refused for + * @p rejection, "Other" for a rejection no single name gets. + */ + constexpr char const* nameRejectionKey(Paths::PathRejection rejection) + { + switch (rejection) + { + case Paths::PathRejection::Empty: + return "Empty"; + case Paths::PathRejection::DotOrDotDot: + return "DotOrDotDot"; + case Paths::PathRejection::ContainsSeparator: + return "ContainsSeparator"; + case Paths::PathRejection::ContainsNul: + return "ContainsNul"; + case Paths::PathRejection::InvalidUtf8: + return "InvalidUtf8"; + case Paths::PathRejection::ReservedCharacter: + return "ReservedCharacter"; + case Paths::PathRejection::ReservedDeviceName: + return "ReservedDeviceName"; + case Paths::PathRejection::TrailingDotOrSpace: + return "TrailingDotOrSpace"; + case Paths::PathRejection::NameTooLong: + return "NameTooLong"; + case Paths::PathRejection::NotAbsolute: + case Paths::PathRejection::EscapesRoot: + case Paths::PathRejection::DevicePath: + case Paths::PathRejection::UnknownParent: + return "Other"; + } + return "Other"; + } +} diff --git a/frontend/include/frontend/file_explorer/open_local_file.hpp b/frontend/include/frontend/file_explorer/open_local_file.hpp index 3b8e4510..e2f2b62f 100644 --- a/frontend/include/frontend/file_explorer/open_local_file.hpp +++ b/frontend/include/frontend/file_explorer/open_local_file.hpp @@ -2,12 +2,24 @@ #include #include +#include + +#include namespace FileExplorer { /** * @brief Opens the local file @p path with its application. A file that can run code is opened only after the - * user confirmed it, when the settings ask for that; errors are shown in @p dialog. + * user confirmed it, when the settings ask for that; errors are shown in @p dialog. Replies that arrive + * after @p owner is gone are dropped. + * + * @param onDeclined Runs when the user closes that question without opening the file, while @p owner exists. */ - void openLocalFile(ConfirmDialog& dialog, Paths::LocalPath const& path, bool openWith); + void openLocalFile( + Utility::LifetimeToken const& owner, + ConfirmDialog& dialog, + Paths::LocalPath const& path, + bool openWith, + std::function onDeclined = {} + ); } diff --git a/frontend/include/frontend/file_explorer/overwrite_decisions.hpp b/frontend/include/frontend/file_explorer/overwrite_decisions.hpp new file mode 100644 index 00000000..866083bf --- /dev/null +++ b/frontend/include/frontend/file_explorer/overwrite_decisions.hpp @@ -0,0 +1,127 @@ +#pragma once + +#include +#include +#include + +namespace FileExplorer +{ + /** + * @brief The user's answer to "the destination exists, overwrite it?". + */ + enum class OverwriteAnswer + { + /** @brief Overwrite this destination. */ + Yes, + /** @brief Skip this candidate. */ + No, + /** @brief Overwrite this and every later existing destination. */ + All, + /** @brief Skip this and every later candidate whose destination exists. */ + None, + /** @brief Stop asking, only what was accepted so far is transferred. */ + Cancel, + }; + + /** + * @brief @p entry with the user's consent to replace its existing destination. + */ + template + EntryT approvedForOverwrite(EntryT entry) + { + entry.mayOverwrite = true; + return entry; + } + + /** + * @brief Decides which transfer candidates go ahead, asking about each one whose destination exists. + * + * A candidate whose destination did not exist when probed is accepted without consent to overwrite, so one that + * appears before the transfer runs fails instead of being replaced. Only candidates the user approved (Yes, All) + * may replace their destination. + */ + template + class OverwriteDecisions + { + public: + /** + * @param candidates The entries to transfer, each with a destination and a mayOverwrite flag. + * @param exists Index aligned with @p candidates: whether the destination exists. A missing answer counts as + * existing, so the user is asked. + */ + OverwriteDecisions(std::vector candidates, std::vector exists) + : candidates_{std::move(candidates)} + , exists_{std::move(exists)} + {} + + /** + * @brief Accepts or skips every candidate up to the next one that needs an answer. + * + * @return That candidate, nullptr when every candidate is decided. + */ + EntryT const* nextQuestion() + { + while (next_ < candidates_.size()) + { + if (!destinationExists(next_)) + accepted_.push_back(candidates_[next_]); + else if (overwriteAll_) + accepted_.push_back(approvedForOverwrite(candidates_[next_])); + else if (!skipAll_) + return &candidates_[next_]; + ++next_; + } + return nullptr; + } + + /** + * @brief Applies @p answer to the candidate @ref nextQuestion returned. + */ + void answer(OverwriteAnswer answer) + { + if (next_ >= candidates_.size()) + return; + switch (answer) + { + case OverwriteAnswer::All: + overwriteAll_ = true; + [[fallthrough]]; + case OverwriteAnswer::Yes: + accepted_.push_back(approvedForOverwrite(candidates_[next_])); + ++next_; + return; + case OverwriteAnswer::None: + skipAll_ = true; + [[fallthrough]]; + case OverwriteAnswer::No: + ++next_; + return; + case OverwriteAnswer::Cancel: + next_ = candidates_.size(); + return; + } + } + + /** + * @brief The accepted candidates in their order, taken out of this object. + */ + std::vector takeAccepted() + { + return std::exchange(accepted_, {}); + } + + private: + bool destinationExists(std::size_t index) const + { + return index >= exists_.size() || exists_[index]; + } + + private: + std::vector candidates_; + std::vector exists_; + std::vector accepted_{}; + std::size_t next_{0}; + bool overwriteAll_{false}; + bool skipAll_{false}; + }; +} diff --git a/frontend/include/frontend/file_explorer/remote_side_model.hpp b/frontend/include/frontend/file_explorer/remote_side_model.hpp index b20603ee..0bff1e52 100644 --- a/frontend/include/frontend/file_explorer/remote_side_model.hpp +++ b/frontend/include/frontend/file_explorer/remote_side_model.hpp @@ -9,7 +9,6 @@ #include #include -#include #include #include @@ -30,7 +29,6 @@ class LocalSideModel; class RemoteSideModel : public SideModel , public NuiFileExplorer::IPlacesProvider - , public NuiFileExplorer::IFavoritesProvider { public: RemoteSideModel( @@ -42,14 +40,6 @@ class RemoteSideModel ArchiveTransferDialog* archiveTransferDialog ); - /** - * @brief Sets the callback invoked whenever the favorites list changes. The callback - * receives the updated list as strings so the caller can persist it. - * - * @param callback Invoked on every add/remove. - */ - void setOnFavoritesChanged(std::function)> callback); - // --- IPlacesProvider --- NuiFileExplorer::IPlacesProvider* placesProvider() override { @@ -65,25 +55,14 @@ class RemoteSideModel */ void setRemoteHome(std::filesystem::path home, bool readUserDirectories); - // --- IFavoritesProvider --- - NuiFileExplorer::IFavoritesProvider* favoritesProvider() override - { - return this; - } - std::shared_ptr>> favorites() const override; - void addFavorite(std::filesystem::path const& path) override; - void removeFavorite(std::filesystem::path const& path) override; - void onActivateItem(NuiFileExplorer::Item const& item) override; void onNewItem(NuiFileExplorer::Item::Type type) override; void onDelete(std::vector const& items) override; void onTransfer(std::vector const& items, std::optional const& subDir) override; /** - * @brief "Transfer as Archive" entry point: opens the archive-transfer dialog - * and, on confirm, kicks off a download of the selected items packed into a - * single tar archive on the local side. Backend wiring is still pending; for - * now the onConfirm just logs the intent. + * @brief Asks for the name, codec and level of an archive and queues a download of @p items packed into one + * tar archive in the listed local directory. */ void onTransferAsArchive(std::vector const& items); std::vector @@ -103,6 +82,7 @@ class RemoteSideModel } void navigateTo(std::filesystem::path const& path) override; void setLocalModel(LocalSideModel* model); + std::optional parentOf(std::filesystem::path const& directory) const override; /** * @brief The directory the shown items were listed from, an error before the first listing. @@ -118,35 +98,18 @@ class RemoteSideModel ) override; private: - void enqueueRefresh(bool otherModel); - - /** - * @brief Renames @p from to @p to, asks before replacing an existing item and retries with @p overwrite. - */ - void renameItem(Paths::RemotePath const& from, Paths::RemotePath const& to, bool overwrite); - /** * @brief Where @p item is on the server, below the directory it was listed from. A listed name that is no * single path segment, like "..", names no item. */ std::expected remotePathOf(NuiFileExplorer::Item const& item) const; - /** - * @brief @p name directly below the directory the shown items were listed from. - */ - std::expected childOfCurrentPath(std::string_view name) const; + std::expected itemPathText(NuiFileExplorer::Item const& item) const override; /** - * @brief Downloads @p candidates, asking per existing destination whether to overwrite it. + * @brief Queues the downloads the user accepted, a single file as a transfer of its own. */ - void downloadItemsConfirmed( - std::vector candidates, - std::shared_ptr> existsResults, - std::size_t index = 0, - bool overwriteNever = false, - bool overwriteAlways = false, - std::shared_ptr> accepted = nullptr - ); + void enqueueDownloads(std::vector accepted); void enqueueSingleDownload(SharedData::BulkDownloadEntry const& entry, bool allowOverwrite, bool insertRefresh); @@ -160,9 +123,11 @@ class RemoteSideModel void enqueueDeletes(std::vector nonEmpties, std::vector filesAndEmptyDirs); - void continueDeletion(); - void onDownloadAndOpen(NuiFileExplorer::Item const& item, bool openWith); - void onWatchDownloadAndOpen(NuiFileExplorer::Item const& item, bool openWith); + /** + * @brief Downloads @p item into a file tracking instance and opens it, with @p watch also uploads it again when + * it changes. + */ + void onDownloadAndOpen(NuiFileExplorer::Item const& item, bool openWith, bool watch); void onFileTrackingInstanceCreated( NuiFileExplorer::Item const& item, bool openWith, @@ -186,6 +151,11 @@ class RemoteSideModel Paths::LocalPath const& localPath, bool openWith ); + /** + * @brief What happens when the user decides not to open the download @p localPath of the instance + * @p instanceId: the download is removed and its watch ends, see FileTrackingPanel::discard. + */ + std::function discardWhenDeclined(Ids::InstanceId const& instanceId, Paths::LocalPath const& localPath); private: void placesFromHomeListing(); @@ -210,6 +180,4 @@ class RemoteSideModel LocalSideModel* localModel_{nullptr}; FileTrackingPanel* fileTracking_{nullptr}; NuiFileExplorer::PathSuggestionCache pathSuggestionCache_; - std::shared_ptr>> favorites_; - std::function)> onFavoritesChanged_; }; \ No newline at end of file diff --git a/frontend/include/frontend/file_explorer/side_model.hpp b/frontend/include/frontend/file_explorer/side_model.hpp index 11be4529..8cd6753c 100644 --- a/frontend/include/frontend/file_explorer/side_model.hpp +++ b/frontend/include/frontend/file_explorer/side_model.hpp @@ -4,16 +4,35 @@ #include #include #include +#include +#include +#include #include #include #include #include +#include #include +#include #include +#include +#include #include +#include + +#include +#include +#include +#include +#include +#include #include +#include +#include +#include +#include // This should be an assertion, but assert failures are hard to debug in the frontend, // log messages and noop is much easier to deal with. @@ -38,7 +57,9 @@ #define CHECK_COMPLETE() CHECK_COMPLETE_RET() // Incomplete class, provides base functionality -class SideModel : public NuiFileExplorer::ISideModel +class SideModel + : public NuiFileExplorer::ISideModel + , public NuiFileExplorer::IFavoritesProvider { public: SideModel( @@ -109,19 +130,60 @@ class SideModel : public NuiFileExplorer::ISideModel return {}; } + /** + * @brief Sets the callback invoked whenever the favorites list changes. The callback + * receives the updated list as strings so the caller can persist it. + * + * @param callback Invoked on every add/remove. + */ + void setOnFavoritesChanged(std::function)> callback); + + NuiFileExplorer::IFavoritesProvider* favoritesProvider() override + { + return this; + } + std::shared_ptr>> favorites() const override; + void addFavorite(std::filesystem::path const& path) override; + void removeFavorite(std::filesystem::path const& path) override; + protected: // Design smell: virtual bool isComplete() const; /** - * @brief A new name is a single path segment: not empty, not "." or "..", without '/', '\\' or NUL. + * @brief Replaces the favorites without telling the callback, for the list the side starts with. + */ + void setFavorites(std::vector const& favorites); + + /** + * @brief The context menu entries that add the one selected directory to the favorites or remove it, none for + * another selection. + */ + std::vector + favoriteMenuItems(std::vector const& selectedItems); + + /** + * @brief The UTF-8 path of @p item on its side, refused for the ".." entry and for names that are no single + * path segment. + */ + virtual std::expected itemPathText(NuiFileExplorer::Item const& item) const = 0; + + /** + * @brief The full path of each of @p items for the list of a confirmation, the listed path for an item whose + * name is no single path segment. + */ + std::vector pathList(std::vector const& items) const; + + /** + * @brief Shows the directory @p item stands for: the parent for "..", else the item's own path, the side may + * already be loading another directory. */ - static bool isValidItemName(std::string_view name); + void navigateInto(NuiFileExplorer::Item const& item); /** - * @brief Tells the user @p name cannot be used as a name. + * @brief Tells the user the name in @p error cannot be used and why. */ - void showInvalidItemName(std::string const& name); + void showInvalidItemName(Paths::PathError const& error); /** * @brief Tells the user an action was refused because of the path in @p error. @@ -139,13 +201,357 @@ class SideModel : public NuiFileExplorer::ISideModel void confirmReplaceOnRename(std::string const& name, std::function onReplace); /** - * @brief The directory has a parent to offer as "..". + * @brief Texts of the questions a transfer asks. + */ + struct TransferPrompt + { + std::string headerText{}; + /** @brief Question for one item, formatted with the destination name and @ref destinationDirectory. */ + std::string singleText{}; + /** @brief Question for several items, formatted with their count and @ref destinationDirectory. */ + std::string multipleText{}; + std::string destinationDirectory{}; + std::string overwriteHeaderText{}; + std::string overwriteText{}; + }; + + /** + * @brief One transfer entry per item of @p items: the source @p sourceOf gives for it, the destination with the + * source's name in @p destinationDirectory. Refused when an item names no source or its name cannot be + * used at the destination. + */ + template + static std::expected, Paths::PathError> transferCandidates( + std::vector const& items, + SourceOfT const& sourceOf, + DirectoryT const& destinationDirectory + ) + { + std::vector candidates; + candidates.reserve(items.size()); + for (auto const& item : items) + { + auto source = sourceOf(item); + if (!source) + return std::unexpected(source.error()); + const auto name = source->filename(); + if (!name) + return std::unexpected( + Paths::PathError{.reason = Paths::PathRejection::Empty, .input = FileExplorer::pathText(*source)} + ); + auto destination = FileExplorer::childOf(destinationDirectory, *name); + if (!destination) + return std::unexpected(destination.error()); + EntryT entry{ + .source = *std::move(source), + .destination = *std::move(destination), + .sizeBytes = item.size, + .isDirectory = item.isDirectory(), + }; + if constexpr (requires { entry.mtime; }) + { + entry.mtime = item.mtime; + entry.mtimeNsec = item.mtimeNsec; + } + candidates.push_back(std::move(entry)); + } + return candidates; + } + + /** + * @brief Asks whether to transfer @p candidates, checks which destinations exist, asks about each existing one + * and hands the accepted entries to @p onAccepted. Nothing is transferred when the check fails. + */ + template + void confirmTransfer( + std::vector candidates, + TransferPrompt prompt, + std::function)> onAccepted + ) + { + if (candidates.empty()) + return; + const auto text = candidates.size() > 1 + ? fmt::format(fmt::runtime(prompt.multipleText), candidates.size(), prompt.destinationDirectory) + : fmt::format( + fmt::runtime(prompt.singleText), + candidates.front().destination.filename()->view(), + prompt.destinationDirectory + ); + std::vector listItems; + listItems.reserve(candidates.size()); + std::ranges::transform( + candidates, + std::back_inserter(listItems), + [](EntryT const& candidate) + { + return ConfirmDialog::OpenOptions::ListElement{.text = FileExplorer::pathText(candidate.source)}; + } + ); + + confirmDialog_->open({ + .styleVariant = ScriptNuiComponents::StyleVariant::Primary, + .headerText = prompt.headerText, + .text = text, + .buttons = ConfirmDialog::Button::Yes | ConfirmDialog::Button::No, + .focusButton = ConfirmDialog::Button::Yes, + .listItems = std::move(listItems), + .onClose = lifetime_.guarded( + [this, candidates = std::move(candidates), prompt, onAccepted = std::move(onAccepted)]( + std::optional button + ) mutable + { + if (button != ConfirmDialog::Button::Yes) + { + Log::info("Transfer cancelled"); + return; + } + checkDestinations(std::move(candidates), std::move(prompt), std::move(onAccepted)); + } + ), + }); + } + + /** + * @brief Asks for a new name of @p item and renames it at @p from, next to itself. Refuses an item that names no + * path. + */ + template + void promptRename(NuiFileExplorer::Item const& item, std::expected const& from) + { + if (!from) + return showPathRefused( + language->get(isLeft() ? "localSideModel" : "remoteSideModel", "renameFailed"), from.error() + ); + + inputDialog_->open({ + .whatFor = language->get(isLeft() ? "localSideModel" : "remoteSideModel", "rename"), + .prompt = fmt::format( + fmt::runtime(language->get(isLeft() ? "localSideModel" : "remoteSideModel", "renamePrompt")), + item.name + ), + .headerText = fmt::format( + fmt::runtime(language->get(isLeft() ? "localSideModel" : "remoteSideModel", "renameWithItem")), + item.name + ), + .initialValue = item.name, + .isPassword = false, + .onConfirm = lifetime_.guarded( + [this, from = *from, currentName = item.name](std::optional const& name) + { + if (!name || name->empty() || *name == currentName) + return; + const auto directory = from.parent(); + if (!directory) + return showPathRefused( + language->get(isLeft() ? "localSideModel" : "remoteSideModel", "renameFailed"), + Paths::PathError{ + .reason = Paths::PathRejection::EscapesRoot, .input = FileExplorer::pathText(from) + } + ); + const auto to = FileExplorer::childOf(*directory, std::string_view{*name}); + if (!to) + return showInvalidItemName(to.error()); + + Log::info("Renaming '{}' to '{}'.", FileExplorer::pathText(from), *name); + renameItem(from, *to, false); + } + ), + }); + } + + /** + * @brief Asks for the name of a new item of @p type and hands its path in the listed directory to @p create. + * Refuses a name the side cannot use for a single item. */ - virtual bool hasParent(std::filesystem::path const& directory) const + template + void promptNewItem(NuiFileExplorer::Item::Type type, std::function create) + { + if (type != NuiFileExplorer::Item::Type::Directory && type != NuiFileExplorer::Item::Type::Regular) + return Log::error("New items of type {} are not supported.", static_cast(type)); + + const bool isDirectory = type == NuiFileExplorer::Item::Type::Directory; + inputDialog_->open({ + .whatFor = language->get( + isLeft() ? "localSideModel" : "remoteSideModel", isDirectory ? "newDirectory" : "newFile" + ), + .prompt = language->get( + isLeft() ? "localSideModel" : "remoteSideModel", + isDirectory ? "enterNewDirectoryName" : "enterNewFileName" + ), + .headerText = language->get( + isLeft() ? "localSideModel" : "remoteSideModel", isDirectory ? "createNewDirectory" : "createNewFile" + ), + .isPassword = false, + .onConfirm = lifetime_.guarded( + [this, create = std::move(create)](std::optional const& name) + { + if (!name) + return; + const auto directory = FileExplorer::parsePath(listedPath_.generic_string()); + if (!directory) + return showPathRefused( + language->get(isLeft() ? "localSideModel" : "remoteSideModel", "createItemFailedTitle"), + directory.error() + ); + const auto path = FileExplorer::childOf(*directory, std::string_view{*name}); + if (!path) + return showInvalidItemName(path.error()); + Log::info("Creating new item: {}", FileExplorer::pathText(*path)); + create(*path); + } + ), + }); + } + + /** + * @brief Renames @p from to @p to, asks before replacing an existing item and retries with @p overwrite. + */ + template + void renameItem(PathT const& from, PathT const& to, bool overwrite) + { + auto onRenamed = lifetime_.guarded( + [this, from, to, overwrite](bool success, std::string const& info) + { + if (success) + { + Log::info("Renamed '{}' to '{}'.", FileExplorer::pathText(from), FileExplorer::pathText(to)); + return onRefresh(); + } + if (info == "backend.rename.targetExists" && !overwrite) + { + return confirmReplaceOnRename( + std::string{to.filename()->view()}, + lifetime_.guarded( + [this, from, to]() + { + renameItem(from, to, true); + } + ) + ); + } + const auto reason = language->translate(info); + Log::error( + "Failed to rename '{}' to '{}': {}", FileExplorer::pathText(from), FileExplorer::pathText(to), reason + ); + confirmDialog_->open({ + .styleVariant = ScriptNuiComponents::StyleVariant::Danger, + .headerText = language->get(isLeft() ? "localSideModel" : "remoteSideModel", "renameFailed"), + .text = fmt::format( + fmt::runtime( + language->get(isLeft() ? "localSideModel" : "remoteSideModel", "failedToRenameItem") + ), + FileExplorer::pathText(from), + FileExplorer::pathText(to), + reason + ), + .buttons = ConfirmDialog::Button::Ok, + }); + } + ); + if constexpr (std::is_same_v) + FileEngine::renameLocal(from, to, overwrite, std::move(onRenamed)); + else + fileEngine_->rename(from, to, overwrite, std::move(onRenamed)); + } + + private: + template + void checkDestinations( + std::vector candidates, + TransferPrompt prompt, + std::function)> onAccepted + ) + { + using DestinationT = decltype(EntryT::destination); + std::vector destinations; + destinations.reserve(candidates.size()); + std::ranges::transform(candidates, std::back_inserter(destinations), &EntryT::destination); + + auto onExists = lifetime_.guarded( + [this, candidates = std::move(candidates), prompt = std::move(prompt), onAccepted = std::move(onAccepted)]( + std::vector exists, std::string const& info + ) mutable + { + // An unknown destination is never taken for absent, the transfer stops instead. + if (exists.size() != candidates.size()) + return showExistenceCheckFailed( + exists.empty() ? language->translate(info) + : fmt::format("{} of {} answers", exists.size(), candidates.size()) + ); + askOverwrites( + std::make_shared>(std::move(candidates), std::move(exists)), + std::make_shared(std::move(prompt)), + std::move(onAccepted) + ); + } + ); + if constexpr (std::is_same_v) + FileEngine::existsBatchLocal(destinations, std::move(onExists)); + else + fileEngine_->existsBatchRemote(destinations, std::move(onExists)); + } + + template + void askOverwrites( + std::shared_ptr> decisions, + std::shared_ptr prompt, + std::function)> onAccepted + ) { - return directory.has_relative_path(); + const auto* question = decisions->nextQuestion(); + if (question == nullptr) + { + auto accepted = decisions->takeAccepted(); + if (!accepted.empty()) + onAccepted(std::move(accepted)); + return; + } + + const auto destination = FileExplorer::pathText(question->destination); + Log::info("The destination '{}' exists, asking whether to overwrite it.", destination); + confirmDialog_->open({ + .styleVariant = ScriptNuiComponents::StyleVariant::Primary, + .headerText = prompt->overwriteHeaderText, + .text = prompt->overwriteText, + .buttons = ConfirmDialog::Button::Yes | ConfirmDialog::Button::No | ConfirmDialog::Button::All | + ConfirmDialog::Button::None | ConfirmDialog::Button::Cancel, + .focusButton = ConfirmDialog::Button::No, + .listItems = {{.text = destination}}, + .onClose = lifetime_.guarded( + [this, decisions, prompt, onAccepted](std::optional button) + { + decisions->answer(overwriteAnswerOf(button)); + askOverwrites(decisions, prompt, onAccepted); + } + ), + }); } + void notifyFavoritesChanged(); + + /** + * @brief The icon URL the grid shows for @p entry. + */ + std::string iconOf(SharedData::DirectoryEntry const& entry) const; + + /** + * @brief The answer a button of the overwrite question stands for, closing the dialog cancels. + */ + static FileExplorer::OverwriteAnswer overwriteAnswerOf(std::optional button); + + protected: + + /** + * @brief The parent of the listed @p directory to offer as "..", nullopt for a root. + */ + virtual std::optional parentOf(std::filesystem::path const& directory) const = 0; + + /** + * @brief Shows the directory of the shown items again after a navigation failed, without listing it again. + */ + void revertNavigation(); + /** * @brief Shows the listing of @p directory. A reply for a directory the side has navigated away from is dropped, * so no item carries the path of another directory. @@ -172,6 +578,10 @@ class SideModel : public NuiFileExplorer::ISideModel std::function refreshCallback_{nullptr}; bool reapplySelectionOnce_{false}; std::string dropMetadata_{}; + std::shared_ptr>> favorites_{ + std::make_shared>>() + }; + std::function)> onFavoritesChanged_{}; /** @brief Guards callbacks that can arrive after the side model is gone, like RPC replies and dialog closes. */ Utility::LifetimeToken lifetime_{}; }; \ No newline at end of file diff --git a/frontend/include/frontend/file_explorer/side_paths.hpp b/frontend/include/frontend/file_explorer/side_paths.hpp new file mode 100644 index 00000000..72f8a410 --- /dev/null +++ b/frontend/include/frontend/file_explorer/side_paths.hpp @@ -0,0 +1,148 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include + +namespace FileExplorer +{ + /** + * @brief The drop target that stands for the parent directory, the ".." entry of a listing. + */ + inline constexpr std::string_view parentDirectoryTarget{".."}; + + inline std::string const& pathText(Paths::RemotePath const& path) + { + return path.value(); + } + + inline std::string const& pathText(Paths::LocalPath const& path) + { + return path.toUtf8(); + } + + /** + * @brief Parses the UTF-8 text of an absolute path of the side @p PathT names. + */ + template + std::expected parsePath(std::string_view utf8); + + template <> + inline std::expected parsePath(std::string_view utf8) + { + return Paths::RemotePath::make(utf8); + } + + template <> + inline std::expected parsePath(std::string_view utf8) + { + return Paths::LocalPath::fromUtf8(utf8); + } + + /** + * @brief @p name directly below @p directory, refused when it is no single name the server allows. + */ + inline std::expected + childOf(Paths::RemotePath const& directory, std::string_view name) + { + return Paths::PathSegment::make(name).transform( + [&directory](Paths::PathSegment const& segment) + { + return directory / segment; + } + ); + } + + inline std::expected + childOf(Paths::RemotePath const& directory, Paths::PathSegment const& name) + { + return directory / name; + } + + /** + * @brief @p name directly below @p directory, refused when it is no single name the platform of @p directory + * allows. + */ + inline std::expected + childOf(Paths::LocalPath const& directory, std::string_view name) + { + return Paths::LocalPathSegment::make(name, directory.platform()) + .transform( + [&directory](Paths::LocalPathSegment const& segment) + { + return directory / segment; + } + ); + } + + inline std::expected + childOf(Paths::LocalPath const& directory, Paths::PathSegment const& name) + { + return Paths::LocalPathSegment::make(name, directory.platform()) + .transform( + [&directory](Paths::LocalPathSegment const& segment) + { + return directory / segment; + } + ); + } + + /** + * @brief The directory a drop or transfer goes into: @p directory itself without @p target, its parent for + * @ref parentDirectoryTarget, else the child named @p target. + */ + template + std::expected + targetDirectoryOf(PathT const& directory, std::optional const& target) + { + if (!target) + return directory; + if (*target == parentDirectoryTarget) + { + if (auto parent = directory.parent(); parent) + return *std::move(parent); + return std::unexpected( + Paths::PathError{.reason = Paths::PathRejection::EscapesRoot, .input = std::string{*target}} + ); + } + return childOf(directory, *target); + } + + /** + * @brief The parent of the listed @p directory, nullopt for a root (like "/", "C:/" or "//server/share") and + * for text that is no absolute path of the side. + */ + template + std::optional parentOf(std::string_view directory) + { + const auto parsed = parsePath(directory); + if (!parsed) + return std::nullopt; + return parsed->parent().transform( + [](PathT const& parent) + { + return pathText(parent); + } + ); + } + + /** + * @brief The directory to list for @p path: the normalized absolute path of the side, which has no trailing + * separator except at a root. Text the side does not take as an absolute path is normalized lexically, + * without trailing separators, and the listing then reports why it fails. + */ + template + std::string navigationTarget(std::string_view path) + { + if (const auto parsed = parsePath(path); parsed) + return pathText(*parsed); + auto normal = std::filesystem::path{std::string{path}}.lexically_normal().generic_string(); + while (normal.size() > 1 && normal.back() == '/') + normal.pop_back(); + return normal; + } +} diff --git a/frontend/include/frontend/session/session_helpers.hpp b/frontend/include/frontend/session/session_helpers.hpp index 857217e5..e16649a6 100644 --- a/frontend/include/frontend/session/session_helpers.hpp +++ b/frontend/include/frontend/session/session_helpers.hpp @@ -1,5 +1,7 @@ #pragma once +#include + #include #include @@ -20,14 +22,6 @@ namespace SessionInternal */ void writeChannelContentToFile(std::filesystem::path const& filePath, std::string const& content); - enum class WriteOutcome - { - Written, - /// The file existed and was left alone. - Exists, - Failed, - }; - /** * @brief Writes @p content to the new file @p filePath, an existing file is never replaced. */ diff --git a/frontend/include/frontend/session_components/file_tracking.hpp b/frontend/include/frontend/session_components/file_tracking.hpp index d884127c..97799274 100644 --- a/frontend/include/frontend/session_components/file_tracking.hpp +++ b/frontend/include/frontend/session_components/file_tracking.hpp @@ -48,6 +48,13 @@ class FileTrackingPanel Paths::LocalPath const& localPath ); + /** + * @brief Removes @p localPath, a download of the instance @p instanceId the user decided not to open, and ends + * its watch like Stop does when it has one. Once nothing else is left in the instance, it ends and its + * directory is removed, so it leaves no orphan. + */ + void discard(Ids::InstanceId const& instanceId, Paths::LocalPath const& localPath); + Nui::ElementRenderer operator()(); private: diff --git a/frontend/include/frontend/session_components/file_tracking_state.hpp b/frontend/include/frontend/session_components/file_tracking_state.hpp new file mode 100644 index 00000000..8bb7e00f --- /dev/null +++ b/frontend/include/frontend/session_components/file_tracking_state.hpp @@ -0,0 +1,382 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace FileTracking +{ + /** + * @brief Size and modification time (seconds) of a file on the server. + */ + struct ServerState + { + std::uint64_t mtime{0}; + std::uint64_t size{0}; + + friend bool operator==(ServerState const&, ServerState const&) = default; + }; + + /** + * @brief Whether the server's copy of a file changed since @p known was taken. A file the server has but nobody + * saw here, a vanished one and one with another size or time all changed. + * @param known What the server had when it was last seen, nothing when it had no file there. + * @param current What the server has now, nothing when it has no file there. + */ + inline bool serverChanged(std::optional const& known, std::optional const& current) + { + if (!known) + return current.has_value(); + return !current || *current != *known; + } + + /** + * @brief The remote path a change at @p changed maps to. The instance directory stands for the directory of + * @p remotePath, a change outside it or of the instance directory itself maps to nothing. + */ + inline std::expected remotePathOfChange( + Paths::LocalPath const& changed, + Paths::LocalPath const& instanceDirectory, + Paths::RemotePath const& remotePath + ) + { + const auto remoteDirectory = remotePath.parent(); + if (!remoteDirectory) + return std::unexpected( + Paths::PathError{.reason = Paths::PathRejection::EscapesRoot, .input = remotePath.value()} + ); + return changed.relativeTo(instanceDirectory) + .and_then( + [&changed, &remoteDirectory]( + Paths::RelativePath const& relative + ) -> std::expected + { + if (relative.empty()) + return std::unexpected( + Paths::PathError{.reason = Paths::PathRejection::Empty, .input = changed.toUtf8()} + ); + return *remoteDirectory / relative; + } + ); + } + + /** + * @brief What a move inside a tracked item does on the server. + */ + enum class MoveMeaning + { + /** @brief The server's copy of the source is renamed. */ + Rename, + /** + * @brief The destination's content changed and the source is gone: an editor saving through a temporary + * file, or a move over an existing file. A rename on the server would fail on the existing + * destination or find no source. + */ + ReplaceDestination, + }; + + /** + * @brief What a move means when the server has the destination (@p destinationOnServer) or the source + * (@p sourceOnServer), as far as file tracking knows. + */ + inline MoveMeaning meaningOfMove(bool destinationOnServer, bool sourceOnServer) + { + return destinationOnServer || !sourceOnServer ? MoveMeaning::ReplaceDestination : MoveMeaning::Rename; + } + + /** + * @brief What the server is known to have of one tracked item, by local path: what the download brought and + * what was uploaded since. + */ + class KnownServerContent + { + public: + void rememberFile(Paths::LocalPath const& path, ServerState state) + { + files_.insert_or_assign(path, state); + } + + void rememberDirectory(Paths::LocalPath const& path) + { + directories_.insert(path); + } + + /** + * @brief The known state of the file at @p path, nothing when no file is known there. + */ + std::optional fileState(Paths::LocalPath const& path) const + { + const auto found = files_.find(path); + if (found == files_.end()) + return std::nullopt; + return found->second; + } + + /** + * @brief Whether the server is known to have @p path or anything below it. + */ + bool has(Paths::LocalPath const& path) const + { + return files_.contains(path) || directories_.contains(path) || hasBelow(path); + } + + /** + * @brief Whether the server is known to have anything below @p path. + */ + bool hasBelow(Paths::LocalPath const& path) const + { + const auto isBelow = [&path](Paths::LocalPath const& candidate) + { + return candidate != path && candidate.isWithin(path); + }; + return std::ranges::any_of(std::views::keys(files_), isBelow) || std::ranges::any_of(directories_, isBelow); + } + + /** + * @brief Forgets @p path and everything below it. + */ + void forget(Paths::LocalPath const& path) + { + std::erase_if( + files_, + [&path](auto const& file) + { + return file.first.isWithin(path); + } + ); + std::erase_if( + directories_, + [&path](Paths::LocalPath const& directory) + { + return directory.isWithin(path); + } + ); + } + + /** + * @brief Moves what is known at @p from and below it to @p to, after the server renamed it. + */ + void move(Paths::LocalPath const& from, Paths::LocalPath const& to) + { + const auto moved = [&from, &to](Paths::LocalPath const& path) -> std::optional + { + return path.relativeTo(from) + .and_then( + [&to](Paths::RelativePath const& relative) + { + return to.join(relative); + } + ) + .transform( + [](Paths::LocalPath moved) + { + return std::optional{std::move(moved)}; + } + ) + .value_or(std::nullopt); + }; + std::map files; + for (auto const& [path, state] : files_) + files.insert_or_assign(moved(path).value_or(path), state); + std::set directories; + std::ranges::transform( + directories_, + std::inserter(directories, directories.end()), + [&moved](Paths::LocalPath const& path) + { + return moved(path).value_or(path); + } + ); + files_ = std::move(files); + directories_ = std::move(directories); + } + + private: + std::map files_; + std::set directories_; + }; + + /** + * @brief A changed path of a tracked item. + */ + struct LocalChange + { + Paths::LocalPath path; + bool isDirectory{false}; + + friend bool operator==(LocalChange const&, LocalChange const&) = default; + }; + + /** + * @brief The uploads of one tracked item that are on their way, from the change until the server's state after + * the upload is known. One path has at most one, and nothing below a directory being uploaded starts its + * own, so two uploads never write the same remote file at once. + */ + class PendingUploads + { + public: + /** + * @brief What became of the uploads of a path when its upload ended. + */ + struct Finished + { + /** @brief The path changed again meanwhile and has to go once more. */ + bool changedAgain{false}; + /** @brief The path was deleted or moved away meanwhile, its server copy has to go. */ + bool removeRemote{false}; + /** @brief Changes below the uploaded directory that arrived meanwhile, in arrival order. */ + std::vector changedBelow{}; + }; + + /** + * @brief Starts the upload of @p change unless one of its path or of a directory above it is on its way, the + * change then goes again once that one finished. + * @return Whether the upload starts. + */ + bool start(LocalChange const& change) + { + if (const auto found = pending_.find(change.path); found != pending_.end()) + { + found->second.changedAgain = true; + found->second.removeRemote = false; + return false; + } + const auto directoryAbove = std::ranges::find_if( + pending_, + [&change](auto const& pending) + { + return pending.second.isDirectory && change.path.isWithin(pending.first); + } + ); + if (directoryAbove != pending_.end()) + { + if (!std::ranges::contains(directoryAbove->second.changedBelow, change)) + directoryAbove->second.changedBelow.push_back(change); + return false; + } + pending_.emplace(change.path, Pending{.isDirectory = change.isDirectory}); + return true; + } + + /** + * @brief The content of @p path as it is now is being handled, by the upload reading it or by the answer to + * a question about it: the changes that arrived so far need nothing more. + */ + void changesHandled(Paths::LocalPath const& path) + { + if (const auto found = pending_.find(path); found != pending_.end()) + { + found->second.changedAgain = false; + found->second.changedBelow.clear(); + } + } + + /** + * @brief Whether an upload of @p path is on its way. + */ + bool isPending(Paths::LocalPath const& path) const + { + return pending_.contains(path); + } + + /** + * @brief The path of a pending upload was deleted or moved away: its server copy goes once the upload + * ended, and it does not go again. + * @return Whether an upload of @p path is on its way. + */ + bool removeRemoteWhenDone(Paths::LocalPath const& path) + { + const auto found = pending_.find(path); + if (found == pending_.end()) + return false; + found->second.removeRemote = true; + found->second.changedAgain = false; + return true; + } + + /** + * @brief Ends the upload of @p path. + */ + Finished finish(Paths::LocalPath const& path) + { + const auto found = pending_.find(path); + if (found == pending_.end()) + return {}; + auto finished = Finished{ + .changedAgain = found->second.changedAgain, + .removeRemote = found->second.removeRemote, + .changedBelow = std::move(found->second.changedBelow), + }; + pending_.erase(found); + return finished; + } + + bool empty() const + { + return pending_.empty(); + } + + private: + struct Pending + { + bool isDirectory{false}; + bool changedAgain{false}; + bool removeRemote{false}; + std::vector changedBelow{}; + }; + std::map pending_; + }; + + /** + * @brief The keys of a table's rows in the order they were added, so the row of a key can be found. + */ + template + class RowOrder + { + public: + /** + * @brief Appends the row of @p key. + */ + void add(KeyT key) + { + keys_.push_back(std::move(key)); + } + + /** + * @brief Forgets the row of @p key. + * @return The index the row had, nothing when @p key has no row. + */ + std::optional remove(KeyT const& key) + { + const auto found = std::ranges::find(keys_, key); + if (found == keys_.end()) + return std::nullopt; + const auto index = static_cast(std::distance(keys_.begin(), found)); + keys_.erase(found); + return index; + } + + void clear() + { + keys_.clear(); + } + + std::size_t size() const + { + return keys_.size(); + } + + private: + std::vector keys_{}; + }; +} diff --git a/frontend/include/frontend/session_components/numbered_saves.hpp b/frontend/include/frontend/session_components/numbered_saves.hpp new file mode 100644 index 00000000..a3b5741c --- /dev/null +++ b/frontend/include/frontend/session_components/numbered_saves.hpp @@ -0,0 +1,145 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +namespace SessionInternal +{ + enum class WriteOutcome + { + Written, + /** + * @brief The file existed and was left alone. + */ + Exists, + Failed, + }; + + /** + * @brief "{stem}_{index}{extension}". + */ + inline std::string numberedFileName(std::string_view stem, int index, std::string_view extension) + { + return fmt::format("{}_{}{}", stem, index, extension); + } + + /** + * @brief Plans saving several contents to numbered files that do not exist yet: content after content, each to + * the next free name "{stem}_{index}{extension}". A name that exists moves the content on to the next number, + * at most @p attemptsPerName names per content. + */ + class NumberedSaves + { + public: + /** + * @brief The content to write next and the name to try. + */ + struct Attempt + { + std::size_t content; + std::string name; + }; + + /** + * @brief A content that was not saved. + */ + struct Failure + { + std::size_t content; + /** + * @brief The last name tried. + */ + std::string name; + /** + * @brief True when every tried name existed, false when writing failed. + */ + bool noFreeName; + }; + + NumberedSaves(std::string stem, std::string extension, std::size_t contentCount, int attemptsPerName = 1000) + : stem_{std::move(stem)} + , extension_{std::move(extension)} + , contentCount_{contentCount} + , attemptsPerName_{attemptsPerName} + {} + + /** + * @brief What to write next, nothing when every content is saved or failed. + */ + std::optional next() const + { + if (content_ >= contentCount_) + return std::nullopt; + return Attempt{.content = content_, .name = currentName()}; + } + + /** + * @brief Takes the outcome of writing the attempt @ref next gave. + */ + void record(WriteOutcome outcome) + { + if (content_ >= contentCount_) + return; + switch (outcome) + { + case WriteOutcome::Written: + written_.push_back(currentName()); + ++index_; + return finishContent(); + case WriteOutcome::Exists: + ++index_; + if (++attempts_ < attemptsPerName_) + return; + failures_.push_back( + Failure{.content = content_, .name = numberedFileName(stem_, index_ - 1, extension_), .noFreeName = true} + ); + return finishContent(); + case WriteOutcome::Failed: + failures_.push_back(Failure{.content = content_, .name = currentName(), .noFreeName = false}); + ++index_; + return finishContent(); + } + } + + /** + * @brief The names written, in content order. + */ + std::vector const& written() const + { + return written_; + } + + std::vector const& failures() const + { + return failures_; + } + + private: + std::string currentName() const + { + return numberedFileName(stem_, index_, extension_); + } + + void finishContent() + { + ++content_; + attempts_ = 0; + } + + std::string stem_; + std::string extension_; + std::size_t contentCount_; + int attemptsPerName_; + std::size_t content_{0}; + int index_{0}; + int attempts_{0}; + std::vector written_{}; + std::vector failures_{}; + }; +} diff --git a/frontend/include/frontend/session_components/operation_queue.hpp b/frontend/include/frontend/session_components/operation_queue.hpp index 32e16d56..c9c8db2b 100644 --- a/frontend/include/frontend/session_components/operation_queue.hpp +++ b/frontend/include/frontend/session_components/operation_queue.hpp @@ -33,6 +33,7 @@ #include #include +#include #include #include @@ -372,4 +373,6 @@ class OperationQueue private: struct Implementation; std::unique_ptr impl_; + /** @brief Drops RPC replies and dialog answers that arrive after the queue is gone. */ + Utility::LifetimeToken lifetime_{}; }; \ No newline at end of file diff --git a/frontend/include/frontend/settings/atomic_setting/setting.hpp b/frontend/include/frontend/settings/atomic_setting/setting.hpp index a4b891db..bf012ca5 100644 --- a/frontend/include/frontend/settings/atomic_setting/setting.hpp +++ b/frontend/include/frontend/settings/atomic_setting/setting.hpp @@ -143,7 +143,18 @@ class Setting this->value(*value); return; } - state_ = ValueType{}; + state_ = fallback_.value_or(ValueType{}); + updateStateWithInheritance(); + } + /** + * @brief The value an unset setting stands for. It is shown while the setting is not set and nothing is + * inherited, and it is the value the setting starts with when it is engaged. + */ + void fallback(ValueType value) + { + fallback_ = std::move(value); + if (!engaged_.value()) + state_ = *fallback_; updateStateWithInheritance(); } void updateStateWithInheritance() @@ -346,6 +357,7 @@ class Setting Nui::Observed> inheritedState_; Nui::Observed stateWithInheritance_; Nui::Observed engaged_{!Disengageable}; + std::optional fallback_{}; std::function onChange_; std::function resetAction_; Nui::Observed* externalDisengage_; diff --git a/frontend/include/frontend/terminal/file_engine.hpp b/frontend/include/frontend/terminal/file_engine.hpp index 8efb16e1..c0c952c9 100644 --- a/frontend/include/frontend/terminal/file_engine.hpp +++ b/frontend/include/frontend/terminal/file_engine.hpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -29,9 +30,14 @@ class FileEngine std::optional release(); + /** + * @brief Lists the remote directory @p path. + * + * @param onComplete Receives the entries, or std::nullopt and the reason on failure. + */ void listDirectory( std::filesystem::path const& path, - std::function> const&, std::string const& info)> + std::function> entries, std::string const& info)> onComplete ); @@ -96,8 +102,8 @@ class FileEngine /** * @brief Loads direct children of @p parentRelKey in @p section. @p generation - * must match the most recent @ref recomputeSyncDiff reply or the server - * rejects as stale — the caller then discards. + * must match the most recent @ref recomputeSyncDiff reply, else the server + * rejects it as stale and the caller discards it. */ void loadSyncDiffChildren( Ids::SyncSessionId syncSessionId, @@ -204,7 +210,7 @@ class FileEngine std::function, std::string const& info)> onOperationCreated ); - /** @brief Upload analogue of addBulkDownload — see its docs. */ + /** @brief Upload analogue of addBulkDownload, see its docs. */ void addBulkUpload( std::vector entries, std::vector operationIds, @@ -264,6 +270,17 @@ class FileEngine std::function onComplete ); + /** + * @brief Renames on this computer, needs no connection. Fails with "backend.rename.targetExists" when + * @p newPath exists and @p overwrite is false. + */ + static void renameLocal( + Paths::LocalPath const& oldPath, + Paths::LocalPath const& newPath, + bool overwrite, + std::function onComplete + ); + void addRename( Paths::RemotePath const& sourcePath, Paths::RemotePath const& destinationPath, @@ -279,9 +296,7 @@ class FileEngine ); /** - * @brief Batched remote-existence probe. Replaces N per-file `sftp::stat` - * round-trips with a single one — used to gate bulk uploads on - * per-file conflict prompts without the latency cost. + * @brief Asks the server in one request which of @p paths exist. * * @param paths Remote paths to probe. * @param onComplete Receives a vector aligned with @p paths. On @@ -293,6 +308,16 @@ class FileEngine std::function exists, std::string const& info)> onComplete ); + /** + * @brief Asks the backend in one request which of the local @p paths exist, needs no connection. + * + * @param onComplete Same as for @ref existsBatchRemote. + */ + static void existsBatchLocal( + std::vector const& paths, + std::function exists, std::string const& info)> onComplete + ); + void dispose(std::function onComplete); private: @@ -302,4 +327,6 @@ class FileEngine struct Implementation; std::unique_ptr impl_; Nui::MoveDetector moveDetector_; + /** @brief Drops the reply of the channel creation when this engine is gone. */ + Utility::LifetimeToken lifetime_{}; }; diff --git a/frontend/source/frontend/dialog/archive_transfer_dialog.cpp b/frontend/source/frontend/dialog/archive_transfer_dialog.cpp index 80d1afea..3de13031 100644 --- a/frontend/source/frontend/dialog/archive_transfer_dialog.cpp +++ b/frontend/source/frontend/dialog/archive_transfer_dialog.cpp @@ -37,6 +37,24 @@ std::string archiveCodecExtension(ArchiveCodec codec) return {}; } +int backendCompressionCode(ArchiveCodec codec) +{ + switch (codec) + { + case ArchiveCodec::None: + return 1; + case ArchiveCodec::Gzip: + return 2; + case ArchiveCodec::Bzip2: + return 3; + case ArchiveCodec::Zstd: + return 4; + case ArchiveCodec::Xz: + return 5; + } + return 2; +} + ArchiveCodec archiveCodecFromExtension(std::string const& extension) { if (extension == ".gz" || extension == "gz") diff --git a/frontend/source/frontend/dialog/confirm_dialog.cpp b/frontend/source/frontend/dialog/confirm_dialog.cpp index 829c402a..e0bedf91 100644 --- a/frontend/source/frontend/dialog/confirm_dialog.cpp +++ b/frontend/source/frontend/dialog/confirm_dialog.cpp @@ -20,6 +20,9 @@ #include #include +#include +#include +#include using namespace std::string_literals; namespace Snc = ScriptNuiComponents; @@ -35,6 +38,8 @@ struct ConfirmDialog::Implementation Nui::Observed showNeverAgain{false}; Nui::Observed neverAgainChecked{false}; std::string currentNeverShowAgainId; + /** @brief The onClose of the dialog on screen, empty once it ran. */ + std::function)> pendingOnClose; Implementation(std::string id, Persistence::StateHolder& stateHolder) : id{std::move(id)} @@ -63,6 +68,10 @@ ROAR_PIMPL_SPECIAL_FUNCTIONS_IMPL(ConfirmDialog); void ConfirmDialog::open(OpenOptions const& options) { + // The dialog on screen is replaced, it closes without a button. Its onClose may open another one. + while (impl_->pendingOnClose) + std::exchange(impl_->pendingOnClose, {})(std::nullopt); + if (options.neverShowAgainId.has_value()) { auto const& neverShow = impl_->stateHolder->stateCache().uiOptions.neverShowAgainDialogs; @@ -96,12 +105,18 @@ void ConfirmDialog::open(OpenOptions const& options) impl_->table.addRow({fmt::format(fmt::runtime(language->get("confirmDialog", "andMore")), totalRows - shownRows)}); impl_->text = options.text; impl_->dialog->setButtonLabels(localizedButtonLabels()); + impl_->pendingOnClose = options.onClose; impl_->dialog->open( {.styleVariant = options.styleVariant, .headerText = options.headerText, .buttons = options.buttons, .initialFocus = options.focusButton, - .onClose = options.onClose, + .onClose = + [implementation = impl_.get()](std::optional