Fix the package fs seam: UTF-8 paths, GetUserFileName pickers, exclusive-create landing, rollback arm/disarm
Both pickers now ride GetUserFileName (mode 0/1); the "no save picker" premise was false. Landing uses O_EXCL so the create is the existence check, not a TOCTOU pair.
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
// Standalone tests for shell/package/package_rollback — no REAPER, no framework.
|
||||
// Pins the discriminator's mechanics: a file is recorded only when this journal's
|
||||
// own write landed it, a pre-existing destination is refused untouched, and
|
||||
// rollback deletes exactly the recorded set — a bystander file beside them stays,
|
||||
// and a vanished file is tolerated rather than failed.
|
||||
// Pins both halves of the deletion discriminator: the structural half (only an
|
||||
// exclusively-created path is recorded, and the record is absolute so a CWD change
|
||||
// cannot re-aim it) and the contract half (markIndexCommitted disarms rollback).
|
||||
|
||||
#include "../src/shell/package/package_rollback.h"
|
||||
#include "../src/shell/package/package_path.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
@@ -19,6 +20,11 @@ static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// The journal records absolute paths, so every expectation is built the same way.
|
||||
static std::string scratch(const std::string& name) {
|
||||
return (fs::current_path() / utf8Path(name)).u8string();
|
||||
}
|
||||
|
||||
static std::vector<std::uint8_t> patternBytes(std::size_t n, std::uint8_t seed) {
|
||||
std::vector<std::uint8_t> v(n);
|
||||
for (std::size_t i = 0; i < n; ++i)
|
||||
@@ -28,32 +34,55 @@ static std::vector<std::uint8_t> patternBytes(std::size_t n, std::uint8_t seed)
|
||||
|
||||
static void writeScratchFile(const std::string& path,
|
||||
const std::vector<std::uint8_t>& bytes) {
|
||||
std::ofstream f(path, std::ios::binary | std::ios::trunc);
|
||||
std::ofstream f(utf8Path(path), std::ios::binary | std::ios::trunc);
|
||||
f.write(reinterpret_cast<const char*>(bytes.data()),
|
||||
static_cast<std::streamsize>(bytes.size()));
|
||||
}
|
||||
|
||||
static std::vector<std::uint8_t> readAll(const std::string& path) {
|
||||
std::ifstream f(path, std::ios::binary);
|
||||
std::ifstream f(utf8Path(path), std::ios::binary);
|
||||
return std::vector<std::uint8_t>(std::istreambuf_iterator<char>(f),
|
||||
std::istreambuf_iterator<char>());
|
||||
}
|
||||
|
||||
static bool exists(const std::string& path) { return fs::exists(utf8Path(path)); }
|
||||
|
||||
static void removeQuietly(const std::string& path) {
|
||||
std::error_code ec;
|
||||
fs::remove(utf8Path(path), ec);
|
||||
}
|
||||
|
||||
static void testLandRecordsOnSuccessOnly() {
|
||||
LandedFileJournal journal;
|
||||
const std::string path = "rb_land.bin";
|
||||
const std::string path = scratch("rb_land.bin");
|
||||
const std::vector<std::uint8_t> bytes = patternBytes(32, 1);
|
||||
CHECK(journal.writeLandedFile(path, PayloadBuffer(bytes)));
|
||||
CHECK(readAll(path) == bytes);
|
||||
CHECK(journal.landedPaths() == (std::vector<std::string>{path}));
|
||||
CHECK(!fs::exists(path + ".rsbanktmp"));
|
||||
CHECK(journal.landedPaths().size() == 1);
|
||||
CHECK(!exists(path + ".rsbanktmp")); // the land is a direct exclusive create
|
||||
journal.rollback();
|
||||
CHECK(!fs::exists(path));
|
||||
CHECK(!exists(path)); // the recorded path denoted the file we asked for
|
||||
}
|
||||
|
||||
static void testRelativeInputIsRecordedAbsolute() {
|
||||
// The hazard: a bare name recorded verbatim, then a CWD change, and rollback
|
||||
// unlinks whatever now sits at that name in the new directory.
|
||||
LandedFileJournal journal;
|
||||
CHECK(journal.writeLandedFile("rb_relative.bin", PayloadBuffer(patternBytes(8, 4))));
|
||||
CHECK(journal.landedPaths().size() == 1);
|
||||
const std::string recorded = journal.landedPaths().front();
|
||||
CHECK(utf8Path(recorded).is_absolute());
|
||||
std::error_code ec;
|
||||
// Absolute AND still the same file — a spelling check alone would not prove that.
|
||||
CHECK(fs::equivalent(utf8Path(recorded), utf8Path(scratch("rb_relative.bin")), ec));
|
||||
CHECK(!ec);
|
||||
journal.rollback();
|
||||
CHECK(!exists(scratch("rb_relative.bin")));
|
||||
}
|
||||
|
||||
static void testExistingDestinationRefusedUntouched() {
|
||||
LandedFileJournal journal;
|
||||
const std::string path = "rb_existing.bin";
|
||||
const std::string path = scratch("rb_existing.bin");
|
||||
const std::vector<std::uint8_t> original = patternBytes(16, 0x60);
|
||||
writeScratchFile(path, original);
|
||||
|
||||
@@ -63,46 +92,49 @@ static void testExistingDestinationRefusedUntouched() {
|
||||
|
||||
const RollbackResult result = journal.rollback();
|
||||
CHECK(result.deletedCount == 0);
|
||||
CHECK(fs::exists(path)); // rollback cannot touch a file it did not write
|
||||
std::error_code ec;
|
||||
fs::remove(path, ec);
|
||||
CHECK(exists(path)); // rollback cannot touch a file it did not write
|
||||
removeQuietly(path);
|
||||
}
|
||||
|
||||
static void testEmptyPayloadRefused() {
|
||||
LandedFileJournal journal;
|
||||
CHECK(!journal.writeLandedFile("rb_empty.bin", PayloadBuffer{}));
|
||||
CHECK(!fs::exists("rb_empty.bin"));
|
||||
const std::string path = scratch("rb_empty.bin");
|
||||
CHECK(!journal.writeLandedFile(path, PayloadBuffer{}));
|
||||
CHECK(!exists(path));
|
||||
CHECK(journal.empty());
|
||||
}
|
||||
|
||||
static void testRollbackDeletesExactlyTheRecordedSet() {
|
||||
LandedFileJournal journal;
|
||||
CHECK(journal.writeLandedFile("rb_a.bin", PayloadBuffer(patternBytes(8, 1))));
|
||||
CHECK(journal.writeLandedFile("rb_c.bin", PayloadBuffer(patternBytes(8, 2))));
|
||||
writeScratchFile("rb_bystander.bin", patternBytes(8, 3)); // not journal-written
|
||||
const std::string a = scratch("rb_a.bin");
|
||||
const std::string c = scratch("rb_c.bin");
|
||||
const std::string bystander = scratch("rb_bystander.bin");
|
||||
CHECK(journal.writeLandedFile(a, PayloadBuffer(patternBytes(8, 1))));
|
||||
CHECK(journal.writeLandedFile(c, PayloadBuffer(patternBytes(8, 2))));
|
||||
writeScratchFile(bystander, patternBytes(8, 3)); // not journal-written
|
||||
|
||||
const RollbackResult result = journal.rollback();
|
||||
CHECK(result.deletedCount == 2);
|
||||
CHECK(result.alreadyAbsentCount == 0);
|
||||
CHECK(result.failedCount == 0);
|
||||
CHECK(!fs::exists("rb_a.bin"));
|
||||
CHECK(!fs::exists("rb_c.bin"));
|
||||
CHECK(fs::exists("rb_bystander.bin")); // exactly the given files, nothing else
|
||||
CHECK(!result.refused);
|
||||
CHECK(!exists(a));
|
||||
CHECK(!exists(c));
|
||||
CHECK(exists(bystander)); // exactly the given files, nothing else
|
||||
CHECK(journal.empty());
|
||||
|
||||
const RollbackResult second = journal.rollback(); // cleared: a no-op
|
||||
CHECK(second.deletedCount == 0);
|
||||
CHECK(fs::exists("rb_bystander.bin"));
|
||||
std::error_code ec;
|
||||
fs::remove("rb_bystander.bin", ec);
|
||||
CHECK(exists(bystander));
|
||||
removeQuietly(bystander);
|
||||
}
|
||||
|
||||
static void testVanishedFileIsToleratedNotFailed() {
|
||||
LandedFileJournal journal;
|
||||
CHECK(journal.writeLandedFile("rb_gone.bin", PayloadBuffer(patternBytes(8, 1))));
|
||||
std::error_code ec;
|
||||
fs::remove("rb_gone.bin", ec); // vanished between land and rollback
|
||||
CHECK(!ec);
|
||||
const std::string path = scratch("rb_gone.bin");
|
||||
CHECK(journal.writeLandedFile(path, PayloadBuffer(patternBytes(8, 1))));
|
||||
removeQuietly(path); // vanished between land and rollback
|
||||
CHECK(!exists(path));
|
||||
|
||||
const RollbackResult result = journal.rollback();
|
||||
CHECK(result.deletedCount == 0);
|
||||
@@ -110,12 +142,39 @@ static void testVanishedFileIsToleratedNotFailed() {
|
||||
CHECK(result.failedCount == 0);
|
||||
}
|
||||
|
||||
static void testIndexCommitDisarmsRollback() {
|
||||
// Once the index references these files the carve-out no longer covers them, so a
|
||||
// late failure in the verb must not be able to delete indexed bytes.
|
||||
LandedFileJournal journal;
|
||||
const std::string path = scratch("rb_committed.bin");
|
||||
const std::vector<std::uint8_t> bytes = patternBytes(8, 1);
|
||||
CHECK(journal.writeLandedFile(path, PayloadBuffer(bytes)));
|
||||
|
||||
journal.markIndexCommitted();
|
||||
CHECK(journal.indexCommitted());
|
||||
|
||||
const RollbackResult result = journal.rollback();
|
||||
CHECK(result.refused);
|
||||
CHECK(result.deletedCount == 0);
|
||||
CHECK(readAll(path) == bytes); // untouched
|
||||
CHECK(!journal.empty()); // the record survives the refusal
|
||||
|
||||
// Landing more files after the commit would produce unrollbackable state.
|
||||
const std::string late = scratch("rb_late.bin");
|
||||
CHECK(!journal.writeLandedFile(late, PayloadBuffer(patternBytes(8, 2))));
|
||||
CHECK(!exists(late));
|
||||
|
||||
removeQuietly(path);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testLandRecordsOnSuccessOnly();
|
||||
testRelativeInputIsRecordedAbsolute();
|
||||
testExistingDestinationRefusedUntouched();
|
||||
testEmptyPayloadRefused();
|
||||
testRollbackDeletesExactlyTheRecordedSet();
|
||||
testVanishedFileIsToleratedNotFailed();
|
||||
testIndexCommitDisarmsRollback();
|
||||
|
||||
if (g_fail == 0) std::printf("package_rollback: all tests passed\n");
|
||||
else std::printf("package_rollback: %d CHECK(s) FAILED\n", g_fail);
|
||||
|
||||
Reference in New Issue
Block a user