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:
2026-08-02 08:15:12 -04:00
parent 41a3016e63
commit edfd7ead4d
11 changed files with 532 additions and 253 deletions
+141 -32
View File
@@ -1,12 +1,11 @@
// Standalone tests for shell/package/package_io — no REAPER, no framework. Pins the
// two properties the seam exists for: an interrupted or failed write leaves the
// destination absent or holding its prior contents (failure injected at the writer
// seam — abandonment, open failure, rename failure), and a multi-entry round trip
// holds at most one entry's payload, asserted against PayloadBuffer::alive() — the
// seam counter — rather than a memory measurement.
// Standalone tests for shell/package/package_io — no REAPER, no framework. Failure is
// injected at the writer seam (abandonment, open failure, rename failure) rather than
// simulated, and the streaming claim is asserted against PayloadBuffer::alive().
#include "../src/shell/package/package_io.h"
#include "../src/shell/package/package_path.h"
#include <algorithm>
#include <cstdio>
#include <filesystem>
#include <fstream>
@@ -28,19 +27,31 @@ static std::vector<std::uint8_t> patternBytes(std::size_t n, std::uint8_t seed)
return v;
}
static bool sameBytes(const PayloadBuffer& p, const std::vector<std::uint8_t>& want) {
return p.size() == want.size() &&
(want.empty() || std::equal(want.begin(), want.end(), p.data()));
}
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 testPayloadCounterTracksMovesNotCopies() {
CHECK(PayloadBuffer::alive() == 0);
{
@@ -81,7 +92,7 @@ static void testStreamingRoundTripHoldsOnePayload() {
std::uint64_t offset = header.size();
for (std::size_t i = 0; i < entries.size(); ++i) {
PayloadBuffer p = readFilePayload(srcs[i]);
CHECK(p.bytes() == entries[i]);
CHECK(sameBytes(p, entries[i]));
CHECK(PayloadBuffer::alive() == 1); // exactly one entry in memory
CHECK(writer.appendPayload(p));
layout.emplace_back(offset, p.size());
@@ -91,8 +102,8 @@ static void testStreamingRoundTripHoldsOnePayload() {
CHECK(writer.commit());
CHECK(!writer.commit()); // a second commit is refused
}
CHECK(!fs::exists(dest + ".rsbanktmp"));
CHECK(fs::exists(dest));
CHECK(!exists(dest + ".rsbanktmp"));
CHECK(exists(dest));
{
// Scoped: the reader holds the file open, and Windows refuses to delete an
@@ -103,14 +114,36 @@ static void testStreamingRoundTripHoldsOnePayload() {
for (std::size_t i = 0; i < entries.size(); ++i) {
PayloadBuffer p = reader.readRange(layout[i].first, layout[i].second);
CHECK(PayloadBuffer::alive() == 1); // one entry per readRange, no more
CHECK(p.bytes() == entries[i]);
CHECK(sameBytes(p, entries[i]));
}
CHECK(PayloadBuffer::alive() == 0);
}
std::error_code ec;
for (const std::string& s : srcs) fs::remove(s, ec);
fs::remove(dest, ec);
for (const std::string& s : srcs) removeQuietly(s);
removeQuietly(dest);
}
static void testEmptyPayloadIsRefusedAndPoisonsTheWriter() {
// The failure this guards: an unreadable source yields an empty payload, and a
// verb that trusted a `true` here would commit framing claiming bytes nobody wrote.
const std::string dest = "pkg_io_emptypayload.rsbank";
{
PackageFileWriter writer(dest);
const std::vector<std::uint8_t> head = patternBytes(4, 1);
CHECK(writer.appendRaw(head.data(), head.size()));
CHECK(!writer.appendPayload(PayloadBuffer{}));
CHECK(!writer.ok());
CHECK(!writer.commit()); // the short stream can never reach the destination
}
CHECK(!exists(dest));
CHECK(!exists(dest + ".rsbanktmp"));
// A zero-length appendRaw stays tolerated — framing has legitimate empty edges.
{
PackageFileWriter writer(dest);
CHECK(writer.appendRaw(nullptr, 0));
CHECK(writer.ok());
writer.abort();
}
}
static void testAbandonedWriteLeavesNoDestination() {
@@ -121,8 +154,8 @@ static void testAbandonedWriteLeavesNoDestination() {
CHECK(writer.appendRaw(some.data(), some.size()));
// no commit — destruction is the injected interruption
}
CHECK(!fs::exists(dest));
CHECK(!fs::exists(dest + ".rsbanktmp"));
CHECK(!exists(dest));
CHECK(!exists(dest + ".rsbanktmp"));
}
static void testAbortPreservesPriorContents() {
@@ -138,9 +171,26 @@ static void testAbortPreservesPriorContents() {
CHECK(!writer.commit());
}
CHECK(readAll(dest) == prior);
CHECK(!fs::exists(dest + ".rsbanktmp"));
std::error_code ec;
fs::remove(dest, ec);
CHECK(!exists(dest + ".rsbanktmp"));
removeQuietly(dest);
}
static void testCommitReplacesAnExistingFile() {
// The module's one deliberate asymmetry against LandedFileJournal's never-overwrite
// rule: the save dialog's overwrite confirm is the consent, so commit() replaces.
const std::string dest = "pkg_io_replace.rsbank";
const std::vector<std::uint8_t> prior = patternBytes(40, 0x11);
const std::vector<std::uint8_t> fresh = patternBytes(7, 0x22);
writeScratchFile(dest, prior);
{
PackageFileWriter writer(dest);
CHECK(writer.ok());
CHECK(writer.appendRaw(fresh.data(), fresh.size()));
CHECK(writer.commit());
}
CHECK(readAll(dest) == fresh);
CHECK(!exists(dest + ".rsbanktmp"));
removeQuietly(dest);
}
static void testOpenFailureIsInert() {
@@ -150,7 +200,7 @@ static void testOpenFailureIsInert() {
const std::vector<std::uint8_t> some = patternBytes(8, 1);
CHECK(!writer.appendRaw(some.data(), some.size()));
CHECK(!writer.commit());
CHECK(!fs::exists("pkg_io_no_such_dir"));
CHECK(!exists("pkg_io_no_such_dir"));
}
static void testCommitRenameFailureSelfCleans() {
@@ -158,7 +208,7 @@ static void testCommitRenameFailureSelfCleans() {
// injected commit failure, not a simulated one.
const std::string dest = "pkg_io_dir.rsbank";
std::error_code ec;
fs::create_directory(dest, ec);
fs::create_directory(utf8Path(dest), ec);
CHECK(!ec);
{
PackageFileWriter writer(dest);
@@ -167,9 +217,9 @@ static void testCommitRenameFailureSelfCleans() {
CHECK(writer.appendRaw(some.data(), some.size()));
CHECK(!writer.commit());
}
CHECK(fs::is_directory(dest)); // prior state intact
CHECK(!fs::exists(dest + ".rsbanktmp"));
fs::remove(dest, ec);
CHECK(fs::is_directory(utf8Path(dest))); // prior state intact
CHECK(!exists(dest + ".rsbanktmp"));
fs::remove(utf8Path(dest), ec);
}
static void testReaderEdges() {
@@ -188,38 +238,97 @@ static void testReaderEdges() {
CHECK(reader.readRange(5, 10).empty()); // past the end
CHECK(reader.readRange(10, 1).empty()); // starts at the end
CHECK(reader.readRange(0, 0).empty()); // zero length is failure, one branch
const PayloadBuffer slice = reader.readRange(2, 3);
CHECK(slice.bytes() ==
std::vector<std::uint8_t>(bytes.begin() + 2, bytes.begin() + 5));
CHECK(sameBytes(reader.readRange(2, 3),
std::vector<std::uint8_t>(bytes.begin() + 2, bytes.begin() + 5)));
}
removeQuietly(path);
}
static void testFileStatusSeparatesAbsentFromUnreadable() {
CHECK(fileStatus("pkg_io_no_such_file.bin") == FileStatus::Absent);
const std::string path = "pkg_io_status.bin";
writeScratchFile(path, patternBytes(4, 1));
CHECK(fileStatus(path) == FileStatus::Present);
removeQuietly(path);
// A directory in a file's place is not a readable file — the export message must
// not report it as simply missing.
const std::string dir = "pkg_io_status_dir";
std::error_code ec;
fs::remove(path, ec);
fs::create_directory(utf8Path(dir), ec);
CHECK(fileStatus(dir) == FileStatus::Unreadable);
fs::remove(utf8Path(dir), ec);
}
static void testNonAsciiPathsRoundTripAsUtf8() {
// "café" in UTF-8. Windows decodes a narrow std::filesystem path through the ANSI
// code page, so an unconverted seam lands "café" or fails outright.
const std::string dir = "pkg_io_caf\xC3\xA9_dir";
const std::string name = "caf\xC3\xA9.rsbank";
std::error_code ec;
fs::create_directory(utf8Path(dir), ec);
CHECK(!ec);
const std::string dest = dir + "/" + name;
const std::vector<std::uint8_t> bytes = patternBytes(24, 0x5A);
{
PackageFileWriter writer(dest);
CHECK(writer.ok());
CHECK(writer.appendRaw(bytes.data(), bytes.size()));
CHECK(writer.commit());
}
CHECK(exists(dest));
CHECK(fileStatus(dest) == FileStatus::Present);
CHECK(sameBytes(readFilePayload(dest), bytes));
// The listing must hand the name back in the same encoding it was given.
CHECK(listFolderFileNames(dir) == (std::vector<std::string>{name}));
fs::remove_all(utf8Path(dir), ec);
}
static void testWriteFileExclusiveRefusesAnOccupiedPath() {
const std::string path = "pkg_io_excl.bin";
const std::vector<std::uint8_t> mine = patternBytes(12, 3);
CHECK(writeFileExclusive(path, PayloadBuffer(mine)));
CHECK(readAll(path) == mine);
// The create IS the check: a second call cannot replace the first file's bytes.
CHECK(!writeFileExclusive(path, PayloadBuffer(patternBytes(9, 8))));
CHECK(readAll(path) == mine);
CHECK(!writeFileExclusive("pkg_io_excl_empty.bin", PayloadBuffer{}));
CHECK(!exists("pkg_io_excl_empty.bin"));
removeQuietly(path);
}
static void testListFolderFileNames() {
const std::string dir = "pkg_io_listdir";
std::error_code ec;
fs::create_directory(dir, ec);
fs::create_directory(utf8Path(dir), ec);
writeScratchFile(dir + "/b.bin", patternBytes(2, 1));
writeScratchFile(dir + "/a.bin", patternBytes(2, 2));
fs::create_directory(dir + "/sub", ec);
fs::create_directory(utf8Path(dir + "/sub"), ec);
writeScratchFile(dir + "/sub/c.bin", patternBytes(2, 3));
const std::vector<std::string> names = listFolderFileNames(dir);
CHECK(names == (std::vector<std::string>{"a.bin", "b.bin"})); // sorted, bare, non-recursive
CHECK(listFolderFileNames("pkg_io_no_such_dir").empty());
fs::remove_all(dir, ec);
fs::remove_all(utf8Path(dir), ec);
}
int main() {
testPayloadCounterTracksMovesNotCopies();
testStreamingRoundTripHoldsOnePayload();
testEmptyPayloadIsRefusedAndPoisonsTheWriter();
testAbandonedWriteLeavesNoDestination();
testAbortPreservesPriorContents();
testCommitReplacesAnExistingFile();
testOpenFailureIsInert();
testCommitRenameFailureSelfCleans();
testReaderEdges();
testFileStatusSeparatesAbsentFromUnreadable();
testNonAsciiPathsRoundTripAsUtf8();
testWriteFileExclusiveRefusesAnOccupiedPath();
testListFolderFileNames();
if (g_fail == 0) std::printf("package_io: all tests passed\n");
+88 -29
View File
@@ -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);