@chainlesschain/personal-data-hub 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -171,6 +171,134 @@ describe("frida-agent — fallback symbol resolution", () => {
171
171
  });
172
172
  });
173
173
 
174
+ describe("frida-agent — sjqz-audit fixes (sig + format + module case)", () => {
175
+ // Helper extending fakePtr with readCString for ascii-hex tests.
176
+ function fakeAsciiHexPtr(asciiHex) {
177
+ return {
178
+ _v: asciiHex,
179
+ toInt32() { return 0; },
180
+ readByteArray(_len) { return new Uint8Array(0).buffer; },
181
+ };
182
+ }
183
+ function memoryReadCString(ptr, _maxLen) {
184
+ return ptr && typeof ptr._v === "string" ? ptr._v : null;
185
+ }
186
+
187
+ it("attaches when only uppercase libWCDB.so resolves (sjqz canonical name)", () => {
188
+ const send = vi.fn();
189
+ const Interceptor = { attach: vi.fn() };
190
+ const Module = {
191
+ findExportByName(mod, sym) {
192
+ return mod === "libWCDB.so" && sym === "sqlite3_key"
193
+ ? { symbol: sym }
194
+ : null;
195
+ },
196
+ };
197
+ const Process = {
198
+ findModuleByName(mod) {
199
+ return mod === "libWCDB.so" ? { name: mod } : null;
200
+ },
201
+ };
202
+
203
+ runAgentUnderMock({ Module, Process, Interceptor, send });
204
+
205
+ const hooked = send.mock.calls.find((c) => c[0].kind === "hooked");
206
+ expect(hooked).toBeDefined();
207
+ expect(hooked[0].module).toBe("libWCDB.so");
208
+ });
209
+
210
+ it("v2 hook reads key from args[2] and length from args[3] (not args[1]/[2])", () => {
211
+ const send = vi.fn();
212
+ const attached = {};
213
+ const Interceptor = {
214
+ attach(addr, handlers) { attached[addr.symbol] = handlers; },
215
+ };
216
+ const Module = {
217
+ findExportByName(mod, sym) {
218
+ return sym === "sqlite3_key_v2" ? { symbol: sym } : null;
219
+ },
220
+ };
221
+ const Process = { findModuleByName() { return { name: "libwcdb.so" }; } };
222
+
223
+ runAgentUnderMock({ Module, Process, Interceptor, send });
224
+
225
+ // sqlite3_key_v2(sqlite3 *db, const char *zDbName, const void *pKey, int nKey)
226
+ // args[0]=db, args[1]=name, args[2]=keyBytes, args[3]=len
227
+ const dbNamePtr = fakePtr("ffeeffeeffeeffeeffeeffeeffeeffee"); // would be wrong if read as key
228
+ const keyHex = "12345678" + "00".repeat(28); // 32 bytes
229
+ const args = [
230
+ fakePtr(0), // db
231
+ dbNamePtr, // name (NOT the key)
232
+ fakePtr(keyHex), // pKey — correct args[2]
233
+ fakePtr(32), // nKey — correct args[3]
234
+ ];
235
+ attached.sqlite3_key_v2.onEnter(args);
236
+
237
+ const keyEvt = send.mock.calls.find((c) => c[0].kind === "key");
238
+ expect(keyEvt).toBeDefined();
239
+ expect(keyEvt[0].hex).toBe(keyHex); // proves args[2] was read, not args[1]
240
+ expect(keyEvt[0].sig).toBe("v2");
241
+ expect(keyEvt[0].format).toBe("raw-bytes");
242
+ expect(keyEvt[0].length).toBe(32);
243
+ });
244
+
245
+ it("reads ascii-hex key via readCString when length === 64", () => {
246
+ const send = vi.fn();
247
+ const attached = {};
248
+ const Interceptor = {
249
+ attach(addr, handlers) { attached[addr.symbol] = handlers; },
250
+ };
251
+ const Module = {
252
+ findExportByName(mod, sym) {
253
+ return sym === "sqlite3_key" ? { symbol: sym } : null;
254
+ },
255
+ };
256
+ const Process = { findModuleByName() { return { name: "libwcdb.so" }; } };
257
+ const Memory = { readCString: memoryReadCString };
258
+
259
+ runAgentUnderMock({ Module, Process, Interceptor, send, Memory });
260
+
261
+ // 64-char ASCII hex string + len=64 → readCString path (sjqz scenario)
262
+ const asciiHex = "ABCDEF0123456789".repeat(4).toLowerCase();
263
+ const args = [fakePtr(0), fakeAsciiHexPtr(asciiHex), fakePtr(64)];
264
+ attached.sqlite3_key.onEnter(args);
265
+
266
+ const keyEvt = send.mock.calls.find((c) => c[0].kind === "key");
267
+ expect(keyEvt).toBeDefined();
268
+ expect(keyEvt[0].hex).toBe(asciiHex);
269
+ expect(keyEvt[0].format).toBe("ascii-hex");
270
+ expect(keyEvt[0].length).toBe(64);
271
+ });
272
+
273
+ it("emits unsupported-signature error for mangled C++ symbol (no host attempt)", () => {
274
+ const send = vi.fn();
275
+ const attached = {};
276
+ const Interceptor = {
277
+ attach(addr, handlers) { attached[addr.symbol] = handlers; },
278
+ };
279
+ const mangledSymbol =
280
+ "_ZN4WCDB8Database13setCipherKeyERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE";
281
+ const Module = {
282
+ findExportByName(mod, sym) {
283
+ return sym === mangledSymbol ? { symbol: sym } : null;
284
+ },
285
+ };
286
+ const Process = { findModuleByName() { return { name: "libwcdb.so" }; } };
287
+
288
+ runAgentUnderMock({ Module, Process, Interceptor, send });
289
+
290
+ attached[mangledSymbol].onEnter([fakePtr(0), fakePtr("aabb"), fakePtr(32)]);
291
+
292
+ const errEvt = send.mock.calls.find(
293
+ (c) => c[0].kind === "error" && /unsupported symbol signature/.test(c[0].message),
294
+ );
295
+ expect(errEvt).toBeDefined();
296
+ // And NO key event emitted (host must fall back to MD5 path).
297
+ const keyEvt = send.mock.calls.find((c) => c[0].kind === "key");
298
+ expect(keyEvt).toBeUndefined();
299
+ });
300
+ });
301
+
174
302
  describe("frida-agent — module not yet loaded path", () => {
175
303
  it("emits module-waiting and schedules retry", () => {
176
304
  const send = vi.fn();
@@ -183,7 +311,10 @@ describe("frida-agent — module not yet loaded path", () => {
183
311
 
184
312
  const waiting = send.mock.calls.find((c) => c[0].kind === "module-waiting");
185
313
  expect(waiting).toBeDefined();
186
- expect(waiting[0].module).toBe("libwcdb.so");
314
+ // Post-sjqz audit: agent now tries both libWCDB.so (uppercase, sjqz-verified)
315
+ // and libwcdb.so. The module-waiting event surfaces the join so the
316
+ // host telemetry shows both attempted names.
317
+ expect(waiting[0].module).toBe("libWCDB.so|libwcdb.so");
187
318
  expect(setTimeoutMock).toHaveBeenCalled();
188
319
  // First retry delay 500ms
189
320
  expect(setTimeoutMock.mock.calls[0][1]).toBe(500);
@@ -0,0 +1,261 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Integration test — A8 v0.1 Bilibili snapshot → vault pipeline.
5
+ *
6
+ * Exercises the full chain WITHOUT any real WebView / OkHttp / Android JNI:
7
+ *
8
+ * Synthetic snapshot JSON (4 kinds)
9
+ * ↓
10
+ * AdapterRegistry (real) + LocalVault (real, SQLCipher)
11
+ * ↓
12
+ * adapter.sync({ inputPath }) → _syncViaSnapshot yields raw events
13
+ * ↓
14
+ * registry.putRawEvent → vault.raw_events
15
+ * ↓
16
+ * adapter.normalize(raw) → batch
17
+ * ↓
18
+ * vault.putBatch → events / persons / places / items / topics tables
19
+ *
20
+ * Two scenarios:
21
+ * A. happy path — 4-kind snapshot ingests; vault yields exact counts;
22
+ * KG triples derive; originalId stable across re-sync (idempotency)
23
+ * B. partial snapshot — only history + follow; vault gets correct subset
24
+ *
25
+ * Win note: bs3mc has a known NODE_MODULE_VERSION mismatch on this dev box
26
+ * (Node 22.22.2 ABI v127 vs prebuild ABI v140); test passes on CI Linux
27
+ * which uses the matched prebuild. See memory pdh-plan-a-android-standalone-
28
+ * design §"bs3mc NODE_MODULE_VERSION mismatch".
29
+ */
30
+
31
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
32
+
33
+ const fs = require("node:fs");
34
+ const path = require("node:path");
35
+ const os = require("node:os");
36
+
37
+ const {
38
+ LocalVault,
39
+ generateKeyHex,
40
+ AdapterRegistry,
41
+ } = require("../../lib");
42
+ const {
43
+ BilibiliAdapter,
44
+ SNAPSHOT_SCHEMA_VERSION,
45
+ } = require("../../lib/adapters/social-bilibili");
46
+
47
+ function makeRig() {
48
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bili-int-"));
49
+ const vault = new LocalVault({ path: path.join(dir, "v.db"), key: generateKeyHex() });
50
+ vault.open();
51
+ const registry = new AdapterRegistry({ vault });
52
+ return { vault, registry, dir };
53
+ }
54
+
55
+ function cleanup(rig) {
56
+ if (!rig) return;
57
+ try { rig.vault.close(); } catch (_e) { /* noop */ }
58
+ try { fs.rmSync(rig.dir, { recursive: true, force: true }); } catch (_e) { /* noop */ }
59
+ }
60
+
61
+ function writeSnapshot(dir, snapshot) {
62
+ const p = path.join(dir, "social-bilibili.json");
63
+ fs.writeFileSync(p, JSON.stringify(snapshot), "utf-8");
64
+ return p;
65
+ }
66
+
67
+ function sampleSnapshot(opts = {}) {
68
+ const include = {
69
+ history: true,
70
+ favourite: true,
71
+ dynamic: true,
72
+ follow: true,
73
+ ...opts.include,
74
+ };
75
+ const events = [];
76
+ if (include.history) {
77
+ events.push({
78
+ kind: "history",
79
+ id: "BV1abc",
80
+ capturedAt: 1715000000000,
81
+ title: "Rust 异步学习",
82
+ bvid: "BV1abc",
83
+ avid: 42,
84
+ duration: 600,
85
+ uploader: "技术UP主",
86
+ uploaderMid: 100,
87
+ part: "01 介绍",
88
+ });
89
+ }
90
+ if (include.favourite) {
91
+ events.push({
92
+ kind: "favourite",
93
+ id: "fav-BV2def",
94
+ capturedAt: 1714000000000,
95
+ title: "前端架构",
96
+ bvid: "BV2def",
97
+ folderName: "学习",
98
+ uploader: "码农UP",
99
+ });
100
+ }
101
+ if (include.dynamic) {
102
+ events.push({
103
+ kind: "dynamic",
104
+ id: "dyn-99",
105
+ capturedAt: 1713000000000,
106
+ summary: "今天发了一个新视频",
107
+ dynamicType: "av",
108
+ rid: "99",
109
+ authorMid: 200,
110
+ authorName: "我关注的UP",
111
+ });
112
+ }
113
+ if (include.follow) {
114
+ events.push({
115
+ kind: "follow",
116
+ id: "follow-300",
117
+ capturedAt: 1712000000000,
118
+ mid: 300,
119
+ uname: "美食UP",
120
+ face: "https://i0.hdslb.com/300.jpg",
121
+ sign: "好吃的视频",
122
+ });
123
+ }
124
+ return {
125
+ schemaVersion: SNAPSHOT_SCHEMA_VERSION,
126
+ snapshottedAt: 1716000000000,
127
+ account: { uid: "12345", displayName: "alice" },
128
+ events,
129
+ };
130
+ }
131
+
132
+ describe("Integration — A8 Bilibili snapshot → vault end-to-end", () => {
133
+ let rig;
134
+ afterEach(() => { cleanup(rig); rig = null; });
135
+
136
+ it("4 kinds ingest into vault with exact entity counts", async () => {
137
+ rig = makeRig();
138
+ const adapter = new BilibiliAdapter();
139
+ rig.registry.register(adapter);
140
+
141
+ const snapshotPath = writeSnapshot(rig.dir, sampleSnapshot());
142
+ const report = await rig.registry.syncAdapter("social-bilibili", {
143
+ inputPath: snapshotPath,
144
+ });
145
+
146
+ // 3 events (history + favourite + dynamic) + 1 person (follow) +
147
+ // 2 items (history-video + favourite-video)
148
+ expect(report.status).toBe("ok");
149
+ expect(report.entityCounts.events).toBe(3);
150
+ expect(report.entityCounts.persons).toBe(1);
151
+ expect(report.entityCounts.items).toBe(2);
152
+
153
+ // Vault round-trip
154
+ const events = rig.vault.queryEvents({ limit: 100 });
155
+ expect(events).toHaveLength(3);
156
+ const subtypes = events.map((e) => e.subtype).sort();
157
+ expect(subtypes).toEqual(["browse", "browse", "like"]); // history+dynamic+favourite
158
+
159
+ const persons = rig.vault.queryPersons({ limit: 100 });
160
+ expect(persons).toHaveLength(1);
161
+ expect(persons[0].names[0]).toBe("美食UP");
162
+
163
+ const items = rig.vault.queryItems({ limit: 100 });
164
+ expect(items).toHaveLength(2);
165
+ expect(items.map((i) => i.name).sort()).toEqual(["Rust 异步学习", "前端架构"]);
166
+ });
167
+
168
+ it("re-sync is idempotent — same snapshot twice does NOT double entities", async () => {
169
+ rig = makeRig();
170
+ const adapter = new BilibiliAdapter();
171
+ rig.registry.register(adapter);
172
+ const snapshotPath = writeSnapshot(rig.dir, sampleSnapshot());
173
+
174
+ // First sync
175
+ const report1 = await rig.registry.syncAdapter("social-bilibili", {
176
+ inputPath: snapshotPath,
177
+ });
178
+ expect(report1.status).toBe("ok");
179
+
180
+ // Second sync — same snapshot
181
+ const report2 = await rig.registry.syncAdapter("social-bilibili", {
182
+ inputPath: snapshotPath,
183
+ });
184
+ expect(report2.status).toBe("ok");
185
+
186
+ // Stable originalId means re-sync de-dups at raw_events layer.
187
+ // The person/item entities should remain at 1 / 2 respectively
188
+ // because their IDs derive from bvid / mid (stable). Events can
189
+ // legitimately double-write because each "browse" is a separate
190
+ // occurrence — registry doesn't dedup events.
191
+ const persons = rig.vault.queryPersons({ limit: 100 });
192
+ expect(persons).toHaveLength(1);
193
+
194
+ const items = rig.vault.queryItems({ limit: 100 });
195
+ // Items with same bvid produce same ID, so item table stays at 2
196
+ // (UPSERT semantics via primary-key id).
197
+ expect(items).toHaveLength(2);
198
+ });
199
+
200
+ it("partial snapshot (history + follow only) yields exact subset", async () => {
201
+ rig = makeRig();
202
+ const adapter = new BilibiliAdapter();
203
+ rig.registry.register(adapter);
204
+
205
+ const snapshotPath = writeSnapshot(
206
+ rig.dir,
207
+ sampleSnapshot({ include: { favourite: false, dynamic: false } })
208
+ );
209
+ const report = await rig.registry.syncAdapter("social-bilibili", {
210
+ inputPath: snapshotPath,
211
+ });
212
+ expect(report.status).toBe("ok");
213
+ expect(report.entityCounts.events).toBe(1); // history only
214
+ expect(report.entityCounts.persons).toBe(1); // follow
215
+ expect(report.entityCounts.items).toBe(1); // history video
216
+ });
217
+
218
+ it("empty events array → ok status with 0 entity counts", async () => {
219
+ rig = makeRig();
220
+ const adapter = new BilibiliAdapter();
221
+ rig.registry.register(adapter);
222
+
223
+ const snapshotPath = writeSnapshot(rig.dir, {
224
+ schemaVersion: SNAPSHOT_SCHEMA_VERSION,
225
+ snapshottedAt: Date.now(),
226
+ events: [],
227
+ });
228
+ const report = await rig.registry.syncAdapter("social-bilibili", {
229
+ inputPath: snapshotPath,
230
+ });
231
+ expect(report.status).toBe("ok");
232
+ expect(report.entityCounts.events).toBe(0);
233
+ expect(report.entityCounts.persons).toBe(0);
234
+ expect(report.entityCounts.items).toBe(0);
235
+ });
236
+
237
+ it("schemaVersion mismatch surfaces in SyncReport.error (not silent)", async () => {
238
+ rig = makeRig();
239
+ const adapter = new BilibiliAdapter();
240
+ rig.registry.register(adapter);
241
+
242
+ const snapshotPath = writeSnapshot(rig.dir, {
243
+ schemaVersion: 99, // wrong
244
+ snapshottedAt: Date.now(),
245
+ events: [],
246
+ });
247
+ const report = await rig.registry.syncAdapter("social-bilibili", {
248
+ inputPath: snapshotPath,
249
+ });
250
+ expect(report.status).toBe("error");
251
+ expect(String(report.error)).toMatch(/schemaVersion mismatch/);
252
+ });
253
+
254
+ it("registry queryable by adapter.name after register()", () => {
255
+ rig = makeRig();
256
+ const adapter = new BilibiliAdapter();
257
+ rig.registry.register(adapter);
258
+ expect(rig.registry.has("social-bilibili")).toBe(true);
259
+ expect(rig.registry.list().some((m) => m.name === "social-bilibili")).toBe(true);
260
+ });
261
+ });
@@ -37,9 +37,47 @@ describe("BilibiliAdapter", () => {
37
37
  expect(a.extractMode).toBe("device-pull");
38
38
  });
39
39
 
40
- it("rejects missing account.uid", () => {
41
- expect(() => new BilibiliAdapter({})).toThrow();
42
- expect(() => new BilibiliAdapter({ account: {} })).toThrow(/uid/);
40
+ it("accepts stateless construction (snapshot mode added in A8)", () => {
41
+ // Before A8: constructor required opts.account.uid. After A8 the adapter
42
+ // is stateless when running snapshot mode (in-APK Android cc reads a JSON
43
+ // produced by the phone). Sqlite mode still needs account.uid but the
44
+ // check moved into _syncViaSqlite where it actually matters.
45
+ expect(() => new BilibiliAdapter({})).not.toThrow();
46
+ expect(() => new BilibiliAdapter({ account: {} })).not.toThrow();
47
+ expect(() => new BilibiliAdapter()).not.toThrow();
48
+ });
49
+
50
+ it("sqlite mode rejects missing account.uid at sync time", async () => {
51
+ const a = new BilibiliAdapter({ dbPath: "/tmp/bili.db" });
52
+ // Path-existence check happens before account.uid validation, so we
53
+ // exercise the guard via dbPath=null + account=null which falls to
54
+ // "sync needs inputPath OR dbPath" first. Use a real-looking dbPath
55
+ // with no account to surface the account.uid throw deterministically.
56
+ const fs = require("node:fs");
57
+ const path = require("node:path");
58
+ const os = require("node:os");
59
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bili-no-acct-"));
60
+ const dbPath = path.join(dir, "bili.db");
61
+ fs.writeFileSync(dbPath, "fake");
62
+ try {
63
+ const b = new BilibiliAdapter({
64
+ dbPath,
65
+ dbDriverFactory: () => () => ({
66
+ prepare: () => ({ all: () => [] }),
67
+ close() {},
68
+ }),
69
+ });
70
+ let threw = null;
71
+ try {
72
+ for await (const _r of b.sync()) { /* drain */ }
73
+ } catch (err) {
74
+ threw = err;
75
+ }
76
+ expect(threw).toBeTruthy();
77
+ expect(String(threw.message)).toMatch(/account\.uid/);
78
+ } finally {
79
+ fs.rmSync(dir, { recursive: true, force: true });
80
+ }
43
81
  });
44
82
 
45
83
  it("sync yields history + favourite records via mocked driver", async () => {
@@ -83,26 +121,34 @@ describe("BilibiliAdapter", () => {
83
121
  }
84
122
  });
85
123
 
86
- it("idle when DB path missing", async () => {
124
+ it("throws when neither inputPath nor dbPath provided (A8: surface config errors)", async () => {
125
+ // Before A8: sync silently yielded 0 if dbPath missing — masked typos and
126
+ // misconfigured callers. After A8 we throw so callers see the problem.
87
127
  const a = new BilibiliAdapter({ account: { uid: "1234" } });
88
- const raws = [];
89
- for await (const r of a.sync()) raws.push(r);
90
- expect(raws).toHaveLength(0);
128
+ let threw = null;
129
+ try {
130
+ for await (const _r of a.sync()) { /* drain */ }
131
+ } catch (err) {
132
+ threw = err;
133
+ }
134
+ expect(threw).toBeTruthy();
135
+ expect(String(threw.message)).toMatch(/inputPath|dbPath/);
91
136
  });
92
137
 
93
- it("normalize captures bvid/avid/uploader into extra", async () => {
138
+ it("normalize captures bvid/avid/uploader into extra (flat payload, A8 shape)", async () => {
94
139
  const a = new BilibiliAdapter({ account: { uid: "1234" } });
95
140
  const raw = {
96
141
  adapter: "social-bilibili",
97
- originalId: "history-1",
142
+ kind: "history",
143
+ originalId: "bilibili:history:BV1abc",
98
144
  capturedAt: 1700000000000,
99
145
  payload: {
100
146
  kind: "history",
101
- row: {
102
- id: 1, bvid: "BV1abc", avid: "1234",
103
- title: "Test", view_at: 1700000000,
104
- uploader: "UpA", duration: 300,
105
- },
147
+ title: "Test",
148
+ bvid: "BV1abc",
149
+ avid: "1234",
150
+ uploader: "UpA",
151
+ duration: 300,
106
152
  },
107
153
  };
108
154
  const batch = a.normalize(raw);
@@ -110,6 +156,9 @@ describe("BilibiliAdapter", () => {
110
156
  expect(batch.events[0].extra.avid).toBe("1234");
111
157
  expect(batch.events[0].extra.uploader).toBe("UpA");
112
158
  expect(batch.events[0].extra.duration).toBe(300);
159
+ // A8: history also yields an item entity (video) for KG linkage
160
+ expect(batch.items).toHaveLength(1);
161
+ expect(batch.items[0].extra.bvid).toBe("BV1abc");
113
162
  });
114
163
  });
115
164