diff --git a/CMakeLists.txt b/CMakeLists.txt index 8efac5d..4fa57bf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -58,6 +58,8 @@ endfunction() add_library(jgmd_core src/Scanner.cpp src/Recover.cpp + src/PathSafety.cpp + src/RecoveryOutput.cpp src/MftRecord.cpp src/Database.cpp src/Utf.cpp) @@ -101,8 +103,6 @@ if (JUSTGIVEMYDISK_BUILD_APP) endif() if (BUILD_TESTING) - add_subdirectory(tests) - add_executable(MftRecordParserTests tests/MftRecordParserTests.cpp tests/NtfsFixtures.cpp @@ -116,6 +116,38 @@ if (BUILD_TESTING) justgivemydisk_enable_sanitizers(MftRecordParserTests) add_test(NAME MftRecordParserTests COMMAND MftRecordParserTests) + + if (JUSTGIVEMYDISK_BUILD_APP) + add_subdirectory(tests) + + add_executable(path_safety_tests + tests/PathSafetyTests.cpp + src/PathSafety.cpp) + target_include_directories(path_safety_tests PRIVATE src) + target_link_libraries(path_safety_tests PRIVATE fmt::fmt) + add_test(NAME path_safety_unit COMMAND path_safety_tests) + + add_executable(recovery_output_tests + tests/RecoveryOutputTests.cpp + src/RecoveryOutput.cpp) + target_include_directories(recovery_output_tests PRIVATE src) + target_link_libraries(recovery_output_tests PRIVATE fmt::fmt) + add_test(NAME recovery_output_unit COMMAND recovery_output_tests) + + add_test( + NAME path_safety_cli_integration + COMMAND ${CMAKE_COMMAND} + -DPROGRAM=$ + -DTEST_ROOT=${CMAKE_CURRENT_BINARY_DIR}/path-safety-integration + -P ${CMAKE_CURRENT_SOURCE_DIR}/tests/PathSafetyIntegration.cmake) + + add_test( + NAME recovery_output_cli_integration + COMMAND ${CMAKE_COMMAND} + -DPROGRAM=$ + -DTEST_ROOT=${CMAKE_CURRENT_BINARY_DIR}/recovery-output-integration + -P ${CMAKE_CURRENT_SOURCE_DIR}/tests/RecoveryOutputIntegration.cmake) + endif() endif() if (JUSTGIVEMYDISK_BUILD_FUZZER) diff --git a/README.md b/README.md index 55268bc..50331cf 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ JustGiveMyDisk is an experimental, terminal-only C++20 recovery helper for corru - Supports resident and non-resident unnamed `$DATA` attributes (`0x80`). - Parses NTFS data runs and copies only each file's real size. - Provides a `--dry-run` recovery preview. +- Validates database and recovery paths against the open source device before writing. +- Publishes recovered files atomically from `.partial` files without overwriting by default. ## Dependencies @@ -91,6 +93,8 @@ Recover the indexed tree into a separate destination: sudo ./build/JustGiveMyDisk recover /dev/nvme0n1p3 scan.db --id 1189298 --dest /media/bkcpdisco/RECUP_IN_ROMANCE ``` +Existing files are skipped by default. Pass `--overwrite` only when replacing existing regular files is intentional. Incomplete writes are removed; a stale `.partial` left by an interrupted process is reported and can be replaced with the same explicit flag. + The `recover` command uses `scan.db` to recursively collect children whose `parent_ref` matches the current directory `record_id_guess`. It then scans the source for MFT `FILE` records and matches the record number stored in the MFT header before reading `$DATA`; it does not assume that `record_id_guess * 1024` is the source offset. ## SQLite Schema diff --git a/src/PathSafety.cpp b/src/PathSafety.cpp new file mode 100644 index 0000000..859f5f7 --- /dev/null +++ b/src/PathSafety.cpp @@ -0,0 +1,325 @@ +#include "PathSafety.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace path_safety { +namespace { + +[[noreturn]] void fail(const char* role, const std::filesystem::path& path, const std::string& reason) { + throw std::runtime_error(fmt::format( + "path validation failed for {} '{}': {}", role, path.string(), reason)); +} + +std::filesystem::path canonicalize(const std::filesystem::path& path, const char* role) { + if (path.empty()) { + fail(role, path, "path is empty"); + } + + std::error_code ec; + const auto absolute = std::filesystem::absolute(path, ec); + if (ec) { + fail(role, path, fmt::format("cannot make path absolute: {}", ec.message())); + } + const auto canonical = std::filesystem::weakly_canonical(absolute, ec); + if (ec) { + fail(role, path, fmt::format("cannot safely canonicalize path: {}", ec.message())); + } + return canonical; +} + +bool same_opened_object(const struct stat& lhs, const struct stat& rhs) { + if ((S_ISBLK(lhs.st_mode) || S_ISCHR(lhs.st_mode)) && + (S_ISBLK(rhs.st_mode) || S_ISCHR(rhs.st_mode))) { + return lhs.st_rdev == rhs.st_rdev; + } + return lhs.st_dev == rhs.st_dev && lhs.st_ino == rhs.st_ino; +} + +struct stat stat_or_fail(const std::filesystem::path& path, const char* role) { + struct stat info {}; + if (::stat(path.c_str(), &info) != 0) { + fail(role, path, fmt::format("stat failed: {}", std::strerror(errno))); + } + return info; +} + +struct ExistingAncestor { + std::filesystem::path path; + struct stat info {}; +}; + +ExistingAncestor existing_ancestor(const std::filesystem::path& canonical_path, const char* role) { + auto current = canonical_path; + while (true) { + struct stat info {}; + if (::stat(current.c_str(), &info) == 0) { + return {current, info}; + } + if (errno != ENOENT && errno != ENOTDIR) { + fail(role, canonical_path, fmt::format( + "cannot inspect '{}' while resolving storage: {}", current.string(), std::strerror(errno))); + } + const auto parent = current.parent_path(); + if (parent == current || parent.empty()) { + fail(role, canonical_path, "no existing ancestor could be inspected"); + } + current = parent; + } +} + +std::optional read_sysfs_device_number(const std::filesystem::path& directory) { + std::ifstream input(directory / "dev"); + unsigned int device_major = 0; + unsigned int device_minor = 0; + char separator = 0; + if (!(input >> device_major >> separator >> device_minor) || separator != ':') { + return std::nullopt; + } + return makedev(device_major, device_minor); +} + +void add_device_relations(dev_t device, std::set& devices) { + if (!devices.insert(device).second) { + return; + } + + std::error_code ec; + auto sys_path = std::filesystem::canonical( + std::filesystem::path("/sys/dev/block") / + fmt::format("{}:{}", major(device), minor(device)), + ec); + if (ec) { + return; + } + + for (auto current = sys_path; !current.empty(); current = current.parent_path()) { + if (const auto parent_device = read_sysfs_device_number(current)) { + devices.insert(*parent_device); + } + if (current == current.root_path()) { + break; + } + } + + const auto slaves = sys_path / "slaves"; + if (!std::filesystem::is_directory(slaves, ec)) { + return; + } + for (const auto& entry : std::filesystem::directory_iterator(slaves, ec)) { + if (ec) { + break; + } + const auto slave_path = std::filesystem::canonical(entry.path(), ec); + if (ec) { + continue; + } + if (const auto slave_device = read_sysfs_device_number(slave_path)) { + add_device_relations(*slave_device, devices); + } + } +} + +bool devices_are_related(dev_t source, dev_t storage) { + if (source == storage) { + return true; + } + std::set source_relations; + std::set storage_relations; + add_device_relations(source, source_relations); + add_device_relations(storage, storage_relations); + for (const auto device : source_relations) { + if (storage_relations.contains(device)) { + return true; + } + } + return false; +} + +void reject_source_storage(const Source& source, + const std::filesystem::path& path, + const char* role, + const ExistingAncestor& storage) { + if (!source.is_block_device()) { + return; + } + const auto source_device = static_cast(source.block_device()); + if (devices_are_related(source_device, storage.info.st_dev)) { + fail(role, path, fmt::format( + "'{}' is stored on the source device (source {}:{}, filesystem {}:{})", + storage.path.string(), + major(source_device), + minor(source_device), + major(storage.info.st_dev), + minor(storage.info.st_dev))); + } +} + +void reject_source_alias(const Source& source, + const std::filesystem::path& original, + const std::filesystem::path& canonical, + const char* role) { + struct stat candidate {}; + if (::stat(canonical.c_str(), &candidate) != 0) { + if (errno == ENOENT || errno == ENOTDIR) { + return; + } + fail(role, original, fmt::format("stat failed: {}", std::strerror(errno))); + } + + struct stat opened_source {}; + if (::fstat(source.fd(), &opened_source) != 0) { + fail(role, original, fmt::format("fstat on the open source failed: {}", std::strerror(errno))); + } + if (same_opened_object(opened_source, candidate)) { + fail(role, original, fmt::format( + "resolves to the open recovery source '{}' (alias, symlink, or equivalent path)", + source.canonical_path().string())); + } +} + +} // namespace + +Source::Source(const std::filesystem::path& path) + : canonical_path_(canonicalize(path, "source")) { + const auto before = stat_or_fail(canonical_path_, "source"); + if (!S_ISREG(before.st_mode) && !S_ISBLK(before.st_mode)) { + fail("source", path, "source must be a regular image file or a block device"); + } + + int open_flags = O_RDONLY; +#ifdef O_CLOEXEC + open_flags |= O_CLOEXEC; +#endif + fd_ = ::open(canonical_path_.c_str(), open_flags); + if (fd_ < 0) { + fail("source", path, fmt::format("cannot open read-only: {}", std::strerror(errno))); + } + + struct stat after {}; + if (::fstat(fd_, &after) != 0) { + const auto error = std::strerror(errno); + ::close(fd_); + fd_ = -1; + fail("source", path, fmt::format("fstat failed after opening: {}", error)); + } + if (!same_opened_object(before, after)) { + ::close(fd_); + fd_ = -1; + fail("source", path, "path changed between stat and read-only open"); + } +#ifndef O_CLOEXEC + if (::fcntl(fd_, F_SETFD, FD_CLOEXEC) != 0) { + const auto error = std::strerror(errno); + ::close(fd_); + fd_ = -1; + fail("source", path, fmt::format("cannot set close-on-exec: {}", error)); + } +#endif + const int flags = ::fcntl(fd_, F_GETFL); + if (flags < 0 || (flags & O_ACCMODE) != O_RDONLY) { + ::close(fd_); + fd_ = -1; + fail("source", path, "open descriptor is not read-only"); + } + + is_block_device_ = S_ISBLK(after.st_mode); + block_device_ = static_cast(after.st_rdev); +} + +Source::~Source() { + if (fd_ >= 0) { + ::close(fd_); + } +} + +Source::Source(Source&& other) noexcept + : fd_(other.fd_), + canonical_path_(std::move(other.canonical_path_)), + block_device_(other.block_device_), + is_block_device_(other.is_block_device_) { + other.fd_ = -1; +} + +Source& Source::operator=(Source&& other) noexcept { + if (this != &other) { + if (fd_ >= 0) { + ::close(fd_); + } + fd_ = other.fd_; + canonical_path_ = std::move(other.canonical_path_); + block_device_ = other.block_device_; + is_block_device_ = other.is_block_device_; + other.fd_ = -1; + } + return *this; +} + +int Source::fd() const noexcept { + return fd_; +} + +const std::filesystem::path& Source::canonical_path() const noexcept { + return canonical_path_; +} + +bool Source::is_block_device() const noexcept { + return is_block_device_; +} + +std::uint64_t Source::block_device() const noexcept { + return block_device_; +} + +std::filesystem::path validate_database_path( + const Source& source, const std::filesystem::path& database_path) { + const auto canonical = canonicalize(database_path, "database"); + reject_source_alias(source, database_path, canonical, "database"); + const auto storage = existing_ancestor(canonical, "database"); + if (!S_ISREG(storage.info.st_mode) && storage.path == canonical) { + fail("database", database_path, "existing database path is not a regular file"); + } + if (storage.path != canonical && !S_ISDIR(storage.info.st_mode)) { + fail("database", database_path, fmt::format( + "existing ancestor '{}' is not a directory", storage.path.string())); + } + reject_source_storage(source, database_path, "database", storage); + return canonical; +} + +std::filesystem::path validate_destination_path( + const Source& source, const std::filesystem::path& destination_path) { + const auto canonical = canonicalize(destination_path, "destination"); + reject_source_alias(source, destination_path, canonical, "destination"); + const auto storage = existing_ancestor(canonical, "destination"); + if (storage.path == canonical && !S_ISDIR(storage.info.st_mode)) { + if (S_ISREG(storage.info.st_mode)) { + fail("destination", destination_path, "destination is a file; a directory is required"); + } + if (S_ISBLK(storage.info.st_mode)) { + fail("destination", destination_path, "destination is a block device; a directory is required"); + } + fail("destination", destination_path, "destination is not a directory"); + } + if (storage.path != canonical && !S_ISDIR(storage.info.st_mode)) { + fail("destination", destination_path, fmt::format( + "existing ancestor '{}' is not a directory", storage.path.string())); + } + reject_source_storage(source, destination_path, "destination", storage); + return canonical; +} + +} // namespace path_safety diff --git a/src/PathSafety.hpp b/src/PathSafety.hpp new file mode 100644 index 0000000..d415626 --- /dev/null +++ b/src/PathSafety.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include + +namespace path_safety { + +class Source { +public: + explicit Source(const std::filesystem::path& path); + ~Source(); + + Source(const Source&) = delete; + Source& operator=(const Source&) = delete; + + Source(Source&& other) noexcept; + Source& operator=(Source&& other) noexcept; + + int fd() const noexcept; + const std::filesystem::path& canonical_path() const noexcept; + bool is_block_device() const noexcept; + std::uint64_t block_device() const noexcept; + +private: + int fd_ = -1; + std::filesystem::path canonical_path_; + std::uint64_t block_device_ = 0; + bool is_block_device_ = false; +}; + +// These checks are deliberately performed before SQLite or recovery output is +// opened. Both the CLI and TUI reach them through Scanner and Recover. +std::filesystem::path validate_database_path( + const Source& source, const std::filesystem::path& database_path); +std::filesystem::path validate_destination_path( + const Source& source, const std::filesystem::path& destination_path); + +} // namespace path_safety diff --git a/src/Recover.cpp b/src/Recover.cpp index 09778f4..41fa531 100644 --- a/src/Recover.cpp +++ b/src/Recover.cpp @@ -2,17 +2,18 @@ #include "Database.hpp" #include "MftRecord.hpp" +#include "PathSafety.hpp" +#include "RecoveryOutput.hpp" #include #include #include #include -#include #include -#include #include #include +#include #include #include #include @@ -21,32 +22,6 @@ namespace { -class SourceDevice { -public: - explicit SourceDevice(const std::string& path) { - fd_ = ::open(path.c_str(), O_RDONLY | O_CLOEXEC); - if (fd_ < 0) { - throw std::runtime_error(fmt::format("failed to open source read-only {}: {}", path, std::strerror(errno))); - } - } - - ~SourceDevice() { - if (fd_ >= 0) { - ::close(fd_); - } - } - - SourceDevice(const SourceDevice&) = delete; - SourceDevice& operator=(const SourceDevice&) = delete; - - int get() const { - return fd_; - } - -private: - int fd_ = -1; -}; - struct RecoverItem { StoredRecord record; std::filesystem::path relative_path; @@ -79,19 +54,6 @@ std::uint64_t read_cluster_size(int fd) { return checked_mul(bytes_per_sector, sectors_per_cluster, "cluster size"); } -std::string safe_name(std::string name) { - for (char& ch : name) { - const auto c = static_cast(ch); - if (ch == '/' || ch == '\\' || c < 0x20) { - ch = '_'; - } - } - if (name.empty() || name == "." || name == "..") { - return "_"; - } - return name; -} - bool better_name(const StoredRecord& candidate, const StoredRecord& current) { const bool candidate_dos = candidate.name_namespace == 2; const bool current_dos = current.name_namespace == 2; @@ -143,16 +105,18 @@ void collect_tree(Database& db, std::uint64_t parent_record_id, const std::filesystem::path& base, std::vector& items, - std::unordered_set& visited_dirs) { + std::unordered_set& visited_dirs, + recovery_output::CollisionTracker& collisions) { if (!visited_dirs.insert(parent_record_id).second) { return; } for (const auto& child : dedup_children(db.children_of(parent_record_id))) { - const auto relative = base / safe_name(child.name); + const auto relative = base / recovery_output::sanitize_name(child.name); + collisions.add(relative, child.name, child.record_id_guess); items.push_back({child, relative}); if (child.is_directory) { - collect_tree(db, child.record_id_guess, relative, items, visited_dirs); + collect_tree(db, child.record_id_guess, relative, items, visited_dirs, collisions); } } } @@ -202,14 +166,11 @@ std::unordered_map> locate_records( return found; } -bool write_zeros(std::ofstream& out, std::uint64_t size) { +bool write_zeros(recovery_output::AtomicFile& out, std::uint64_t size) { std::vector zeros(1024 * 1024, 0); while (size > 0) { const auto chunk = static_cast(std::min(size, zeros.size())); - out.write(zeros.data(), static_cast(chunk)); - if (!out) { - return false; - } + out.write(zeros.data(), chunk); size -= chunk; } return true; @@ -219,7 +180,7 @@ bool copy_nonresident(int fd, const DataAttribute& data, std::uint64_t cluster_size, std::uint64_t bytes_to_copy, - std::ofstream& out) { + recovery_output::AtomicFile& out) { std::vector buffer(1024 * 1024); std::uint64_t remaining = bytes_to_copy; @@ -244,10 +205,7 @@ bool copy_nonresident(int fd, if (!read_exact_at(fd, buffer.data(), chunk, source_offset)) { return false; } - out.write(buffer.data(), static_cast(chunk)); - if (!out) { - return false; - } + out.write(buffer.data(), chunk); source_offset += chunk; to_copy -= chunk; remaining -= chunk; @@ -261,7 +219,8 @@ bool recover_file(int fd, const StoredRecord& record, const std::vector& mft_bytes, std::uint64_t cluster_size, - const std::filesystem::path& path) { + const std::filesystem::path& path, + bool overwrite) { MftRecordParser parser; const auto attributes = parser.data_attributes(mft_bytes); if (attributes.empty()) { @@ -274,19 +233,54 @@ bool recover_file(int fd, return false; } - std::filesystem::create_directories(path.parent_path()); - std::ofstream out(path, std::ios::binary | std::ios::trunc); - if (!out) { - return false; - } + recovery_output::AtomicFile out(path, overwrite); if (data.resident) { const auto count = static_cast(std::min(bytes_to_copy, data.resident_data.size())); - out.write(reinterpret_cast(data.resident_data.data()), static_cast(count)); - return static_cast(out) && count == bytes_to_copy; + out.write(data.resident_data.data(), count); + if (count != bytes_to_copy) { + return false; + } + out.commit(); + return true; } - return copy_nonresident(fd, data, cluster_size, bytes_to_copy, out); + if (!copy_nonresident(fd, data, cluster_size, bytes_to_copy, out)) { + return false; + } + out.commit(); + return true; +} + +void ensure_safe_directory(const path_safety::Source& source, const std::filesystem::path& directory) { + std::filesystem::path current = directory.root_path(); + for (const auto& component : directory.relative_path()) { + current /= component; + struct stat info {}; + if (::lstat(current.c_str(), &info) != 0) { + if (errno != ENOENT) { + throw std::runtime_error(fmt::format( + "cannot inspect output directory '{}': {}", current.string(), std::strerror(errno))); + } + if (::mkdir(current.c_str(), 0755) != 0 && errno != EEXIST) { + throw std::runtime_error(fmt::format( + "cannot create output directory '{}': {}", current.string(), std::strerror(errno))); + } + if (::lstat(current.c_str(), &info) != 0) { + throw std::runtime_error(fmt::format( + "cannot inspect created output directory '{}': {}", current.string(), std::strerror(errno))); + } + } + if (S_ISLNK(info.st_mode)) { + throw std::runtime_error(fmt::format( + "refusing symlink in output directory path '{}'", current.string())); + } + if (!S_ISDIR(info.st_mode)) { + throw std::runtime_error(fmt::format( + "output directory component '{}' is not a directory", current.string())); + } + path_safety::validate_destination_path(source, current); + } } } // namespace @@ -304,9 +298,11 @@ RecoverStats Recover::run(const RecoverOptions& options, const LogCallback& log) } }; - Database db(options.database_path); - SourceDevice source(options.source); - const std::uint64_t cluster_size = read_cluster_size(source.get()); + path_safety::Source source(options.source); + const auto database_path = path_safety::validate_database_path(source, options.database_path); + const auto destination = path_safety::validate_destination_path(source, options.destination); + Database db(database_path.string()); + const std::uint64_t cluster_size = read_cluster_size(source.fd()); const auto root = db.get_by_record_id(options.root_record_id); if (!root) { @@ -315,22 +311,23 @@ RecoverStats Recover::run(const RecoverOptions& options, const LogCallback& log) std::vector items; std::unordered_set visited_dirs; - collect_tree(db, options.root_record_id, {}, items, visited_dirs); + recovery_output::CollisionTracker collisions; + collect_tree(db, options.root_record_id, {}, items, visited_dirs, collisions); RecoverStats stats; stats.preview_items = items.size(); if (options.dry_run) { - emit(fmt::format("dry-run: root record {} -> {}", options.root_record_id, options.destination)); + emit(fmt::format("dry-run: root record {} -> {}", options.root_record_id, destination.string())); for (const auto& item : items) { emit(fmt::format("{} {}", item.record.is_directory ? "dir " : "file", - (std::filesystem::path(options.destination) / item.relative_path).string())); + (destination / item.relative_path).string())); } emit(fmt::format("dry-run: {} item(s)", items.size())); return stats; } - std::filesystem::create_directories(options.destination); + ensure_safe_directory(source, destination); std::unordered_set wanted_files; for (const auto& item : items) { @@ -340,19 +337,22 @@ RecoverStats Recover::run(const RecoverOptions& options, const LogCallback& log) } emit(fmt::format("Locating {} file MFT record(s) by FILE header record number", wanted_files.size())); - const auto records = locate_records(source.get(), wanted_files); + const auto records = locate_records(source.fd(), wanted_files); for (const auto& item : items) { - const auto target = std::filesystem::path(options.destination) / item.relative_path; + const auto target = destination / item.relative_path; try { if (item.record.is_directory) { - std::filesystem::create_directories(target); + ensure_safe_directory(source, target); continue; } + ensure_safe_directory(source, target.parent_path()); + path_safety::validate_destination_path(source, target.parent_path()); + const auto found = records.find(item.record.record_id_guess); if (found == records.end() || - !recover_file(source.get(), item.record, found->second, cluster_size, target)) { + !recover_file(source.fd(), item.record, found->second, cluster_size, target, options.overwrite)) { ++stats.skipped; emit(fmt::format("skipped {}", target.string())); continue; diff --git a/src/Recover.hpp b/src/Recover.hpp index 7edd069..32cdce3 100644 --- a/src/Recover.hpp +++ b/src/Recover.hpp @@ -10,6 +10,7 @@ struct RecoverOptions { std::uint64_t root_record_id = 0; std::string destination; bool dry_run = false; + bool overwrite = false; }; struct RecoverStats { diff --git a/src/RecoveryOutput.cpp b/src/RecoveryOutput.cpp new file mode 100644 index 0000000..f4d6af0 --- /dev/null +++ b/src/RecoveryOutput.cpp @@ -0,0 +1,232 @@ +#include "RecoveryOutput.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __linux__ +#include +#include +#endif + +namespace recovery_output { +namespace { + +std::string collision_key(const std::filesystem::path& path) { + std::string key = path.lexically_normal().generic_string(); + for (char& character : key) { + const auto byte = static_cast(character); + if (byte < 0x80) { + character = static_cast(std::tolower(byte)); + } + } + return key; +} + +bool path_exists(const std::filesystem::path& path, struct stat& info) { + if (::lstat(path.c_str(), &info) == 0) { + return true; + } + if (errno == ENOENT || errno == ENOTDIR) { + return false; + } + throw std::runtime_error(fmt::format( + "cannot inspect output '{}': {}", path.string(), std::strerror(errno))); +} + +void reject_unsafe_existing_output(const std::filesystem::path& path, const struct stat& info) { + if (S_ISLNK(info.st_mode)) { + throw std::runtime_error(fmt::format("refusing to replace output symlink '{}'", path.string())); + } + if (!S_ISREG(info.st_mode)) { + throw std::runtime_error(fmt::format("refusing to replace non-regular output '{}'", path.string())); + } +} + +int exclusive_output_flags() { + int flags = O_WRONLY | O_CREAT | O_EXCL; +#ifdef O_CLOEXEC + flags |= O_CLOEXEC; +#endif +#ifdef O_NOFOLLOW + flags |= O_NOFOLLOW; +#endif + return flags; +} + +int rename_without_replacement(const std::filesystem::path& from, + const std::filesystem::path& to) { +#ifdef __linux__ + const int result = static_cast(::syscall( + SYS_renameat2, AT_FDCWD, from.c_str(), AT_FDCWD, to.c_str(), RENAME_NOREPLACE)); + if (result == 0 || (errno != ENOSYS && errno != EINVAL)) { + return result; + } +#endif + // link+unlink is the portable no-replace fallback. The final name becomes + // visible atomically, and an existing final path is never overwritten. + if (::link(from.c_str(), to.c_str()) != 0) { + return -1; + } + if (::unlink(from.c_str()) != 0) { + const int unlink_error = errno; + ::unlink(to.c_str()); + errno = unlink_error; + return -1; + } + return 0; +} + +} // namespace + +std::string sanitize_name(std::string name) { + for (char& character : name) { + const auto byte = static_cast(character); + if (character == '/' || character == '\\' || byte < 0x20) { + character = '_'; + } + } + if (name.empty() || name == "." || name == "..") { + return "_"; + } + return name; +} + +void CollisionTracker::add(const std::filesystem::path& relative_path, + const std::string& original_name, + std::uint64_t record_id) { + const auto key = collision_key(relative_path); + const Entry candidate{original_name, record_id, relative_path}; + const auto [position, inserted] = entries_.emplace(key, candidate); + if (!inserted) { + const auto& previous = position->second; + throw std::runtime_error(fmt::format( + "sanitized output collision: '{}' (record {}, original '{}') and '{}' " + "(record {}, original '{}') resolve to the same output path", + previous.path.string(), + previous.record_id, + previous.original_name, + relative_path.string(), + record_id, + original_name)); + } +} + +AtomicFile::AtomicFile(const std::filesystem::path& final_path, bool overwrite) + : final_path_(final_path), + partial_path_(final_path.string() + ".partial"), + overwrite_(overwrite) { + struct stat final_info {}; + if (path_exists(final_path_, final_info)) { + reject_unsafe_existing_output(final_path_, final_info); + if (!overwrite_) { + throw std::runtime_error(fmt::format( + "output '{}' already exists; use --overwrite to replace it", final_path_.string())); + } + } + + struct stat partial_info {}; + if (path_exists(partial_path_, partial_info)) { + reject_unsafe_existing_output(partial_path_, partial_info); + if (!overwrite_) { + throw std::runtime_error(fmt::format( + "stale partial output '{}' exists; remove it or use --overwrite", + partial_path_.string())); + } + if (::unlink(partial_path_.c_str()) != 0) { + throw std::runtime_error(fmt::format( + "cannot remove stale partial output '{}': {}", + partial_path_.string(), + std::strerror(errno))); + } + } + + fd_ = ::open(partial_path_.c_str(), exclusive_output_flags(), 0600); + if (fd_ < 0) { + throw std::runtime_error(fmt::format( + "cannot create partial output '{}': {}", partial_path_.string(), std::strerror(errno))); + } +#ifndef O_CLOEXEC + if (::fcntl(fd_, F_SETFD, FD_CLOEXEC) != 0) { + const auto error = std::strerror(errno); + cleanup_partial(); + throw std::runtime_error(fmt::format( + "cannot set close-on-exec on partial output '{}': {}", partial_path_.string(), error)); + } +#endif +} + +AtomicFile::~AtomicFile() { + cleanup_partial(); +} + +void AtomicFile::write(const void* data, std::size_t size) { + const auto* bytes = static_cast(data); + std::size_t written = 0; + while (written < size) { + const ssize_t count = ::write(fd_, bytes + written, size - written); + if (count < 0) { + if (errno == EINTR) { + continue; + } + throw std::runtime_error(fmt::format( + "write failed for partial output '{}': {}", partial_path_.string(), std::strerror(errno))); + } + if (count == 0) { + throw std::runtime_error(fmt::format( + "write made no progress for partial output '{}'", partial_path_.string())); + } + written += static_cast(count); + } +} + +void AtomicFile::commit() { + if (committed_) { + return; + } + if (::fsync(fd_) != 0) { + throw std::runtime_error(fmt::format( + "fsync failed for partial output '{}': {}", partial_path_.string(), std::strerror(errno))); + } + if (::close(fd_) != 0) { + fd_ = -1; + throw std::runtime_error(fmt::format( + "close failed for partial output '{}': {}", partial_path_.string(), std::strerror(errno))); + } + fd_ = -1; + + const int result = overwrite_ + ? ::rename(partial_path_.c_str(), final_path_.c_str()) + : rename_without_replacement(partial_path_, final_path_); + if (result != 0) { + throw std::runtime_error(fmt::format( + "cannot atomically publish '{}' as '{}': {}", + partial_path_.string(), + final_path_.string(), + std::strerror(errno))); + } + committed_ = true; +} + +const std::filesystem::path& AtomicFile::partial_path() const noexcept { + return partial_path_; +} + +void AtomicFile::cleanup_partial() noexcept { + if (fd_ >= 0) { + ::close(fd_); + fd_ = -1; + } + if (!committed_) { + ::unlink(partial_path_.c_str()); + } +} + +} // namespace recovery_output diff --git a/src/RecoveryOutput.hpp b/src/RecoveryOutput.hpp new file mode 100644 index 0000000..7238ee2 --- /dev/null +++ b/src/RecoveryOutput.hpp @@ -0,0 +1,51 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace recovery_output { + +std::string sanitize_name(std::string name); + +class CollisionTracker { +public: + void add(const std::filesystem::path& relative_path, + const std::string& original_name, + std::uint64_t record_id); + +private: + struct Entry { + std::string original_name; + std::uint64_t record_id = 0; + std::filesystem::path path; + }; + std::unordered_map entries_; +}; + +class AtomicFile { +public: + AtomicFile(const std::filesystem::path& final_path, bool overwrite); + ~AtomicFile(); + + AtomicFile(const AtomicFile&) = delete; + AtomicFile& operator=(const AtomicFile&) = delete; + + void write(const void* data, std::size_t size); + void commit(); + + const std::filesystem::path& partial_path() const noexcept; + +private: + std::filesystem::path final_path_; + std::filesystem::path partial_path_; + int fd_ = -1; + bool overwrite_ = false; + bool committed_ = false; + + void cleanup_partial() noexcept; +}; + +} // namespace recovery_output diff --git a/src/Scanner.cpp b/src/Scanner.cpp index c94e9bb..8c627b5 100644 --- a/src/Scanner.cpp +++ b/src/Scanner.cpp @@ -1,10 +1,11 @@ #include "Scanner.hpp" +#include "PathSafety.hpp" + #include #include #include -#include #include #include #include @@ -13,32 +14,6 @@ namespace { -class FileDescriptor { -public: - explicit FileDescriptor(const std::string& path) { - fd_ = ::open(path.c_str(), O_RDONLY | O_CLOEXEC); - if (fd_ < 0) { - throw std::runtime_error(fmt::format("failed to open source read-only {}: {}", path, std::strerror(errno))); - } - } - - ~FileDescriptor() { - if (fd_ >= 0) { - ::close(fd_); - } - } - - FileDescriptor(const FileDescriptor&) = delete; - FileDescriptor& operator=(const FileDescriptor&) = delete; - - int get() const { - return fd_; - } - -private: - int fd_ = -1; -}; - std::optional source_size(int fd) { struct stat st {}; if (::fstat(fd, &st) == 0 && S_ISREG(st.st_mode)) { @@ -73,12 +48,13 @@ ScanStats Scanner::run(const ScanOptions& options) { } ScanStats Scanner::run(const ScanOptions& options, const ProgressCallback& progress) { - FileDescriptor source(options.source); - Database database(options.database_path); + path_safety::Source source(options.source); + const auto database_path = path_safety::validate_database_path(source, options.database_path); + Database database(database_path.string()); database.initialize(); ScanStats stats; - const auto total_size = source_size(source.get()); + const auto total_size = source_size(source.fd()); if (total_size) { const auto message = fmt::format("Scanning {} bytes from {} in read-only mode", *total_size, options.source); if (progress) { @@ -105,7 +81,7 @@ ScanStats Scanner::run(const ScanOptions& options, const ProgressCallback& progr while (true) { std::size_t filled = 0; while (filled < buffer.size()) { - const ssize_t n = ::read(source.get(), buffer.data() + filled, buffer.size() - filled); + const ssize_t n = ::read(source.fd(), buffer.data() + filled, buffer.size() - filled); if (n < 0) { if (errno == EINTR) { continue; diff --git a/src/main.cpp b/src/main.cpp index 9b973ca..0f2d19e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -94,6 +94,7 @@ int main(int argc, char** argv) { recover_cmd->add_option("--dest", recover_options.destination, "Destination directory") ->required(); recover_cmd->add_flag("--dry-run", recover_options.dry_run, "Print what would be recovered without writing files"); + recover_cmd->add_flag("--overwrite", recover_options.overwrite, "Replace existing regular files atomically"); auto* tui_cmd = app.add_subcommand("tui", "Open the interactive terminal recovery UI"); diff --git a/tests/PathSafetyIntegration.cmake b/tests/PathSafetyIntegration.cmake new file mode 100644 index 0000000..4bdc4d7 --- /dev/null +++ b/tests/PathSafetyIntegration.cmake @@ -0,0 +1,40 @@ +if (NOT PROGRAM OR NOT TEST_ROOT) + message(FATAL_ERROR "PROGRAM and TEST_ROOT are required") +endif() + +file(REMOVE_RECURSE "${TEST_ROOT}") +file(MAKE_DIRECTORY "${TEST_ROOT}") +set(SOURCE "${TEST_ROOT}/source.img") +set(DATABASE "${TEST_ROOT}/scan.db") +set(DESTINATION_FILE "${TEST_ROOT}/destination-file") +file(WRITE "${SOURCE}" "not-an-ntfs-image") +file(WRITE "${DATABASE}" "not-a-database") +file(WRITE "${DESTINATION_FILE}" "existing-file") + +execute_process( + COMMAND "${PROGRAM}" scan "${SOURCE}" --out "${SOURCE}" + RESULT_VARIABLE SAME_RESULT + ERROR_VARIABLE SAME_ERROR) +if (SAME_RESULT EQUAL 0 OR NOT SAME_ERROR MATCHES "path validation failed for database.*alias, symlink, or equivalent path") + message(FATAL_ERROR "scan did not reject the source as its SQLite database: ${SAME_ERROR}") +endif() + +execute_process( + COMMAND "${PROGRAM}" recover "${SOURCE}" "${DATABASE}" --id 1 --dest "${DESTINATION_FILE}" + RESULT_VARIABLE DEST_RESULT + ERROR_VARIABLE DEST_ERROR) +if (DEST_RESULT EQUAL 0 OR NOT DEST_ERROR MATCHES "path validation failed for destination.*destination is a file") + message(FATAL_ERROR "recover did not reject a file destination: ${DEST_ERROR}") +endif() + +if (UNIX) + set(SOURCE_ALIAS "${TEST_ROOT}/source-alias") + file(CREATE_LINK "${SOURCE}" "${SOURCE_ALIAS}" SYMBOLIC) + execute_process( + COMMAND "${PROGRAM}" scan "${SOURCE}" --out "${SOURCE_ALIAS}" + RESULT_VARIABLE ALIAS_RESULT + ERROR_VARIABLE ALIAS_ERROR) + if (ALIAS_RESULT EQUAL 0 OR NOT ALIAS_ERROR MATCHES "alias, symlink, or equivalent path") + message(FATAL_ERROR "scan did not reject a source symlink alias: ${ALIAS_ERROR}") + endif() +endif() diff --git a/tests/PathSafetyTests.cpp b/tests/PathSafetyTests.cpp new file mode 100644 index 0000000..0176b09 --- /dev/null +++ b/tests/PathSafetyTests.cpp @@ -0,0 +1,107 @@ +#include "PathSafety.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +class TempDirectory { +public: + TempDirectory() { + auto pattern = (std::filesystem::temp_directory_path() / "jgmd-path-safety-XXXXXX").string(); + const char* created = ::mkdtemp(pattern.data()); + if (!created) { + throw std::runtime_error("mkdtemp failed"); + } + path_ = created; + } + + ~TempDirectory() { + std::error_code ec; + std::filesystem::remove_all(path_, ec); + } + + const std::filesystem::path& path() const { return path_; } + +private: + std::filesystem::path path_; +}; + +void require(bool condition, const std::string& message) { + if (!condition) { + throw std::runtime_error(message); + } +} + +template +void require_rejection(Fn&& fn, const std::string& expected, const std::string& label) { + try { + fn(); + } catch (const std::exception& error) { + require(std::string(error.what()).find(expected) != std::string::npos, + label + ": unexpected validation error: " + std::string(error.what())); + return; + } + throw std::runtime_error(label + ": unsafe path was accepted"); +} + +} // namespace + +int main() { + try { + TempDirectory temp; + const auto source_path = temp.path() / "source.img"; + { + std::ofstream source(source_path, std::ios::binary); + source << "image"; + } + + path_safety::Source source(source_path); + const int access_mode = ::fcntl(source.fd(), F_GETFL) & O_ACCMODE; + require(access_mode == O_RDONLY, "source descriptor is not read-only"); + + require_rejection( + [&] { path_safety::validate_database_path(source, source_path); }, + "alias, symlink, or equivalent path", + "same database path"); + + const auto symlink_path = temp.path() / "source-alias"; + std::filesystem::create_symlink(source_path, symlink_path); + std::error_code equivalent_error; + if (std::filesystem::equivalent(source_path, symlink_path, equivalent_error)) { + require_rejection( + [&] { path_safety::validate_database_path(source, symlink_path); }, + "alias, symlink, or equivalent path", + "symlink database path"); + } + + const auto hardlink_path = temp.path() / "source-hardlink"; + std::filesystem::create_hard_link(source_path, hardlink_path); + require_rejection( + [&] { path_safety::validate_database_path(source, hardlink_path); }, + "alias, symlink, or equivalent path", + "hardlink database path"); + + const auto destination_file = temp.path() / "destination-file"; + std::ofstream(destination_file) << "data"; + require_rejection( + [&] { path_safety::validate_destination_path(source, destination_file); }, + "destination is a file", + "file destination"); + + const auto destination_dir = temp.path() / "new" / "destination"; + path_safety::validate_destination_path(source, destination_dir); + path_safety::validate_database_path(source, temp.path() / "index" / "scan.db"); + + std::cout << "path safety tests passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << "path safety tests failed: " << error.what() << '\n'; + return 1; + } +} diff --git a/tests/RecoveryOutputIntegration.cmake b/tests/RecoveryOutputIntegration.cmake new file mode 100644 index 0000000..f6c1940 --- /dev/null +++ b/tests/RecoveryOutputIntegration.cmake @@ -0,0 +1,44 @@ +if (NOT PROGRAM OR NOT TEST_ROOT) + message(FATAL_ERROR "PROGRAM and TEST_ROOT are required") +endif() + +file(REMOVE_RECURSE "${TEST_ROOT}") +file(MAKE_DIRECTORY "${TEST_ROOT}") +set(SOURCE "${TEST_ROOT}/source.img") +set(DATABASE "${TEST_ROOT}/scan.db") +set(DESTINATION_FILE "${TEST_ROOT}/destination-file") +file(WRITE "${SOURCE}" "not-an-ntfs-image") +file(WRITE "${DATABASE}" "not-a-database") +file(WRITE "${DESTINATION_FILE}" "existing-file") + +# 1. Verify that CLI recover documents --overwrite flag +execute_process( + COMMAND "${PROGRAM}" recover --help + RESULT_VARIABLE HELP_RESULT + OUTPUT_VARIABLE HELP_OUTPUT + ERROR_VARIABLE HELP_ERROR) +if (NOT HELP_RESULT EQUAL 0 OR NOT HELP_OUTPUT MATCHES "--overwrite") + message(FATAL_ERROR "recover CLI does not expose --overwrite flag: ${HELP_OUTPUT} ${HELP_ERROR}") +endif() + +# 2. Verify that recover rejects destination if it is an existing file +execute_process( + COMMAND "${PROGRAM}" recover "${SOURCE}" "${DATABASE}" --id 1 --dest "${DESTINATION_FILE}" + RESULT_VARIABLE FILE_DEST_RESULT + ERROR_VARIABLE FILE_DEST_ERROR) +if (FILE_DEST_RESULT EQUAL 0 OR NOT FILE_DEST_ERROR MATCHES "path validation failed for destination.*destination is a file") + message(FATAL_ERROR "recover did not reject a file destination: ${FILE_DEST_ERROR}") +endif() + +# 3. Verify that path safety rejects destination alias or symlink pointing to source +if (UNIX) + set(DEST_SOURCE_ALIAS "${TEST_ROOT}/dest-source-alias") + file(CREATE_LINK "${SOURCE}" "${DEST_SOURCE_ALIAS}" SYMBOLIC) + execute_process( + COMMAND "${PROGRAM}" recover "${SOURCE}" "${DATABASE}" --id 1 --dest "${DEST_SOURCE_ALIAS}" + RESULT_VARIABLE ALIAS_RESULT + ERROR_VARIABLE ALIAS_ERROR) + if (ALIAS_RESULT EQUAL 0 OR NOT ALIAS_ERROR MATCHES "alias, symlink, or equivalent path") + message(FATAL_ERROR "recover did not reject destination source alias: ${ALIAS_ERROR}") + endif() +endif() diff --git a/tests/RecoveryOutputTests.cpp b/tests/RecoveryOutputTests.cpp new file mode 100644 index 0000000..e0e7d22 --- /dev/null +++ b/tests/RecoveryOutputTests.cpp @@ -0,0 +1,141 @@ +#include "RecoveryOutput.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { + +class TempDirectory { +public: + TempDirectory() { + auto pattern = (std::filesystem::temp_directory_path() / "jgmd-output-XXXXXX").string(); + const char* created = ::mkdtemp(pattern.data()); + if (!created) { + throw std::runtime_error("mkdtemp failed"); + } + path_ = created; + } + + ~TempDirectory() { + std::error_code ec; + std::filesystem::remove_all(path_, ec); + } + + const std::filesystem::path& path() const { return path_; } + +private: + std::filesystem::path path_; +}; + +void require(bool condition, const std::string& message) { + if (!condition) { + throw std::runtime_error(message); + } +} + +template +void require_rejection(Fn&& action, const std::string& expected) { + try { + action(); + } catch (const std::exception& error) { + require(std::string(error.what()).find(expected) != std::string::npos, + "unexpected error: " + std::string(error.what())); + return; + } + throw std::runtime_error("unsafe output operation was accepted"); +} + +std::string read_file(const std::filesystem::path& path) { + std::ifstream input(path, std::ios::binary); + return {std::istreambuf_iterator(input), std::istreambuf_iterator()}; +} + +} // namespace + +int main() { + try { + TempDirectory temp; + + recovery_output::CollisionTracker collisions; + collisions.add(recovery_output::sanitize_name("reports/2026"), "reports/2026", 10); + require_rejection( + [&] { collisions.add(recovery_output::sanitize_name("reports\\2026"), "reports\\2026", 11); }, + "sanitized output collision"); + + collisions.add(recovery_output::sanitize_name("alpha.txt"), "alpha.txt", 20); + require_rejection( + [&] { collisions.add(recovery_output::sanitize_name("ALPHA.TXT"), "ALPHA.TXT", 21); }, + "sanitized output collision"); + + const auto completed = temp.path() / "completed.bin"; + { + recovery_output::AtomicFile output(completed, false); + const std::string content = "complete"; + output.write(content.data(), content.size()); + require(std::filesystem::exists(output.partial_path()), "partial file is not visible during recovery"); + require(!std::filesystem::exists(completed), "final file became visible before commit"); + output.commit(); + } + require(read_file(completed) == "complete", "committed output content differs"); + require(!std::filesystem::exists(completed.string() + ".partial"), "partial remained after commit"); + + require_rejection( + [&] { recovery_output::AtomicFile output(completed, false); }, + "use --overwrite"); + require(read_file(completed) == "complete", "default policy overwrote an existing file"); + + { + recovery_output::AtomicFile output(completed, true); + const std::string replacement = "replacement"; + output.write(replacement.data(), replacement.size()); + output.commit(); + } + require(read_file(completed) == "replacement", "explicit overwrite did not replace output"); + + const auto interrupted = temp.path() / "interrupted.bin"; + { + recovery_output::AtomicFile output(interrupted, false); + const std::string incomplete = "incomplete"; + output.write(incomplete.data(), incomplete.size()); + } + require(!std::filesystem::exists(interrupted), "interrupted final output exists"); + require(!std::filesystem::exists(interrupted.string() + ".partial"), "interrupted partial was not removed"); + + const auto stale_final = temp.path() / "stale.bin"; + const auto stale_partial = std::filesystem::path(stale_final.string() + ".partial"); + std::ofstream(stale_partial) << "stale"; + require_rejection( + [&] { recovery_output::AtomicFile output(stale_final, false); }, + "stale partial output"); + require(std::filesystem::exists(stale_partial), "stale partial was not clearly preserved"); + + { + recovery_output::AtomicFile output(stale_final, true); + const std::string recovered = "recovered"; + output.write(recovered.data(), recovered.size()); + output.commit(); + } + require(read_file(stale_final) == "recovered", "overwrite did not replace stale partial safely"); + + const auto symlink_target = temp.path() / "symlink-target.bin"; + std::ofstream(symlink_target) << "target"; + const auto symlink_output = temp.path() / "symlink-out.bin"; + std::error_code symlink_ec; + std::filesystem::create_symlink(symlink_target, symlink_output, symlink_ec); + if (!symlink_ec && std::filesystem::is_symlink(symlink_output, symlink_ec)) { + require_rejection( + [&] { recovery_output::AtomicFile output(symlink_output, true); }, + "refusing to replace output symlink"); + } + + std::cout << "recovery output tests passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << "recovery output tests failed: " << error.what() << '\n'; + return 1; + } +}