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,22 +1,22 @@
|
||||
// shell/package/package_io — streaming filesystem seam for bank packages: append one
|
||||
// payload at a time through a temp-file + atomic-rename writer, seek and read one
|
||||
// payload at a time back out. Bytes only: what a package contains is core/package's
|
||||
// business, never this seam's. Blocking I/O — UI-thread actions only, never the
|
||||
// audio thread.
|
||||
// shell/package/package_io — every filesystem act the export/import verbs need:
|
||||
// streaming package read/write, whole-file payload read, folder listing, file status,
|
||||
// and the exclusive create that lands one bank file. Bytes only — what a package
|
||||
// contains is core/package's business. Blocking I/O: UI-thread actions only, never
|
||||
// the audio thread.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// One entry's payload, and the seam counter that makes "never more than one entry in
|
||||
// memory" assertable: alive() counts every buffer currently holding bytes, so the
|
||||
// streaming claim is a test CHECK against this counter rather than a memory
|
||||
// measurement. Move-only — copying a payload would silently double the held bytes.
|
||||
// One entry's payload. Move-only, because a copy would silently double the bytes the
|
||||
// seam promises to hold at most one of; alive() is the counter that makes that
|
||||
// promise assertable instead of aspirational.
|
||||
class PayloadBuffer {
|
||||
public:
|
||||
PayloadBuffer() = default;
|
||||
@@ -30,7 +30,6 @@ public:
|
||||
const std::uint8_t* data() const { return bytes_.data(); }
|
||||
std::size_t size() const { return bytes_.size(); }
|
||||
bool empty() const { return bytes_.empty(); }
|
||||
const std::vector<std::uint8_t>& bytes() const { return bytes_; }
|
||||
|
||||
// Buffers currently holding at least one byte, process-wide.
|
||||
static int alive();
|
||||
@@ -42,26 +41,25 @@ private:
|
||||
bool counted_ = false;
|
||||
};
|
||||
|
||||
// Streaming atomic writer. Bytes accumulate in "<dest>.rsbanktmp" beside the
|
||||
// destination (same directory, so the final rename never crosses a volume); the
|
||||
// destination itself is touched only by commit()'s rename, so a failed, aborted, or
|
||||
// abandoned write leaves it absent or holding its prior contents — never a partial
|
||||
// file. Destruction without commit() aborts and removes the temp. commit() REPLACES
|
||||
// an existing destination: the export save dialog's own overwrite confirm is the
|
||||
// consent (the never-overwrite rule for bank-folder files lives in
|
||||
// LandedFileJournal, upstream of this writer). Neither copyable nor movable, and
|
||||
// append-only — there is deliberately no way to hand it a whole package at once.
|
||||
// Streaming atomic writer; paths cross this seam as UTF-8 narrow strings and are held
|
||||
// as fs::path internally. The temp sibling is created in the DESTINATION's own
|
||||
// directory so commit()'s rename never crosses a volume — a cross-device rename
|
||||
// degrades to a copy and stops being atomic. commit() REPLACES an existing
|
||||
// destination (the deliberate asymmetry against LandedFileJournal; see CLAUDE.md).
|
||||
class PackageFileWriter {
|
||||
public:
|
||||
explicit PackageFileWriter(std::string destAbsPath);
|
||||
explicit PackageFileWriter(const std::string& destAbsPath);
|
||||
~PackageFileWriter();
|
||||
PackageFileWriter(const PackageFileWriter&) = delete;
|
||||
PackageFileWriter& operator=(const PackageFileWriter&) = delete;
|
||||
|
||||
bool ok() const { return ok_; }
|
||||
// Framing/header bytes. False on a failed or already-finished writer.
|
||||
// Framing/header bytes. False on a failed or already-finished writer. A zero
|
||||
// length is accepted — framing has legitimate zero-length edges.
|
||||
bool appendRaw(const std::uint8_t* data, std::size_t len);
|
||||
// One entry's bytes. Same contract as appendRaw.
|
||||
// One entry's bytes. Also false — and the writer poisoned — on an EMPTY payload:
|
||||
// empty is this seam's one "nothing to work with" signal, so accepting it would
|
||||
// let a verb commit a package whose framing claims bytes nobody wrote.
|
||||
bool appendPayload(const PayloadBuffer& payload);
|
||||
// Flush, close, rename over the destination. False (and self-cleaning: the temp
|
||||
// is removed, the destination untouched) on any failure or on a second call.
|
||||
@@ -69,21 +67,21 @@ public:
|
||||
// Close and remove the temp; the destination is never touched. Idempotent.
|
||||
void abort();
|
||||
|
||||
const std::string& destPath() const { return destPath_; }
|
||||
const std::string& tempPath() const { return tempPath_; }
|
||||
const std::filesystem::path& destPath() const { return destPath_; }
|
||||
const std::filesystem::path& tempPath() const { return tempPath_; }
|
||||
|
||||
private:
|
||||
std::string destPath_;
|
||||
std::string tempPath_;
|
||||
std::filesystem::path destPath_;
|
||||
std::filesystem::path tempPath_;
|
||||
std::ofstream out_;
|
||||
bool ok_ = false;
|
||||
bool done_ = false;
|
||||
};
|
||||
|
||||
// Seek-and-read reader: exactly one payload is materialized per readRange call, and
|
||||
// there is deliberately no read-whole-file entry point. Empty buffer on ANY failure
|
||||
// — unopenable file, zero length, out of range, short read — so the caller has one
|
||||
// "nothing to work with" branch (file_bytes' contract).
|
||||
// there is deliberately no read-whole-file entry point. Empty buffer on ANY failure —
|
||||
// unopenable file, zero length, out of range, short read — so the caller has one
|
||||
// "nothing to work with" branch. Use fileStatus() when the two must be told apart.
|
||||
class PackageFileReader {
|
||||
public:
|
||||
explicit PackageFileReader(const std::string& srcAbsPath);
|
||||
@@ -101,11 +99,24 @@ private:
|
||||
};
|
||||
|
||||
// One source file read whole as one entry's payload — a bank file IS the streaming
|
||||
// unit, so whole-file here is one entry, released before the next is read. Empty on
|
||||
// any failure, per readFileBytes.
|
||||
// unit. Empty on any failure, per PackageFileReader.
|
||||
PayloadBuffer readFilePayload(const std::string& absPath);
|
||||
|
||||
// Bare file names (regular files only, never a path) in dirAbsPath, sorted so
|
||||
// Export must tell a missing indexed file from an unreadable one in its refusal
|
||||
// message; readFilePayload deliberately cannot, since both fail to an empty buffer.
|
||||
enum class FileStatus { Present, Absent, Unreadable };
|
||||
FileStatus fileStatus(const std::string& absPath);
|
||||
|
||||
// Creates absPath and writes the payload, failing if ANYTHING already occupies the
|
||||
// path. The create IS the existence check (O_EXCL / CREATE_NEW), so nothing can slip
|
||||
// in between: an exists()-then-write pair would let a file created in that window be
|
||||
// overwritten and then deleted by a rollback that believes it wrote it. Refuses an
|
||||
// empty payload, and removes its own partial file on a mid-write failure. Not
|
||||
// temp+rename — an exclusive rename has no portable spelling, and the debris a crash
|
||||
// leaves here is unrecorded and unindexed either way.
|
||||
bool writeFileExclusive(const std::string& absPath, const PayloadBuffer& payload);
|
||||
|
||||
// Bare file names (regular files only, never a path) in dirAbsPath, UTF-8, sorted so
|
||||
// callers see a deterministic order; empty on a missing or unreadable folder.
|
||||
std::vector<std::string> listFolderFileNames(const std::string& dirAbsPath);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user