fix(usage): close the remaining withdrawal races, reset per-item name consent

Follow-up review on the previous commit, including a hole in that
commit's own fix.

The cancellation flag became a counter. Clearing a boolean needed a
write of its own, and a /disable landing between the lease and that
write was erased — the same race one level down. enable() now records
the counter it started with and claims only if it is unchanged, so no
clearing write exists to lose. It also fixes the case a boolean could
not express at all: a stale cancellation already set, and a fresh one
arriving mid-activation, are indistinguishable as flags and obvious as
counts. Migration 203, separate from 202 for the reason 202 was separate
from 201 — knex will not re-run an applied migration.

deliver() re-checks immediately before dispatch. The existing check ran
before the binding lookup, which is asynchronous, so a withdrawal that
COMPLETED during it still had its registration or report sent
afterwards. Not an already-in-flight request — a new one started after
the operator had withdrawn.

The outbox writes in tick() and command() are conditional on still being
active. /disable clears pending_packet without holding the lease, so an
unconditional write put a report — or a feedback body and name — back
into an outbox the withdrawal had just emptied, where deliver() would
then leave it, since it declines to send anything but the delete.

Per-item name consent resets with the item. `named` stayed checked after
submitting, so the next item carried the previous name automatically,
contradicting the anonymous-by-default promise the disclosure makes for
each item. The remembered name stays in preferences; attaching it is
decided again each time.

Two of these tests were worthless when first written and are noted
because the pattern keeps recurring: the pre-dispatch case passed
without the guard because an empty report payload failed schema
validation during signing, so nothing reached the collector for reasons
unrelated to the check. With a valid payload it fails without the guard
and passes with it. Same for the counter: dropping it from the claim
fails two.

Refs #1110
This commit is contained in:
Paul Nothaft
2026-09-05 22:09:39 +02:00
parent 80e238f0ad
commit 22da018e1b
4 changed files with 152 additions and 19 deletions
@@ -15,6 +15,22 @@ const { UsageService } = require('../../src/usage/UsageService');
const SECRET = 'z'.repeat(48);
// A report the envelope schema accepts. An empty payload fails validation
// during signing, so the packet would never reach the collector for reasons
// unrelated to what the test is checking.
function validReport() {
const { FEATURE_KEYS } = require('../../src/usage/protocol.cjs');
return {
picpeak_version: '3.0.0',
report_date: '2026-09-05',
generated_at: '2026-09-05T00:00:00.000Z',
features: Object.fromEntries(
FEATURE_KEYS.map((k) => [k, { configured: false, used: false }])
),
gallery_layouts: ['grid'],
};
}
async function bootDb() {
const db = knex({
client: 'sqlite3',
@@ -38,7 +54,7 @@ async function bootDb() {
t.text('feedback_preferences');
t.string('lease_token', 36);
t.bigInteger('lease_until').notNullable().defaultTo(0);
t.boolean('cancel_requested').notNullable().defaultTo(false);
t.bigInteger('cancel_seq').notNullable().defaultTo(0);
});
await db.schema.createTable('product_usage_markers', (t) => {
t.string('feature', 60).primary();
@@ -99,7 +115,9 @@ describe('withdrawal during an in-flight activation', () => {
it('does not let a stale cancellation veto a later deliberate opt-in', async () => {
db = await bootDb();
await db('product_usage_state').where({ id: 1 }).update({ cancel_requested: true });
// A withdrawal from an earlier participation is already reflected in the
// counter when this activation reads it, so it cannot veto anything.
await db('product_usage_state').where({ id: 1 }).update({ cancel_seq: 7 });
const service = makeService(db);
await service.enable('usage-consent.v1');
@@ -108,4 +126,66 @@ describe('withdrawal during an in-flight activation', () => {
expect(row.status).toBe('activation_pending');
expect(row.installation_id).not.toBeNull();
});
it('honours a withdrawal even when an earlier one was never cleared', async () => {
// The case a boolean could not express: a stale cancellation is already
// set, and a fresh one lands mid-activation. With a flag both look the
// same; with a counter the second increment is visible.
db = await bootDb();
await db('product_usage_state').where({ id: 1 }).update({ cancel_seq: 3 });
const service = makeService(db, {
onBinding: async () => { await service.disable(); },
});
await service.enable('usage-consent.v1');
const row = await db('product_usage_state').where({ id: 1 }).first();
expect(row.status).toBe('disabled');
expect(row.installation_id).toBeNull();
});
it('does not dispatch a report when the withdrawal completes during preparation', async () => {
// deliver() checks for a withdrawal before the binding lookup, which is
// asynchronous. A /disable that COMPLETED during it used to have the
// report sent anyway — not an already-in-flight request, but a new one
// started after the operator had withdrawn.
db = await bootDb();
const posted = [];
const service = new UsageService(db, {
secret: SECRET,
endpoint: 'http://127.0.0.1:9/',
fetch: async (_url, init) => {
posted.push(JSON.parse(init.body).packet.action);
throw new Error('collector unreachable');
},
});
const identity = require('../../src/usage/protocol.cjs').generateIdentity();
await db('product_usage_state').where({ id: 1 }).update({
status: 'active',
installation_id: identity.installation_id,
public_key: identity.public_key,
private_key_encrypted: service.encrypt(identity.private_key),
instance_binding: 'b'.repeat(64),
sequence: 1,
pending_packet: JSON.stringify(
require('../../src/usage/protocol.cjs').makePacket(
{ installation_id: identity.installation_id },
'report',
2,
validReport()
)
),
});
// The withdrawal lands while the binding lookup is awaited.
service.binding = async () => {
await db('product_usage_state').where({ id: 1 }).update({
status: 'deletion_pending', pending_packet: null,
});
return 'b'.repeat(64);
};
await service.deliver(await db('product_usage_state').where({ id: 1 }).first());
expect(posted).not.toContain('report');
});
});