@chainlesschain/personal-data-hub 0.1.0 → 0.2.0

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.
Files changed (116) hide show
  1. package/__tests__/adapters/ai-chat-history.test.js +395 -0
  2. package/__tests__/adapters/ai-chat-http-client.test.js +242 -0
  3. package/__tests__/adapters/ai-chat-vendors.test.js +733 -0
  4. package/__tests__/adapters/alipay-bill-adapter.test.js +538 -0
  5. package/__tests__/adapters/email-adapter.test.js +138 -1
  6. package/__tests__/adapters/email-classifier.test.js +347 -0
  7. package/__tests__/adapters/email-pdf-extractor.test.js +529 -0
  8. package/__tests__/adapters/email-retry-progress.test.js +294 -0
  9. package/__tests__/adapters/email-templates.test.js +699 -0
  10. package/__tests__/adapters/system-data-adapter.test.js +440 -0
  11. package/__tests__/adapters/system-data-disclosure.test.js +153 -0
  12. package/__tests__/analysis-skills.test.js +409 -0
  13. package/__tests__/entity-resolver-ingest-hook.test.js +177 -0
  14. package/__tests__/entity-resolver-stages.test.js +411 -0
  15. package/__tests__/entity-resolver-vault.test.js +246 -0
  16. package/__tests__/entity-resolver.test.js +526 -0
  17. package/__tests__/fixtures/entity-resolver-200-mock.json +96 -0
  18. package/__tests__/longtail-adapters.test.js +217 -0
  19. package/__tests__/mobile-extractor.test.js +288 -0
  20. package/__tests__/shopping-adapters.test.js +296 -0
  21. package/__tests__/sidecar-contacts-cross-validate.test.js +163 -0
  22. package/__tests__/sidecar-supervisor.test.js +120 -0
  23. package/__tests__/social-adapters.test.js +206 -0
  24. package/__tests__/travel-adapters.test.js +325 -0
  25. package/__tests__/vault.test.js +3 -3
  26. package/__tests__/wechat-adapter.test.js +476 -0
  27. package/__tests__/whatsapp-adapter.test.js +135 -0
  28. package/lib/adapter-spec.js +12 -0
  29. package/lib/adapters/_python-sidecar-base.js +207 -0
  30. package/lib/adapters/ai-chat-history/ai-chat-adapter.js +335 -0
  31. package/lib/adapters/ai-chat-history/cookie-auth.js +109 -0
  32. package/lib/adapters/ai-chat-history/http-client.js +211 -0
  33. package/lib/adapters/ai-chat-history/index.js +28 -0
  34. package/lib/adapters/ai-chat-history/schema-map.js +221 -0
  35. package/lib/adapters/ai-chat-history/vendor-spec.js +85 -0
  36. package/lib/adapters/ai-chat-history/vendors/coze.js +179 -0
  37. package/lib/adapters/ai-chat-history/vendors/deepseek.js +199 -0
  38. package/lib/adapters/ai-chat-history/vendors/dreamina.js +174 -0
  39. package/lib/adapters/ai-chat-history/vendors/hunyuan.js +176 -0
  40. package/lib/adapters/ai-chat-history/vendors/kimi.js +182 -0
  41. package/lib/adapters/ai-chat-history/vendors/qianfan.js +160 -0
  42. package/lib/adapters/ai-chat-history/vendors/tongyi.js +193 -0
  43. package/lib/adapters/ai-chat-history/vendors/zhipu.js +202 -0
  44. package/lib/adapters/alipay-bill/alipay-bill-adapter.js +307 -0
  45. package/lib/adapters/alipay-bill/counterparty.js +129 -0
  46. package/lib/adapters/alipay-bill/csv-parser.js +217 -0
  47. package/lib/adapters/alipay-bill/index.js +41 -0
  48. package/lib/adapters/alipay-bill/zip-decryptor.js +111 -0
  49. package/lib/adapters/email-imap/classifier.js +495 -0
  50. package/lib/adapters/email-imap/email-adapter.js +419 -8
  51. package/lib/adapters/email-imap/index.js +42 -0
  52. package/lib/adapters/email-imap/pdf-extractor.js +192 -0
  53. package/lib/adapters/email-imap/templates/bill.js +232 -0
  54. package/lib/adapters/email-imap/templates/government.js +120 -0
  55. package/lib/adapters/email-imap/templates/index.js +78 -0
  56. package/lib/adapters/email-imap/templates/order.js +186 -0
  57. package/lib/adapters/email-imap/templates/other.js +114 -0
  58. package/lib/adapters/email-imap/templates/register.js +113 -0
  59. package/lib/adapters/email-imap/templates/travel.js +157 -0
  60. package/lib/adapters/email-imap/templates/utils.js +275 -0
  61. package/lib/adapters/email-imap/transactions.js +234 -0
  62. package/lib/adapters/messaging-qq/index.js +158 -0
  63. package/lib/adapters/messaging-telegram/index.js +142 -0
  64. package/lib/adapters/messaging-whatsapp/index.js +189 -0
  65. package/lib/adapters/shopping-base/index.js +208 -0
  66. package/lib/adapters/shopping-jd/index.js +150 -0
  67. package/lib/adapters/shopping-meituan/index.js +154 -0
  68. package/lib/adapters/shopping-taobao/index.js +176 -0
  69. package/lib/adapters/social-bilibili/index.js +171 -0
  70. package/lib/adapters/social-douyin/index.js +116 -0
  71. package/lib/adapters/social-weibo/index.js +164 -0
  72. package/lib/adapters/social-xiaohongshu/index.js +96 -0
  73. package/lib/adapters/system-data/disclosure.js +166 -0
  74. package/lib/adapters/system-data/index.js +34 -0
  75. package/lib/adapters/system-data/system-data-adapter.js +344 -0
  76. package/lib/adapters/travel-12306/index.js +151 -0
  77. package/lib/adapters/travel-amap/index.js +164 -0
  78. package/lib/adapters/travel-baidu-map/index.js +162 -0
  79. package/lib/adapters/travel-base/index.js +240 -0
  80. package/lib/adapters/travel-ctrip/index.js +151 -0
  81. package/lib/adapters/wechat/content-parser.js +326 -0
  82. package/lib/adapters/wechat/db-reader.js +209 -0
  83. package/lib/adapters/wechat/index.js +28 -0
  84. package/lib/adapters/wechat/key-extractor.js +158 -0
  85. package/lib/adapters/wechat/normalize.js +220 -0
  86. package/lib/adapters/wechat/wechat-adapter.js +205 -0
  87. package/lib/analysis-skills/base.js +113 -0
  88. package/lib/analysis-skills/footprint.js +167 -0
  89. package/lib/analysis-skills/index.js +58 -0
  90. package/lib/analysis-skills/interests.js +161 -0
  91. package/lib/analysis-skills/relations.js +226 -0
  92. package/lib/analysis-skills/spending.js +216 -0
  93. package/lib/analysis-skills/timeline.js +167 -0
  94. package/lib/entity-resolver/embedding-stage.js +198 -0
  95. package/lib/entity-resolver/entity-resolver.js +384 -0
  96. package/lib/entity-resolver/index.js +42 -0
  97. package/lib/entity-resolver/llm-stage.js +191 -0
  98. package/lib/entity-resolver/rule-stage.js +208 -0
  99. package/lib/entity-resolver/worker.js +149 -0
  100. package/lib/index.js +115 -0
  101. package/lib/migrations.js +73 -0
  102. package/lib/mobile-extractor/android.js +193 -0
  103. package/lib/mobile-extractor/index.js +9 -0
  104. package/lib/mobile-extractor/ios.js +223 -0
  105. package/lib/registry.js +42 -0
  106. package/lib/sidecar/index.js +15 -0
  107. package/lib/sidecar/supervisor.js +359 -0
  108. package/lib/vault.js +266 -0
  109. package/package.json +29 -3
  110. package/scripts/_make-fixture-all.js +126 -0
  111. package/scripts/_make-fixture-contacts.js +84 -0
  112. package/scripts/evaluate-entity-resolver.js +213 -0
  113. package/scripts/smoke-phase-5-5.js +196 -0
  114. package/scripts/smoke-phase-5-7.js +181 -0
  115. package/scripts/smoke-system-data-contacts.js +309 -0
  116. package/scripts/smoke-system-data.js +312 -0
@@ -0,0 +1,217 @@
1
+ "use strict";
2
+
3
+ import { describe, it, expect } from "vitest";
4
+
5
+ const fs = require("node:fs");
6
+ const path = require("node:path");
7
+ const os = require("node:os");
8
+
9
+ const {
10
+ DouyinAdapter,
11
+ XiaohongshuAdapter,
12
+ QQAdapter,
13
+ TelegramAdapter,
14
+ } = require("../lib");
15
+ const { assertAdapter } = require("../lib/adapter-spec");
16
+ const { validateBatch } = require("../lib/batch");
17
+
18
+ function makeMockDriver(scriptedRows) {
19
+ return function () {
20
+ return {
21
+ prepare(sql) {
22
+ return {
23
+ all() {
24
+ for (const [matchSubstr, rows] of scriptedRows) {
25
+ if (sql.includes(matchSubstr)) return rows;
26
+ }
27
+ throw new Error("no such table");
28
+ },
29
+ };
30
+ },
31
+ pragma() {},
32
+ close() {},
33
+ };
34
+ };
35
+ }
36
+
37
+ function tmpDb() {
38
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "longtail-"));
39
+ const dbPath = path.join(dir, "fake.db");
40
+ fs.writeFileSync(dbPath, "fake");
41
+ return { dir, dbPath };
42
+ }
43
+
44
+ function cleanup(dir) {
45
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_e) {}
46
+ }
47
+
48
+ // ─── DouyinAdapter ──────────────────────────────────────────────────────
49
+
50
+ describe("DouyinAdapter", () => {
51
+ it("contract conformance", () => {
52
+ const a = new DouyinAdapter({ account: { uid: "u-1" } });
53
+ expect(assertAdapter(a).ok).toBe(true);
54
+ });
55
+
56
+ it("rejects missing account.uid", () => {
57
+ expect(() => new DouyinAdapter({ account: {} })).toThrow(/uid/);
58
+ });
59
+
60
+ it("sync yields history + favourite + search", async () => {
61
+ const { dir, dbPath } = tmpDb();
62
+ try {
63
+ const mockDriver = makeMockDriver([
64
+ ["FROM video_history", [{ id: 1, aweme_id: "v1", title: "Cat", view_time: 1700000000, author: "@cat", duration: 30 }]],
65
+ ["FROM history", []],
66
+ ["FROM user_favorite", [{ id: 1, aweme_id: "v2", title: "Saved", create_time: 1700001000 }]],
67
+ ["FROM favourite", []],
68
+ ["FROM search_history", [{ id: 1, keyword: "music", time: 1700002000 }]],
69
+ ]);
70
+ const a = new DouyinAdapter({ account: { uid: "u-1" }, dbPath, dbDriverFactory: () => mockDriver });
71
+ const raws = [];
72
+ for await (const r of a.sync()) raws.push(r);
73
+ expect(raws.length).toBe(3);
74
+ for (const r of raws) {
75
+ expect(validateBatch(a.normalize(r)).valid).toBe(true);
76
+ }
77
+ } finally { cleanup(dir); }
78
+ });
79
+ });
80
+
81
+ // ─── XiaohongshuAdapter ─────────────────────────────────────────────────
82
+
83
+ describe("XiaohongshuAdapter", () => {
84
+ it("contract conformance", () => {
85
+ const a = new XiaohongshuAdapter({ account: { uid: "u-1" } });
86
+ expect(assertAdapter(a).ok).toBe(true);
87
+ });
88
+
89
+ it("rejects missing account.uid", () => {
90
+ expect(() => new XiaohongshuAdapter({ account: {} })).toThrow(/uid/);
91
+ });
92
+
93
+ it("sync yields history + likes + favourites", async () => {
94
+ const { dir, dbPath } = tmpDb();
95
+ try {
96
+ const mockDriver = makeMockDriver([
97
+ ["FROM browse_history", [{ id: 1, note_id: "n1", title: "Recipe", view_time: 1700000000, author: "chef" }]],
98
+ ["FROM note", []],
99
+ ["FROM liked_note", [{ id: 1, note_id: "n2", title: "Liked", like_time: 1700001000 }]],
100
+ ["FROM favourite", [{ id: 1, note_id: "n3", title: "Saved", save_time: 1700002000 }]],
101
+ ]);
102
+ const a = new XiaohongshuAdapter({ account: { uid: "u-1" }, dbPath, dbDriverFactory: () => mockDriver });
103
+ const raws = [];
104
+ for await (const r of a.sync()) raws.push(r);
105
+ expect(raws.length).toBe(3);
106
+ for (const r of raws) {
107
+ const batch = a.normalize(r);
108
+ expect(validateBatch(batch).valid).toBe(true);
109
+ }
110
+ } finally { cleanup(dir); }
111
+ });
112
+ });
113
+
114
+ // ─── QQAdapter ──────────────────────────────────────────────────────────
115
+
116
+ describe("QQAdapter", () => {
117
+ it("contract conformance", () => {
118
+ const a = new QQAdapter({ account: { qq: "12345" } });
119
+ expect(assertAdapter(a).ok).toBe(true);
120
+ expect(a.dataDisclosure.legalGate).toBe(true);
121
+ });
122
+
123
+ it("rejects missing account.qq", () => {
124
+ expect(() => new QQAdapter({ account: {} })).toThrow(/qq/);
125
+ });
126
+
127
+ it("authenticate fails without DB", async () => {
128
+ const a = new QQAdapter({ account: { qq: "12345" }, keyProvider: { getKey: async () => "k" } });
129
+ const r = await a.authenticate();
130
+ expect(r.ok).toBe(false);
131
+ expect(r.reason).toBe("DB_NOT_PULLED");
132
+ });
133
+
134
+ it("authenticate fails without keyProvider", async () => {
135
+ const { dir, dbPath } = tmpDb();
136
+ try {
137
+ const a = new QQAdapter({ account: { qq: "12345" }, dbPath });
138
+ const r = await a.authenticate();
139
+ expect(r.reason).toBe("NO_KEY_PROVIDER");
140
+ } finally { cleanup(dir); }
141
+ });
142
+
143
+ it("sync yields contact + group + message types", async () => {
144
+ const { dir, dbPath } = tmpDb();
145
+ try {
146
+ const mockDriver = makeMockDriver([
147
+ ["FROM mr_friend", [{ uin: "999", nickname: "好友A", remark: "" }]],
148
+ ["FROM mr_troop", [{ troop_uin: "888", troop_name: "测试群" }]],
149
+ ["FROM sqlite_master", [{ name: "msgcsr_friend_999" }]],
150
+ ["FROM msgcsr_friend_999", [{ msgid: "m1", msg: "你好", time: 1700000000, frienduin: "999", msgtype: 1 }]],
151
+ ]);
152
+ const a = new QQAdapter({
153
+ account: { qq: "12345" },
154
+ dbPath,
155
+ keyProvider: { getKey: async () => "fakekey" },
156
+ dbDriverFactory: () => mockDriver,
157
+ });
158
+ const raws = [];
159
+ for await (const r of a.sync()) raws.push(r);
160
+ expect(raws.length).toBe(3); // contact + group + message
161
+ const contact = raws.find((r) => r.payload.kind === "contact");
162
+ const group = raws.find((r) => r.payload.kind === "group");
163
+ const message = raws.find((r) => r.payload.kind === "message");
164
+ expect(contact).toBeDefined();
165
+ expect(group).toBeDefined();
166
+ expect(message).toBeDefined();
167
+ for (const r of raws) {
168
+ expect(validateBatch(a.normalize(r)).valid).toBe(true);
169
+ }
170
+ } finally { cleanup(dir); }
171
+ });
172
+ });
173
+
174
+ // ─── TelegramAdapter ────────────────────────────────────────────────────
175
+
176
+ describe("TelegramAdapter", () => {
177
+ it("contract conformance", () => {
178
+ const a = new TelegramAdapter({ account: { userId: "u-1" } });
179
+ expect(assertAdapter(a).ok).toBe(true);
180
+ expect(a.dataDisclosure.legalGate).toBe(true);
181
+ });
182
+
183
+ it("rejects missing account.userId", () => {
184
+ expect(() => new TelegramAdapter({ account: {} })).toThrow(/userId/);
185
+ });
186
+
187
+ it("sync yields user + chat + messages (no key needed)", async () => {
188
+ const { dir, dbPath } = tmpDb();
189
+ try {
190
+ const mockDriver = makeMockDriver([
191
+ ["FROM users", [{ uid: "111", name: "Alice", username: "alice", phone: "13800001111" }]],
192
+ ["FROM chats", [{ uid: "222", name: "Group A" }]],
193
+ ["FROM messages_v2", [{ mid: "m1", uid: "111", message: "Hi", date: 1700000000, out: 0 }]],
194
+ ["FROM messages", []],
195
+ ]);
196
+ const a = new TelegramAdapter({ account: { userId: "u-1" }, dbPath, dbDriverFactory: () => mockDriver });
197
+ const raws = [];
198
+ for await (const r of a.sync()) raws.push(r);
199
+ expect(raws.length).toBe(3);
200
+ for (const r of raws) {
201
+ expect(validateBatch(a.normalize(r)).valid).toBe(true);
202
+ }
203
+ } finally { cleanup(dir); }
204
+ });
205
+
206
+ it("normalize contact includes phone identifier", async () => {
207
+ const a = new TelegramAdapter({ account: { userId: "u-1" } });
208
+ const batch = a.normalize({
209
+ adapter: "messaging-telegram",
210
+ originalId: "user-111",
211
+ capturedAt: Date.now(),
212
+ payload: { kind: "contact", row: { uid: "111", name: "Bob", phone: "13800001111" } },
213
+ });
214
+ expect(batch.persons[0].identifiers.telegramId).toBe("111");
215
+ expect(batch.persons[0].identifiers.phone).toEqual(["13800001111"]);
216
+ });
217
+ });
@@ -0,0 +1,288 @@
1
+ "use strict";
2
+
3
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
4
+
5
+ const fs = require("node:fs");
6
+ const path = require("node:path");
7
+ const os = require("node:os");
8
+ const { AndroidExtractor, iOSBackupReader } = require("../lib/mobile-extractor");
9
+
10
+ // ─── AndroidExtractor — mocked execFn ────────────────────────────────────
11
+
12
+ function mockExec(scriptMap) {
13
+ // scriptMap: { "args joined by space": { stdout: "...", stderr: "..." }
14
+ // | () => Promise<{stdout, stderr}> }
15
+ return async (cmd, args) => {
16
+ const key = args.join(" ");
17
+ if (key in scriptMap) {
18
+ const v = scriptMap[key];
19
+ return typeof v === "function" ? await v() : v;
20
+ }
21
+ // Allow prefix matches for convenience
22
+ for (const k of Object.keys(scriptMap)) {
23
+ if (key.startsWith(k)) {
24
+ const v = scriptMap[k];
25
+ return typeof v === "function" ? await v() : v;
26
+ }
27
+ }
28
+ throw new Error(`mockExec: no script for: ${cmd} ${key}`);
29
+ };
30
+ }
31
+
32
+ describe("AndroidExtractor", () => {
33
+ it("listDevices parses adb output", async () => {
34
+ const ext = new AndroidExtractor({
35
+ execFn: mockExec({
36
+ "devices -l": {
37
+ stdout:
38
+ "List of devices attached\n" +
39
+ "ABCDEF123 device product:redmi model:Redmi_24115RA8EC device:redmi\n" +
40
+ "OFFLINE99 offline\n",
41
+ stderr: "",
42
+ },
43
+ }),
44
+ });
45
+ const devices = await ext.listDevices();
46
+ expect(devices).toHaveLength(2);
47
+ expect(devices[0].serial).toBe("ABCDEF123");
48
+ expect(devices[0].state).toBe("device");
49
+ expect(devices[0].model).toBe("Redmi_24115RA8EC");
50
+ expect(devices[1].state).toBe("offline");
51
+ });
52
+
53
+ it("isDeviceReady checks state", async () => {
54
+ const ext = new AndroidExtractor({
55
+ execFn: mockExec({
56
+ "devices -l": {
57
+ stdout: "List of devices attached\nABCDEF123 device product:p\n",
58
+ stderr: "",
59
+ },
60
+ }),
61
+ });
62
+ expect(await ext.isDeviceReady("ABCDEF123")).toBe(true);
63
+ expect(await ext.isDeviceReady("BAD-SERIAL")).toBe(false);
64
+ });
65
+
66
+ it("probeRoot detects Magisk + selinux", async () => {
67
+ const ext = new AndroidExtractor({
68
+ execFn: mockExec({
69
+ "-s ABC shell which su": { stdout: "/system/bin/magisk\n" },
70
+ "-s ABC shell which magisk": { stdout: "/system/bin/magisk\n" },
71
+ "-s ABC shell getenforce": { stdout: "Enforcing\n" },
72
+ }),
73
+ });
74
+ const probe = await ext.probeRoot("ABC");
75
+ expect(probe.rooted).toBe(true);
76
+ expect(probe.su).toBe("magisk-su");
77
+ expect(probe.magiskInstalled).toBe(true);
78
+ expect(probe.selinux).toBe("enforcing");
79
+ });
80
+
81
+ it("probeRoot non-rooted device", async () => {
82
+ const ext = new AndroidExtractor({
83
+ execFn: mockExec({
84
+ "-s ABC shell which su": { stdout: "" },
85
+ "-s ABC shell which magisk": { stdout: "" },
86
+ "-s ABC shell getenforce": { stdout: "Enforcing\n" },
87
+ }),
88
+ });
89
+ const probe = await ext.probeRoot("ABC");
90
+ expect(probe.rooted).toBe(false);
91
+ expect(probe.su).toBeNull();
92
+ });
93
+
94
+ it("listPackages filters user-installed by default", async () => {
95
+ const ext = new AndroidExtractor({
96
+ execFn: mockExec({
97
+ "-s ABC shell pm list packages -3": {
98
+ stdout: "package:com.tencent.mm\npackage:com.taobao.taobao\n",
99
+ },
100
+ }),
101
+ });
102
+ const pkgs = await ext.listPackages("ABC");
103
+ expect(pkgs).toEqual(["com.tencent.mm", "com.taobao.taobao"]);
104
+ });
105
+
106
+ it("pull creates dest dir + invokes adb pull", async () => {
107
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ax-"));
108
+ let pulled = false;
109
+ const ext = new AndroidExtractor({
110
+ execFn: async (cmd, args) => {
111
+ if (args[2] === "pull") {
112
+ pulled = true;
113
+ // Simulate adb pull writing to dest
114
+ fs.writeFileSync(args[4], "fake-pulled-content");
115
+ return { stdout: "1 file pulled", stderr: "" };
116
+ }
117
+ throw new Error("unexpected adb call");
118
+ },
119
+ });
120
+ const dest = path.join(dir, "sub/file.bin");
121
+ await ext.pull("ABC", "/sdcard/x.bin", dest);
122
+ expect(pulled).toBe(true);
123
+ expect(fs.existsSync(dest)).toBe(true);
124
+ fs.rmSync(dir, { recursive: true, force: true });
125
+ });
126
+
127
+ it("pullFromAppPrivate uses su cat + temp + cleanup", async () => {
128
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ax-"));
129
+ const calls = [];
130
+ const ext = new AndroidExtractor({
131
+ execFn: async (cmd, args) => {
132
+ calls.push(args.join(" "));
133
+ if (args[2] === "shell" && args[3].startsWith("su -c 'cat")) {
134
+ return { stdout: "", stderr: "" };
135
+ }
136
+ if (args[2] === "pull") {
137
+ fs.writeFileSync(args[4], "decrypted-db-bytes");
138
+ return { stdout: "", stderr: "" };
139
+ }
140
+ if (args[2] === "shell" && args[3].startsWith("rm -f")) {
141
+ return { stdout: "", stderr: "" };
142
+ }
143
+ throw new Error("unexpected adb call: " + args.join(" "));
144
+ },
145
+ });
146
+ const dest = path.join(dir, "EnMicroMsg.db");
147
+ await ext.pullFromAppPrivate("ABC", "com.tencent.mm", "/data/data/com.tencent.mm/MicroMsg/x/EnMicroMsg.db", dest);
148
+ expect(fs.existsSync(dest)).toBe(true);
149
+ // Verify su cat happened + cleanup rm happened
150
+ expect(calls.some((c) => c.includes("su -c 'cat"))).toBe(true);
151
+ expect(calls.some((c) => c.includes("rm -f"))).toBe(true);
152
+ fs.rmSync(dir, { recursive: true, force: true });
153
+ });
154
+
155
+ it("lsAppPrivate parses ls -1 output", async () => {
156
+ const ext = new AndroidExtractor({
157
+ execFn: mockExec({
158
+ "-s ABC shell su -c 'ls -1 \"/data/data/com.tencent.mm\"'": {
159
+ stdout: "MicroMsg\nshared_prefs\nfiles\ncache\n",
160
+ },
161
+ }),
162
+ });
163
+ const ls = await ext.lsAppPrivate("ABC", "/data/data/com.tencent.mm");
164
+ expect(ls).toContain("MicroMsg");
165
+ expect(ls).toContain("shared_prefs");
166
+ });
167
+ });
168
+
169
+ // ─── iOSBackupReader — fixture-driven ────────────────────────────────────
170
+
171
+ describe("iOSBackupReader", () => {
172
+ let dir;
173
+
174
+ function makeBackup({ encrypted = false } = {}) {
175
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "ios-bk-"));
176
+ fs.writeFileSync(
177
+ path.join(dir, "Manifest.plist"),
178
+ `<?xml version="1.0"?><plist version="1.0"><dict>
179
+ <key>IsEncrypted</key>${encrypted ? "<true/>" : "<false/>"}
180
+ </dict></plist>`,
181
+ );
182
+ fs.writeFileSync(
183
+ path.join(dir, "Info.plist"),
184
+ `<?xml version="1.0"?><plist version="1.0"><dict>
185
+ <key>Device Name</key><string>Test iPhone</string>
186
+ <key>Product Type</key><string>iPhone15,2</string>
187
+ <key>Product Version</key><string>18.0</string>
188
+ </dict></plist>`,
189
+ );
190
+ // Empty SQLite Manifest.db — mock driver below skips it
191
+ fs.writeFileSync(path.join(dir, "Manifest.db"), Buffer.from("SQLite format 3\0"));
192
+ return dir;
193
+ }
194
+
195
+ afterEach(() => {
196
+ if (dir) {
197
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_e) {}
198
+ }
199
+ dir = null;
200
+ });
201
+
202
+ it("constructor rejects missing dir", () => {
203
+ expect(() => new iOSBackupReader({ backupDir: "/nonexistent/path/xx" })).toThrow();
204
+ expect(() => new iOSBackupReader({})).toThrow(/backupDir/);
205
+ });
206
+
207
+ it("encrypted backup throws (Phase 7.5b TODO)", async () => {
208
+ const backupDir = makeBackup({ encrypted: true });
209
+ const reader = new iOSBackupReader({
210
+ backupDir,
211
+ dbDriverFn: () => {
212
+ throw new Error("driver should not be called when encrypted");
213
+ },
214
+ });
215
+ await expect(reader.open()).rejects.toThrow(/encrypted/);
216
+ });
217
+
218
+ it("open() reads Info.plist + opens Manifest.db", async () => {
219
+ const backupDir = makeBackup({ encrypted: false });
220
+ // Mock SQLite driver
221
+ const mockDb = {
222
+ prepare: () => ({ all: () => [], get: () => undefined }),
223
+ close: () => {},
224
+ };
225
+ const mockDriver = () => mockDb;
226
+ const reader = new iOSBackupReader({ backupDir, dbDriverFn: mockDriver });
227
+ const r = await reader.open();
228
+ expect(r.encrypted).toBe(false);
229
+ expect(r.info["Device Name"]).toBe("Test iPhone");
230
+ expect(r.info["Product Version"]).toBe("18.0");
231
+ reader.close();
232
+ });
233
+
234
+ it("listFiles passes WHERE clauses based on opts", async () => {
235
+ const backupDir = makeBackup({ encrypted: false });
236
+ let lastSql, lastParams;
237
+ const mockDriver = () => ({
238
+ prepare: (sql) => ({
239
+ all: (...params) => {
240
+ lastSql = sql;
241
+ lastParams = params;
242
+ return [{ fileID: "abc", domain: "HomeDomain", relativePath: "Library/Notes/notes.sqlite", flags: 1 }];
243
+ },
244
+ }),
245
+ close: () => {},
246
+ });
247
+ const reader = new iOSBackupReader({ backupDir, dbDriverFn: mockDriver });
248
+ await reader.open();
249
+ const files = reader.listFiles({ domain: "HomeDomain", limit: 10 });
250
+ expect(files).toHaveLength(1);
251
+ expect(lastSql).toContain("WHERE domain = ?");
252
+ expect(lastParams).toContain("HomeDomain");
253
+ reader.close();
254
+ });
255
+
256
+ it("resolveFileOnDisk shards by first 2 chars", async () => {
257
+ const backupDir = makeBackup({ encrypted: false });
258
+ const mockDriver = () => ({
259
+ prepare: () => ({ all: () => [], get: () => undefined }),
260
+ close: () => {},
261
+ });
262
+ const reader = new iOSBackupReader({ backupDir, dbDriverFn: mockDriver });
263
+ await reader.open();
264
+ const p = reader.resolveFileOnDisk("abcd1234567890");
265
+ expect(p).toBe(path.join(backupDir, "ab", "abcd1234567890"));
266
+ reader.close();
267
+ });
268
+
269
+ it("copyOut copies the sharded file to localPath", async () => {
270
+ const backupDir = makeBackup({ encrypted: false });
271
+ // Create a fake sharded file
272
+ const fileID = "abc1234";
273
+ const shardedDir = path.join(backupDir, "ab");
274
+ fs.mkdirSync(shardedDir, { recursive: true });
275
+ fs.writeFileSync(path.join(shardedDir, fileID), "test-content");
276
+
277
+ const mockDriver = () => ({
278
+ prepare: () => ({ all: () => [], get: () => undefined }),
279
+ close: () => {},
280
+ });
281
+ const reader = new iOSBackupReader({ backupDir, dbDriverFn: mockDriver });
282
+ await reader.open();
283
+ const local = path.join(backupDir, "out", "extracted.bin");
284
+ reader.copyOut(fileID, local);
285
+ expect(fs.readFileSync(local, "utf-8")).toBe("test-content");
286
+ reader.close();
287
+ });
288
+ });