// prune_fs.cpp — the prune scan + THE SINGLE FILE-DELETION AUTHORITY in ReaSampler. // // deleteOrphanFile below (SHFileOperationW on Windows, std::filesystem::remove // on SWELL platforms) is the ONLY code in the system that deletes USER files — // the sole deletion authority over the bank folder's bytes (a shell removing a // transient scratch file it just created, e.g. the drop path's temp // .vstpreset, is self-cleanup, not authority over user data). Deliberately // file-local (anonymous namespace): nothing outside this TU can reach it, and // this concentration must never spread. The safety-critical "which files are // orphans" decision stays in the pure core (prune_reconcile); this TU only // enumerates, resolves, stats, and — after the confirm — executes. // // Compiled into the reaper_reasampler module. REAPER-facing only through the // persist_detail helpers and usage_scan; this TU itself calls no REAPER API directly. #include #include #include #include #include #include #include // Move-to-trash surface, trash-preferred. Windows reaches the Recycle Bin via // SHFileOperationW + FOF_ALLOWUNDO (verified against shellapi.h: SHFILEOPSTRUCTW // { hwnd, wFunc, pFrom(double-NUL list), pTo, fFlags, ... }, FO_DELETE=0x3, // FOF_ALLOWUNDO=0x40). No portable move-to-trash exists on SWELL (macOS/Linux), // so those platforms fall back to unlink — see deleteOrphanFile below. #ifdef _WIN32 #include #include #endif #include "shell/persist/persist_internal.h" #include "shell/persist/session.h" #include "shell/persist/usage_scan.h" // scanInstanceUsage — one of the authority's two inputs #include "core/capture/capture_paths.h" // resolveBankFile / bankRelativeForName / kBankSubfolder #include "core/reclaim/prune_reconcile.h" // the pure orphan decision + report tallies #include "core/tracking/tracking_authority.h" // the one protection answer namespace reasampler { namespace { namespace fs = std::filesystem; using persist_detail::projectDirOf; using persist_detail::readActiveProject; // The dry-run file-list display cap: count and size are always exact // (tallied over the full orphan set), but the enumerated list handed to the // console is clipped so a project with thousands of orphans does not flood // the report. PruneReport::truncated flags the clip. constexpr std::size_t kPruneListDisplayCap = 64; // A fresh enumerate + pure-core prune compute for the active project. Shared // by the dry-run report, the full-set query, and the deletion so all three // agree on one resolution + enumeration + set-algebra path — no divergence // between what is shown and what is deleted. REAPER-facing but writes nothing. // // * bankDirAbs — the resolved current bank folder. Empty when there is no // active/saved project, no project dir, or no folder yet. // * orphans — the full orphan set, untruncated. The pure core decides. // * sizeByRel — per-orphan on-disk byte size (0 when it could not be stat'd). // * blocked — true iff the tracking authority could not answer: `orphans` // is left EMPTY, the prune must halt rather than proceed with // degraded protection. struct PruneScan { std::string bankDirAbs; std::vector orphans; std::unordered_map sizeByRel; bool blocked = false; bool ledgerUnreadable = false; bool ledgerFutureVersion = false; std::vector unreadableUsageKeys; }; // Non-throwing: readActiveProject + resolveBankFile are pure/string; every filesystem // call below uses an error_code form so no std::filesystem_error crosses REAPER's C ABI. PruneScan scanPruneOrphans(const BankBook& book, const tracking::OriginLedger& ledger, tracking::LedgerStatus ledgerStatus) { PruneScan scan; std::string rppPath; void* proj = readActiveProject(rppPath); if (!proj || rppPath.empty()) return scan; // no active/saved project -> empty scan // Resolve the current bank folder the same way the index does — never a // stored absolute path, so a Save-As relocation is followed automatically. const std::string projectDir = projectDirOf(rppPath); const std::string bankDir = capture::resolveBankFile(projectDir, capture::kBankSubfolder); if (bankDir.empty()) return scan; // unresolvable (no project dir) -> empty scan std::error_code ec; if (!fs::exists(bankDir, ec) || !fs::is_directory(bankDir, ec)) { return scan; // no bank folder captured yet -> nothing to reclaim } // Enumerate into project-relative paths spelled the SAME way the capture // path spells them, so the pure core's exact-string match lines up with // referencedPaths() and the ledger. Non-recursive: the bank folder is // flat. Manual iterator form (it.increment(ec)) keeps the loop // non-throwing on a mid-iteration failure. std::vector present; fs::directory_iterator it(bankDir, ec); for (; !ec && it != fs::directory_iterator{}; it.increment(ec)) { const auto& entry = *it; std::error_code reg_ec; if (!entry.is_regular_file(reg_ec)) continue; // skip subdirs / specials const std::string name = entry.path().filename().string(); const std::string rel = capture::bankRelativeForName(name); if (rel.empty()) continue; present.push_back(rel); std::error_code sz_ec; const std::uintmax_t sz = entry.file_size(sz_ec); scan.sizeByRel[rel] = sz_ec ? 0 : static_cast(sz); } // The decision lives in the pure core; the tracking authority supplies both of // its tracking-derived inputs so the prune and the resample can never disagree // about what is protected. referencedPaths() unions across the whole book; the // authority's heldPaths adds every live instance's captures on top — a capture // any live instance holds can never be an orphan, even if its bank entry was // deleted while the instance kept its ref. Read-only throughout: this shell // only enumerates, resolves, and stats. scan.bankDirAbs = bankDir; const wire::UsageFoldResult usage = scanInstanceUsage(proj); const tracking::TrackingState state{ledgerStatus, ledger, usage}; const tracking::ProtectionAnswer protection = tracking::pruneProtection(state); if (protection.blocked) { // FAIL-SAFE ABORT: the protected set is unknowable. Compute NO orphans — // every downstream consumer then deletes nothing. The blockers let the // action tell the user what to recover. scan.blocked = true; scan.ledgerUnreadable = protection.ledgerUnreadable; scan.ledgerFutureVersion = protection.ledgerFutureVersion; scan.unreadableUsageKeys = protection.unreadableUsageKeys; return scan; } scan.orphans = reclaim::pruneOrphans( present, reclaim::mergeReferenced(book.referencedPaths(), protection.heldPaths), protection.ownedPaths); return scan; } // Deletes ONE orphan file, trash-preferred. Returns true iff deleted by this // call. Returns false with `outAlreadyAbsent` set when the file was already // gone (caller folds into staleness, not reclaimedCount); false with it unset // on a real delete failure (locked, conversion error — folds into // skippedCount). `absPath` is the resolved absolute path. Non-throwing: no // exception may cross the C ABI. // // Windows routes through SHFileOperationW + FOF_ALLOWUNDO (Recycle Bin, // recoverable); the no-UI flags suppress REAPER-blocking dialogs since our own // confirm already happened. Other platforms (SWELL: macOS/Linux) have no // portable move-to-trash surface, so they fall back to std::filesystem::remove // (hard unlink) behind the confirm guardrail. bool deleteOrphanFile(const std::string& absPath, bool& outUsedTrash, bool& outAlreadyAbsent) { #ifdef _WIN32 // Back-slashed, double-NUL-terminated wide string: SHFileOperation's // pFrom is a list (needs the extra terminating NUL) and rejects forward // slashes in some cases. std::string win = absPath; for (char& c : win) if (c == '/') c = '\\'; const int wlen = MultiByteToWideChar(CP_UTF8, 0, win.c_str(), -1, nullptr, 0); if (wlen <= 0) return false; // conversion failed -> real skip std::vector wbuf(static_cast(wlen) + 1, L'\0'); // +1 for list NUL MultiByteToWideChar(CP_UTF8, 0, win.c_str(), -1, wbuf.data(), wlen); SHFILEOPSTRUCTW op{}; op.hwnd = nullptr; op.wFunc = FO_DELETE; op.pFrom = wbuf.data(); op.pTo = nullptr; op.fFlags = static_cast(FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_SILENT | FOF_NOERRORUI); const int rv = SHFileOperationW(&op); if (rv == 0 && !op.fAnyOperationsAborted) { outUsedTrash = true; return true; // deleted this call -> reclaimed } // Distinguish "already absent" (nonzero return on some Windows/shell versions // for a vanished file) from a real failure so the caller can tally separately. std::error_code ec; if (!fs::exists(absPath, ec)) { outAlreadyAbsent = true; } return false; #else // No portable trash surface on SWELL platforms -> hard unlink. std::error_code ec; const bool removed = fs::remove(absPath, ec); if (removed) return true; if (ec) return false; // real failure (locked/permission) -> skip outAlreadyAbsent = true; // no error, no removal -> already gone return false; #endif } } // namespace tracking::Answer ReaSamplerSession::tiedUsageFor(const std::string& capturePath, const std::string& ownUsageKey) const { // The SECOND consumer of the same gather the prune scan above runs — deliberately // next to it, so "both answers come out of one TrackingState" is structural rather // than a rule two files have to remember. std::string rppPath; void* proj = readActiveProject(rppPath); if (!proj) return tracking::Answer::Indeterminate; const wire::UsageFoldResult usage = scanInstanceUsage(proj); const tracking::TrackingState state{trackingStatus_, tracking_, usage}; return tracking::tiedUsageExists(state, capturePath, ownUsageKey); } reclaim::PruneReport ReaSamplerSession::pruneDryRun() const { const PruneScan scan = scanPruneOrphans(book_, tracking_, trackingStatus_); reclaim::PruneReport report = reclaim::buildPruneReport(scan.orphans, scan.sizeByRel, kPruneListDisplayCap); // Surface the block so the action halts with an explicit message instead of // reporting "no orphaned files" — the count IS zero, but the user must know // the prune refused to run. report.blockedByTracking = scan.blocked; report.ledgerUnreadable = scan.ledgerUnreadable; report.ledgerFutureVersion = scan.ledgerFutureVersion; report.unreadableUsageKeys = scan.unreadableUsageKeys; return report; } std::vector ReaSamplerSession::pruneOrphanSet() const { // Full set, untruncated; empty on a block, so a caller that skipped the report // still confirms nothing. return scanPruneOrphans(book_, tracking_, trackingStatus_).orphans; } reclaim::PruneDeletionResult ReaSamplerSession::pruneReclaim( const std::vector& confirmed) const { reclaim::PruneDeletionResult result; // Re-enumerate + run the pure core FRESH (never a stale set): deletion // targets exactly `confirmed ∩ freshOrphans`, so a file that vanished or // became referenced between confirm and delete is skipped, and a newly- // appeared orphan not in `confirmed` is never swept. If this fresh scan // hits unreadable tracking state it aborts with an EMPTY orphan set, so // the plan below intersects to empty and nothing is deleted — the // fail-safe holds even in the confirm-to-delete window. const PruneScan scan = scanPruneOrphans(book_, tracking_, trackingStatus_); if (scan.bankDirAbs.empty()) return result; // no project / no folder -> nothing const std::vector plan = reclaim::pruneDeletePlan(confirmed, scan.orphans); // Staleness skip count: confirmed entries no longer fresh orphans. // pruneDeletePlan de-dups confirmed internally, so compare against the // unique-confirmed size to avoid counting de-duped entries as stale. const std::size_t uniqueConfirmedCount = std::unordered_set(confirmed.begin(), confirmed.end()).size(); result.skippedCount += uniqueConfirmedCount - plan.size(); for (const std::string& rel : plan) { // rel is index-spelled "/"; reconstruct the // absolute path from the resolved bank dir + the tail after '/'. const std::string::size_type slash = rel.find_last_of('/'); const std::string name = (slash == std::string::npos) ? rel : rel.substr(slash + 1); if (name.empty()) { ++result.skippedCount; continue; } const std::string absPath = scan.bankDirAbs + "/" + name; const auto szIt = scan.sizeByRel.find(rel); const std::uint64_t bytes = (szIt != scan.sizeByRel.end()) ? szIt->second : 0; bool alreadyAbsent = false; if (deleteOrphanFile(absPath, result.usedTrash, alreadyAbsent)) { ++result.reclaimedCount; result.reclaimedBytes += bytes; } else if (alreadyAbsent) { ++result.skippedCount; // vanished between plan and delete -> staleness } else { ++result.skippedCount; // locked / conversion failure -> recorded, not thrown } } return result; } } // namespace reasampler