// 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 #include #include #include #include namespace reasampler { // 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; explicit PayloadBuffer(std::vector bytes); ~PayloadBuffer(); PayloadBuffer(PayloadBuffer&& other) noexcept; PayloadBuffer& operator=(PayloadBuffer&& other) noexcept; PayloadBuffer(const PayloadBuffer&) = delete; PayloadBuffer& operator=(const PayloadBuffer&) = delete; const std::uint8_t* data() const { return bytes_.data(); } std::size_t size() const { return bytes_.size(); } bool empty() const { return bytes_.empty(); } // Buffers currently holding at least one byte, process-wide. static int alive(); private: void release(); std::vector bytes_; bool counted_ = false; }; // 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(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. 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. 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. bool commit(); // Close and remove the temp; the destination is never touched. Idempotent. void abort(); const std::filesystem::path& destPath() const { return destPath_; } const std::filesystem::path& tempPath() const { return tempPath_; } private: 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. Use fileStatus() when the two must be told apart. class PackageFileReader { public: explicit PackageFileReader(const std::string& srcAbsPath); bool ok() const { return ok_; } std::uint64_t fileSize() const { return size_; } // Bytes [offset, offset+length). Range-checked against the real file size, so a // hostile layout can never demand an allocation past the file's end, and capped // against a 4 GiB sanity ceiling so a merely large-but-real file can't still // force a multi-gigabyte allocation out of one call. PayloadBuffer readRange(std::uint64_t offset, std::uint64_t length); private: std::ifstream in_; std::uint64_t size_ = 0; bool ok_ = false; }; // One source file read whole as one entry's payload — a bank file IS the streaming // unit. Empty on any failure, per PackageFileReader. PayloadBuffer readFilePayload(const std::string& absPath); // 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 listFolderFileNames(const std::string& dirAbsPath); } // namespace reasampler