fix(privacy): the cleanup could never be applied, and three more from the audit

The cleanup preview marked itself stale before it could be drawn. The
gate bumps a revision on release so a preview cannot survive a wipe; I
then made the preview take the gate, so its own release invalidated it
every single time and the apply button never appeared. The feature has
been shipping non-functional since it was written, with a self-test that
asserted the exact bump that killed it.

Read-only operations no longer move the revision, and preview and
maintenance have their own marks instead of borrowing Cleanup -- which
also stops the five-second metadata refresh from expiring previews, and
stops the UI announcing "another operation is running: cleanup" during a
VACUUM.

The JSON export produced invalid JSON. The chat relation kinds were
interpolated straight into the output, and interpolating an enum writes
its member name, so every message with a recognised relation came out as
"source_kind":LocalPlayer. That is the file a GDPR access request goes
out on. The self-test wrote a JSON file and never parsed it; it does now.

Retention with the limit at zero still deleted. The slider is labelled
"0 = never" and the sweep seeded 31 spec defaults unconditionally before
reading the user's overrides, so zero still lost free company, linkshell
and party history after ninety days -- and the short-circuit written for
exactly this case could never be reached, because the map was never
empty.

A wipe that worked reported that it had failed. VACUUM needs the
database to itself, the refilter walks a lazy reader on the primary
connection outside the lock, and the two collide -- after the DELETE has
committed. The delete paths no longer let that escape: the rows are
gone, an uncompacted file is a housekeeping problem, and telling
somebody their history is still there when it is not is a different kind
of problem.

Also:

- CSV cells starting with =, +, - or @ get a leading apostrophe. The
  content is text other people typed into a chat channel and the file
  exists to be opened in a spreadsheet.
- An export that matched nothing no longer replaces the previous one. It
  used to write its header, move it into place, and then report that
  nothing matched. Dalamud's save dialog offers no overwrite
  confirmation to fall back on, so this is the part that had to move.
- The retention sweep says so when it loses the race for the gate, and
  routes its notifications through the teardown check like everything
  else.
This commit is contained in:
2026-08-19 07:17:33 +02:00
parent 9ea9e96145
commit fdb1a98519
32 changed files with 300 additions and 20 deletions
+42 -3
View File
@@ -1064,9 +1064,23 @@ public sealed class Plugin : IAsyncDalamudPlugin
return false;
// Snapshot the policy so the user can edit settings while the sweep runs.
//
// Seeded from the spec defaults only when the global limit is not "keep
// forever". The slider is labelled "0 = never", and pre-filling 31
// channels with 365- and 90-day windows made that label a lie: setting
// it to zero still lost free company, linkshell and party history after
// ninety days, and the short-circuit in DeleteByRetentionPolicy could
// never be reached because the map was never empty.
//
// Explicit per-channel overrides still apply. Somebody who typed a
// number for one channel meant that number.
var policy = new Dictionary<int, int>();
foreach (var (type, days) in Privacy.PrivacyDefaults.DefaultRetentionDays)
policy[(int)(ushort)type] = days;
if (Config.RetentionDefaultDays > 0)
{
foreach (var (type, days) in Privacy.PrivacyDefaults.DefaultRetentionDays)
policy[(int)(ushort)type] = days;
}
// This is the enumerator the wizard's Clear() cuts short. Reading under the
// same lock the writers take keeps the policy snapshot whole.
lock (ConfigMapsLock)
@@ -1086,7 +1100,18 @@ public sealed class Plugin : IAsyncDalamudPlugin
try
{
if (!DbOperations.TryBegin(Util.DbOperation.RetentionSweep))
{
// A run the user pressed a button for has to say something.
// The pre-check in StartRetentionSweep only covers a gate
// that was already busy; losing the race here is the same
// outcome and used to be silent.
if (notify)
NotifySweep(
Resources.HellionStrings.Retention_Error,
Dalamud.Interface.ImGuiNotification.NotificationType.Warning
);
return;
}
try
{
@@ -1142,7 +1167,7 @@ public sealed class Plugin : IAsyncDalamudPlugin
{
Log.Error(e, "Retention sweep failed");
if (notify)
Util.WrapperUtil.AddNotification(
NotifySweep(
Resources.HellionStrings.Retention_Error,
Dalamud.Interface.ImGuiNotification.NotificationType.Error
);
@@ -1170,6 +1195,20 @@ public sealed class Plugin : IAsyncDalamudPlugin
}
}
// The sweep is a background thread that can outlive an unload, same as the
// settings-tab workers. A notification filed against a plugin that is gone
// belongs to nobody.
private void NotifySweep(
string message,
Dalamud.Interface.ImGuiNotification.NotificationType type
)
{
if (_isDisposing)
return;
Util.WrapperUtil.AddNotification(message, type);
}
// Read by the settings tab every frame so the manual button can say a run is
// in progress. The gate itself cannot answer that: it goes busy only once
// the worker reaches TryBegin, which is after Start returns.