Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions lib/data/db.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9998,6 +9998,19 @@ class LocalDb {
return {'fw': fw, 'last_seen': now, 'history': kept};
});

/// The firmware [noteBandFirmware] last recorded, or null when no band has
/// reported one on this install. Best-effort; never throws.
static Future<String?> bandFirmware() async {
try {
final raw = (await computeFreshness(kBandFirmwareKey))?['payload_json'];
final m = raw is String ? jsonDecode(raw) : null;
final fw = m is Map ? m['fw'] : null;
return fw is String && fw.isNotEmpty ? fw : null;
} catch (_) {
return null;
}
}

/// `compute_freshness` key marking the raw-free next-night nap cleanup
/// (compute/nap_overlap_cleanup.dart) as applied. Cleared by an import.
/// v2: the v1 pass skipped every day the readiness backfill had restamped
Expand Down
23 changes: 18 additions & 5 deletions lib/support/issue_report.dart
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,9 @@ Future<File> writeReportZip(
}

/// The plaintext `meta` field the Worker shows in the tracker. Nothing in it
/// is health data.
/// is health data. The band's generation and firmware ride here so a ticket
/// can be triaged without decrypting it: most "no data" reports turn on them
/// (a WHOOP 5.0 / MG on 50.43.x withholds all data, edge#673).
Map<String, Object?> reportServerMeta(
ReportDraft d, Map<String, Object?> meta) =>
{
Expand All @@ -191,6 +193,8 @@ Map<String, Object?> reportServerMeta(
'platform': meta['platform'],
'osVersion': meta['osVersion'],
'deviceModel': meta['deviceModel'],
'bandGeneration': ?meta['bandGeneration'],
'bandFirmware': ?meta['bandFirmware'],
'hasDatabase': d.includeDatabase && !d.fullDatabase,
'hasScreenshot': d.screenshotPath != null,
if (d.includeDatabase && !d.fullDatabase) 'rawDays': ?meta['rawDays'],
Expand Down Expand Up @@ -256,6 +260,15 @@ Future<({File zip, Map<String, Object?> meta})> collectReport(
final s = await t.staticDevice();
final now = DateTime.now();
final build = s['app_build'];
final d = app.device;
final bandGeneration = app.paired?.generation ?? d.generation;
// Only gen5 HELLOs record a firmware, and the record is per install, not
// per band: after a swap to a WHOOP 4.0, or with no band paired, it would
// name a band that is no longer the one reporting.
// ponytail: two gen5 bands in turn still share one record; keying it by
// serial is the upgrade if that ever misleads a ticket.
final bandFirmware =
bandGeneration == 'gen5' ? await LocalDb.bandFirmware() : null;
final meta = <String, Object?>{
'appVersion': '${s['app_version'] ?? '?'}${build == null ? '' : '+$build'}',
'algoVersion': kAlgoVersion,
Expand All @@ -269,12 +282,14 @@ Future<({File zip, Map<String, Object?> meta})> collectReport(
'tzOffsetMinutes': now.timeZoneOffset.inMinutes,
'installId': app.deviceId,
'createdAt': _iso(now),
'bandGeneration': bandGeneration,
'bandFirmware': bandFirmware,
};
final d = app.device;
final device = <String, Object?>{
'band': {
'paired': app.isPaired,
'generation': app.paired?.generation ?? d.generation,
'generation': bandGeneration,
'firmware': bandFirmware,
'connection': d.connection,
'batteryPct': d.batteryPct,
'charging': d.charging,
Expand All @@ -283,8 +298,6 @@ Future<({File zip, Map<String, Object?> meta})> collectReport(
'lastSyncedRecord': app.lastSynced?.tsEpoch,
'bandDataOldest': d.dataRangeOldest,
'bandDataNewest': d.dataRangeNewest,
// Firmware is not held in app state; the HELLO lines in logs/sync.log
// carry it.
},
'sensors': [
for (final r in app.sensors)
Expand Down
14 changes: 14 additions & 0 deletions server/report-worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,15 @@ export interface Meta {
hasDatabase: boolean; hasScreenshot: boolean;
/** Days of second-by-second data in the attached slice (1..14); absent when there is no database. */
rawDays?: number;
/** The paired band's generation (`gen4`, `gen5`) and firmware (`50.43.2.0`), when the app knows them.
* Optional and best-effort: a malformed value is dropped, never a reason to refuse the report. */
bandGeneration?: string;
bandFirmware?: string;
}

const BAND_GENERATION = /^[a-z0-9]{1,16}$/;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Trailing newline passes band validation

When a band field ends in a single line terminator, javaScript’s $ also matches before a final line terminator, so either regex accepts a value ending in \n; createIssue then inserts that newline into the Band summary and splits its formatting instead of dropping the malformed value.

Require an absolute end-of-input match or explicitly reject line terminators before accepting either field.

Also at server/report-worker/src/index.ts:61.

Prompt for AI agents
In `server/report-worker/src/index.ts` at line 60:

**Trailing newline passes band validation**

When a band field ends in a single line terminator, javaScript’s `$` also matches before a final line terminator, so either regex accepts a value ending in `\n`; `createIssue` then inserts that newline into the Band summary and splits its formatting instead of dropping the malformed value.

Require an absolute end-of-input match or explicitly reject line terminators before accepting either field.

Also at `server/report-worker/src/index.ts:61`.

const BAND_FIRMWARE = /^\d{1,4}(\.\d{1,6}){1,4}$/;

/** Returns a normalised Meta or null. Unknown fields are dropped. */
export function validateMeta(raw: unknown): Meta | null {
if (!raw || typeof raw !== "object") return null;
Expand All @@ -74,10 +81,14 @@ export function validateMeta(raw: unknown): Meta | null {
if (typeof m.hasDatabase !== "boolean" || typeof m.hasScreenshot !== "boolean") return null;
const rawDays = m.rawDays;
if (rawDays !== undefined && !(Number.isInteger(rawDays) && (rawDays as number) >= 1 && (rawDays as number) <= 14)) return null;
const bandGeneration = typeof m.bandGeneration === "string" && BAND_GENERATION.test(m.bandGeneration) ? m.bandGeneration : undefined;
const bandFirmware = typeof m.bandFirmware === "string" && BAND_FIRMWARE.test(m.bandFirmware) ? m.bandFirmware : undefined;
return {
v: 1, installId: installId.toLowerCase(), category, title, description, appVersion,
platform: m.platform, osVersion, deviceModel, hasDatabase: m.hasDatabase, hasScreenshot: m.hasScreenshot,
...(rawDays !== undefined && { rawDays: rawDays as number }),
...(bandGeneration !== undefined && { bandGeneration }),
...(bandFirmware !== undefined && { bandFirmware }),
};
}

Expand All @@ -90,6 +101,9 @@ async function createIssue(env: Env, id: string, m: Meta): Promise<string | null
const body = [
`**Report:** ${id}`, `**Category:** ${m.category}`,
`**App:** ${m.appVersion} (${m.platform} ${m.osVersion}, ${m.deviceModel})`,
...(m.bandGeneration || m.bandFirmware
? [`**Band:** ${m.bandGeneration ?? "unknown"}, firmware ${m.bandFirmware ?? "unknown"}`]
: []),
`**Database attached:** ${m.hasDatabase ? "yes" : "no"} | **Screenshot:** ${m.hasScreenshot ? "yes" : "no"}`,
"", m.description || "(no description)",
].join("\n");
Expand Down
38 changes: 38 additions & 0 deletions server/report-worker/test/worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,32 @@ describe("POST /v1/reports", () => {
expect(JSON.parse(row.meta).rawDays).toBe(7);
});

it("keeps the band's generation and firmware in the stored meta", async () => {
const j = (await (await post(meta({ bandGeneration: "gen5", bandFirmware: "50.43.2.0" }))).json()) as any;
const row = await e.DB.prepare("SELECT meta FROM reports WHERE id=?").bind(j.id).first<any>();
expect(JSON.parse(row.meta)).toMatchObject({ bandGeneration: "gen5", bandFirmware: "50.43.2.0" });
});

it("drops a malformed band field but still accepts the report", async () => {
const r = await post(meta({ bandGeneration: "Gen 5!", bandFirmware: "50.43.2.0\n**x**" }));
expect(r.status).toBe(201);
const row = await e.DB.prepare("SELECT meta FROM reports WHERE id=?").bind(((await r.json()) as any).id).first<any>();
const stored = JSON.parse(row.meta);
expect(stored.bandGeneration).toBeUndefined();
expect(stored.bandFirmware).toBeUndefined();
});

it("drops a band field that only ends in a line break", async () => {
// JavaScript's `$` (no m flag) matches only at the very end of the input,
// not before a trailing newline, so this must not reach the issue body.
const r = await post(meta({ bandGeneration: "gen5\n", bandFirmware: "50.43.2.0\n" }));
expect(r.status).toBe(201);
const row = await e.DB.prepare("SELECT meta FROM reports WHERE id=?").bind(((await r.json()) as any).id).first<any>();
const stored = JSON.parse(row.meta);
expect(stored.bandGeneration).toBeUndefined();
expect(stored.bandFirmware).toBeUndefined();
});

it("requires Content-Length and refuses oversized ones before reading the body", async () => {
const h = { "content-type": "application/octet-stream", "x-report-meta": metaHeader(meta()) };
expect((await req(h, streamOf([blobOf()]))).status).toBe(411);
Expand Down Expand Up @@ -208,13 +234,25 @@ describe("POST /v1/reports", () => {
const body = JSON.parse(init.body as string);
expect(body.title).toBe(`[${id}] sync: Sync stuck`);
expect(body.labels).toContain("sync");
// No band fields in this report: no Band line, rather than "unknown, unknown".
expect(body.body).not.toContain("**Band:**");
const row = await e.DB.prepare("SELECT github_issue_url u FROM reports WHERE id=?").bind(id).first<any>();
expect(row.u).toContain("/issues/1");

spy.mockRejectedValueOnce(new Error("down"));
expect((await post(meta())).status).toBe(201);
spy.mockRestore();
});

it("names the band and firmware in the GitHub issue", async () => {
e.GITHUB_TOKEN = "t"; e.GITHUB_REPO = "OpenStrap/reports";
const spy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response(JSON.stringify({ html_url: "https://github.com/OpenStrap/reports/issues/2" }), { status: 201 }));
await post(meta({ bandGeneration: "gen5", bandFirmware: "50.43.2.0" }));
const body = JSON.parse((spy.mock.calls[0] as [string, RequestInit])[1].body as string);
expect(body.body).toContain("**Band:** gen5, firmware 50.43.2.0");
spy.mockRestore();
});
});

describe("DELETE /v1/reports/:id", () => {
Expand Down
15 changes: 15 additions & 0 deletions test/issue_report_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,21 @@ void main() {
{'rawDays': 7}).containsKey('rawDays'), isFalse);
});

test('server meta names the band and its firmware, and only when known',
() {
const d = ReportDraft(
category: ReportCategory.sync, title: 't', description: 'd');
final m = reportServerMeta(
d, {'bandGeneration': 'gen5', 'bandFirmware': '50.43.2.0'});
expect(m['bandGeneration'], 'gen5');
expect(m['bandFirmware'], '50.43.2.0');
// No band, or one that never reported firmware: the keys are absent, not
// null, so the tracker never shows an empty "firmware" line.
final none = reportServerMeta(d, {});
expect(none.containsKey('bandGeneration'), isFalse);
expect(none.containsKey('bandFirmware'), isFalse);
});

test('contact rides only in the encrypted user.txt', () async {
final a = ZipDecoder().decodeBytes(await _zip());
expect(utf8.decode(a.findFile('user.txt')!.content as List<int>),
Expand Down
4 changes: 4 additions & 0 deletions test/local_counters_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ void main() {
tearDownAll(() async => LocalDb.close());

test('firmware: latest plus one history entry per change', () async {
// Nothing recorded yet reads as unknown, not as an empty version.
expect(await LocalDb.bandFirmware(), isNull);
await LocalDb.noteBandFirmware('41.16.6.0', nowSec: 100);
await LocalDb.noteBandFirmware('41.16.6.0', nowSec: 200);
await LocalDb.noteBandFirmware('41.17.1.0', nowSec: 300);
Expand All @@ -33,6 +35,8 @@ void main() {
expect(m['last_seen'], 300);
expect([for (final e in m['history'] as List) e['fw']],
['41.16.6.0', '41.17.1.0']);
// What a support report reads: the latest version only.
expect(await LocalDb.bandFirmware(), '41.17.1.0');
});

test('usage counters count', () async {
Expand Down
Loading