zigmini 0.9.0-beta.3 → 1.0.0-beta.1
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.
- package/README.ko.md +24 -4
- package/README.md +24 -4
- package/assets/agent/gateway-rules.md +1 -1
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/docs/manual.ko.md +51 -11
- package/docs/manual.md +52 -12
- package/host/omo-bridge/agent-control.ts +274 -0
- package/host/omo-bridge/bridge.ts +1 -1
- package/host/omo-bridge/continuity-control.ts +1 -1
- package/host/omo-bridge/task-control.ts +26 -16
- package/host/omo-check/patches/omo-5.1.51-compaction-long-context.patch +92 -0
- package/host/omo-check/patches/omo-5.1.51-compaction-port.patch +2635 -0
- package/host/omo-check/patches/omo-5.1.51-compaction-recovery.patch +911 -0
- package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +363 -0
- package/host/omo-check/patches/omo-5.1.51-provider-failover.patch +50 -0
- package/host/omo-check/patches/omo-5.1.51-provider-port.patch +441 -0
- package/host/omo-check/patches/omo-5.1.51-rpc-port.patch +799 -0
- package/host/omo-check/patches/omo-5.1.51-runtime-compat.patch +31 -0
- package/package.json +3 -2
- package/src/agent/api.ts +215 -0
- package/src/agent/definition.ts +177 -0
- package/src/agent/fields.ts +42 -0
- package/src/agent/files.ts +192 -0
- package/src/agent/legacy-files.ts +46 -0
- package/src/agent/list.ts +21 -0
- package/src/agent-rules.ts +14 -5
- package/src/approvals.ts +6 -1
- package/src/cli/main.ts +33 -0
- package/src/cli/messages.ts +10 -2
- package/src/cli/rotate-main.ts +2 -1
- package/src/cli/scan.ts +414 -0
- package/src/cli/security-import.ts +155 -0
- package/src/compact.ts +22 -0
- package/src/decisions.ts +6 -4
- package/src/engine/compat.json +3 -3
- package/src/engine/index.ts +1 -0
- package/src/engine/schema-guard.ts +12 -4
- package/src/engine/sdk.ts +8 -0
- package/src/formal-verification.ts +366 -0
- package/src/gc.ts +37 -6
- package/src/hub.ts +339 -79
- package/src/main.ts +95 -9
- package/src/maintenance.ts +1 -1
- package/src/manual.ts +9 -2
- package/src/profile-prompt.ts +73 -0
- package/src/profile.ts +33 -10
- package/src/protocol.ts +11 -2
- package/src/question-notifications.ts +4 -1
- package/src/reply-notifications.ts +7 -1
- package/src/repo-security-setting.ts +33 -0
- package/src/runtime/paths.ts +16 -0
- package/src/security/baseline.ts +170 -0
- package/src/security/dex-strings.ts +242 -0
- package/src/security/repo-scan.ts +369 -0
- package/src/security/revision-probe.ts +144 -0
- package/src/security/terms.ts +97 -0
- package/src/security-allow.ts +1 -0
- package/src/security-baseline.ts +113 -0
- package/src/security-check.ts +91 -16
- package/src/server-text.ts +38 -0
- package/src/store.ts +142 -16
- package/src/transcript.ts +7 -1
- package/src/ui-language.ts +33 -0
- package/src/update/check.ts +7 -3
- package/src/update/handoff-system.ts +24 -16
- package/src/update/handoff.ts +23 -12
- package/src/update/helper.ts +72 -36
- package/src/update/request.ts +46 -30
- package/src/update/state.ts +21 -11
- package/src/update/strategy.ts +12 -5
- package/src/update/target.ts +32 -15
- package/src/usage.ts +43 -20
- package/src/version.ts +39 -16
- package/src/voice/agents.ts +8 -3
- package/src/voice/install.ts +29 -18
- package/src/work-watch.ts +40 -18
- package/web/agent-icon.js +15 -9
- package/web/agents.d.ts +201 -0
- package/web/agents.js +813 -0
- package/web/app-download.js +32 -19
- package/web/app.js +1407 -278
- package/web/autocopy.js +6 -1
- package/web/boot.js +27 -8
- package/web/commands.js +20 -11
- package/web/decisions.js +70 -49
- package/web/devlog.js +25 -19
- package/web/diagram.js +8 -3
- package/web/enroll.js +43 -22
- package/web/gc.js +32 -19
- package/web/i18n/agent-icon.js +24 -0
- package/web/i18n/agents.js +48 -0
- package/web/i18n/app-download.js +26 -0
- package/web/i18n/autocopy.js +5 -0
- package/web/i18n/boot.js +24 -0
- package/web/i18n/commands.js +34 -0
- package/web/i18n/decisions.js +24 -0
- package/web/i18n/devlog.js +60 -0
- package/web/i18n/diagram.js +12 -0
- package/web/i18n/enroll.js +22 -0
- package/web/i18n/gc.js +38 -0
- package/web/i18n/lastsent.js +10 -0
- package/web/i18n/main.d.ts +4 -0
- package/web/i18n/main.js +527 -0
- package/web/i18n/manual.d.ts +4 -0
- package/web/i18n/manual.js +14 -0
- package/web/i18n/memo.js +30 -0
- package/web/i18n/notifications.js +16 -0
- package/web/i18n/profile.js +13 -0
- package/web/i18n/reference.js +26 -0
- package/web/i18n/security-inbox.js +40 -0
- package/web/i18n/slash.js +38 -0
- package/web/i18n/turn-stats.js +5 -0
- package/web/i18n/update.js +42 -0
- package/web/i18n/voice.js +28 -0
- package/web/i18n/wake.js +16 -0
- package/web/i18n/workid.js +10 -0
- package/web/i18n/workstatus.js +20 -0
- package/web/i18n.d.ts +20 -0
- package/web/i18n.js +63 -0
- package/web/index.html +156 -96
- package/web/lastsent.js +9 -4
- package/web/manual.d.ts +1 -1
- package/web/manual.js +11 -5
- package/web/memo.js +18 -13
- package/web/notifications.js +12 -7
- package/web/profile.js +42 -17
- package/web/reference.js +20 -15
- package/web/security-inbox.d.ts +18 -1
- package/web/security-inbox.js +173 -4
- package/web/slash.js +23 -13
- package/web/style.css +132 -3
- package/web/tooltip.js +5 -2
- package/web/turn-stats.js +6 -2
- package/web/update.js +31 -23
- package/web/voice.js +18 -8
- package/web/wake.d.ts +4 -0
- package/web/wake.js +7 -3
- package/web/workid.js +12 -2
- package/web/workstatus.js +36 -32
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
chmodSync, closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync,
|
|
4
|
+
mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync,
|
|
5
|
+
} from "node:fs";
|
|
6
|
+
import { securityBaselinePaths, type Env, type RuntimePaths } from "../runtime/paths.ts";
|
|
7
|
+
|
|
8
|
+
export const SCHEMA_VERSION = 1;
|
|
9
|
+
|
|
10
|
+
export interface BaselineAllow {
|
|
11
|
+
readonly value: string;
|
|
12
|
+
readonly scopes: readonly ("transport" | "repo")[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface BaselineFile {
|
|
16
|
+
readonly schemaVersion: typeof SCHEMA_VERSION;
|
|
17
|
+
readonly baselineId: string;
|
|
18
|
+
readonly revision: number;
|
|
19
|
+
readonly terms: readonly string[];
|
|
20
|
+
readonly home: string;
|
|
21
|
+
readonly user: string;
|
|
22
|
+
readonly allow: readonly BaselineAllow[];
|
|
23
|
+
readonly exportedAt: string;
|
|
24
|
+
readonly gatewayVersion: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type BaselineError = "missing" | "unreadable" | "invalid" | "unsupported" | "empty" | "insecure";
|
|
28
|
+
export type BaselineResult =
|
|
29
|
+
| { readonly ok: true; readonly file: BaselineFile }
|
|
30
|
+
| { readonly ok: false; readonly error: BaselineError };
|
|
31
|
+
export type BaselineWriteResult =
|
|
32
|
+
| { readonly ok: true }
|
|
33
|
+
| { readonly ok: false; readonly error: "write_failed" };
|
|
34
|
+
|
|
35
|
+
export interface BaselineFileSource {
|
|
36
|
+
readonly kind: "baseline";
|
|
37
|
+
readonly path: string;
|
|
38
|
+
readonly explicit: boolean;
|
|
39
|
+
}
|
|
40
|
+
export type BaselineSource =
|
|
41
|
+
| BaselineFileSource
|
|
42
|
+
| { readonly kind: "legacy-list"; readonly path: string }
|
|
43
|
+
| { readonly kind: "hash-only" }
|
|
44
|
+
| { readonly kind: "missing" };
|
|
45
|
+
|
|
46
|
+
export function legacyListSource(path: string): Extract<BaselineSource, { kind: "legacy-list" }> {
|
|
47
|
+
return { kind: "legacy-list", path };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Only callers with an explicit CI policy or a hash-only test may select this source. */
|
|
51
|
+
export function hashOnlySource(): Extract<BaselineSource, { kind: "hash-only" }> {
|
|
52
|
+
return { kind: "hash-only" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
56
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Parse the file boundary, not the store's term policy. Errors never contain file contents. */
|
|
60
|
+
export function parseBaseline(text: string): BaselineResult {
|
|
61
|
+
let input: unknown;
|
|
62
|
+
try { input = JSON.parse(text); }
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (error instanceof SyntaxError) return { ok: false, error: "invalid" };
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
if (!record(input) || !Number.isSafeInteger(input.schemaVersion)) return { ok: false, error: "invalid" };
|
|
68
|
+
if (input.schemaVersion !== SCHEMA_VERSION) return { ok: false, error: "unsupported" };
|
|
69
|
+
if (typeof input.baselineId !== "string" || input.baselineId.trim().length === 0
|
|
70
|
+
|| typeof input.revision !== "number" || !Number.isSafeInteger(input.revision) || input.revision < 0
|
|
71
|
+
|| !Array.isArray(input.terms) || !input.terms.every((term): term is string => typeof term === "string" && term.trim().length > 0)
|
|
72
|
+
|| typeof input.home !== "string" || input.home.length === 0 || typeof input.user !== "string"
|
|
73
|
+
|| !Array.isArray(input.allow)
|
|
74
|
+
|| typeof input.exportedAt !== "string" || !Number.isFinite(Date.parse(input.exportedAt))
|
|
75
|
+
|| typeof input.gatewayVersion !== "string" || input.gatewayVersion.length === 0) {
|
|
76
|
+
return { ok: false, error: "invalid" };
|
|
77
|
+
}
|
|
78
|
+
const allow: BaselineAllow[] = [];
|
|
79
|
+
for (const entry of input.allow) {
|
|
80
|
+
if (!record(entry) || typeof entry.value !== "string" || entry.value.trim().length === 0
|
|
81
|
+
|| !Array.isArray(entry.scopes)
|
|
82
|
+
|| !entry.scopes.every((scope): scope is "transport" | "repo" => scope === "transport" || scope === "repo")
|
|
83
|
+
|| !entry.scopes.includes("transport") || new Set(entry.scopes).size !== entry.scopes.length) {
|
|
84
|
+
return { ok: false, error: "invalid" };
|
|
85
|
+
}
|
|
86
|
+
allow.push({ value: entry.value, scopes: entry.scopes });
|
|
87
|
+
}
|
|
88
|
+
if (input.terms.length === 0) return { ok: false, error: "empty" };
|
|
89
|
+
return { ok: true, file: {
|
|
90
|
+
schemaVersion: input.schemaVersion, baselineId: input.baselineId, revision: input.revision,
|
|
91
|
+
terms: input.terms, home: input.home, user: input.user, allow,
|
|
92
|
+
exportedAt: input.exportedAt, gatewayVersion: input.gatewayVersion,
|
|
93
|
+
} };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** lstat preserves dangling links and unreadable paths for validation instead of silently downgrading. */
|
|
97
|
+
function present(path: string): boolean {
|
|
98
|
+
try { lstatSync(path); return true; }
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (error instanceof Error && "code" in error) return error.code !== "ENOENT";
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface BaselineSourceOptions {
|
|
106
|
+
readonly baselineFlag?: string;
|
|
107
|
+
readonly env?: Env;
|
|
108
|
+
readonly paths?: Pick<RuntimePaths, "secrets">;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Selection only. Explicit missing paths stay selected; the caller validates exactly one source. */
|
|
112
|
+
export function resolveBaselineSource(options: BaselineSourceOptions): BaselineSource {
|
|
113
|
+
const env = options.env ?? process.env;
|
|
114
|
+
const paths = securityBaselinePaths(env, options.paths);
|
|
115
|
+
if (options.baselineFlag !== undefined) return { kind: "baseline", path: options.baselineFlag, explicit: true };
|
|
116
|
+
if (env.ZGW_SECURITY_BASELINE_FILE !== undefined) return { kind: "baseline", path: env.ZGW_SECURITY_BASELINE_FILE, explicit: true };
|
|
117
|
+
if (present(paths.securityBaselineFile)) return { kind: "baseline", path: paths.securityBaselineFile, explicit: false };
|
|
118
|
+
if (env.ZIGMINI_PRIVATE_STRINGS !== undefined) return legacyListSource(env.ZIGMINI_PRIVATE_STRINGS);
|
|
119
|
+
if (present(paths.legacyPrivateStringsFile)) return legacyListSource(paths.legacyPrivateStringsFile);
|
|
120
|
+
return { kind: "missing" };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Only baseline JSON has this security contract; legacy-list and hash-only are separate consumers. */
|
|
124
|
+
export function readBaseline(source: BaselineFileSource): BaselineResult {
|
|
125
|
+
if (source.path.trim().length === 0) return { ok: false, error: "invalid" };
|
|
126
|
+
try {
|
|
127
|
+
const entry = lstatSync(source.path);
|
|
128
|
+
if (entry.isSymbolicLink()) return { ok: false, error: "insecure" };
|
|
129
|
+
if (!entry.isFile()) return { ok: false, error: "unreadable" };
|
|
130
|
+
if ((entry.mode & 0o7777) !== 0o600 || entry.uid !== process.getuid?.()) return { ok: false, error: "insecure" };
|
|
131
|
+
const fd = openSync(source.path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
132
|
+
try {
|
|
133
|
+
// Check the opened inode as well: replacement between lstat and open cannot bypass permissions or ownership.
|
|
134
|
+
const opened = fstatSync(fd);
|
|
135
|
+
if (!opened.isFile()) return { ok: false, error: "unreadable" };
|
|
136
|
+
if ((opened.mode & 0o7777) !== 0o600 || opened.uid !== process.getuid?.()) return { ok: false, error: "insecure" };
|
|
137
|
+
return parseBaseline(readFileSync(fd, "utf8"));
|
|
138
|
+
} finally { closeSync(fd); }
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (!(error instanceof Error) || !("code" in error)) throw error;
|
|
141
|
+
if (error.code === "ENOENT") return { ok: false, error: "missing" };
|
|
142
|
+
if (error.code === "ELOOP") return { ok: false, error: "insecure" };
|
|
143
|
+
return { ok: false, error: "unreadable" };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Synchronous export: private temporary inode, flush, close, then same-directory atomic rename. */
|
|
148
|
+
export function writeBaselineAtomic(path: string, file: BaselineFile): BaselineWriteResult {
|
|
149
|
+
// Runtime paths are macOS/POSIX paths; keeping the final slash also handles a file directly under /.
|
|
150
|
+
const directory = path.includes("/") ? path.slice(0, path.lastIndexOf("/") + 1) : ".";
|
|
151
|
+
try {
|
|
152
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
153
|
+
chmodSync(directory, 0o700);
|
|
154
|
+
const temporary = `${directory}/.security-baseline-${randomUUID()}.tmp`;
|
|
155
|
+
const fd = openSync(temporary, "wx", 0o600);
|
|
156
|
+
try {
|
|
157
|
+
try {
|
|
158
|
+
// A restrictive umask may remove owner bits; it must never make the exported file unreadable.
|
|
159
|
+
fchmodSync(fd, 0o600);
|
|
160
|
+
writeFileSync(fd, `${JSON.stringify(file)}\n`, "utf8");
|
|
161
|
+
fsyncSync(fd);
|
|
162
|
+
} finally { closeSync(fd); }
|
|
163
|
+
renameSync(temporary, path);
|
|
164
|
+
} finally { rmSync(temporary, { force: true }); }
|
|
165
|
+
return { ok: true };
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (error instanceof Error) return { ok: false, error: "write_failed" };
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/** Bounded string extraction, not a bytecode verifier. See AOSP dex-format string_data_item. */
|
|
2
|
+
export class DexFormatError extends Error {
|
|
3
|
+
override readonly name = "DexFormatError";
|
|
4
|
+
constructor(readonly reason: string) { super(`private_scan_dex_${reason}`); }
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface DexString {
|
|
8
|
+
readonly text: string;
|
|
9
|
+
/** A declared Kotlin getter's package in an exact compiler source trace, not a host value. */
|
|
10
|
+
readonly sourcePackage: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function stringData(bytes: Uint8Array, range: { readonly start: number; readonly end: number }): string {
|
|
14
|
+
let at = range.start;
|
|
15
|
+
const byte = () => {
|
|
16
|
+
if (at >= range.end) throw new DexFormatError("string_truncated");
|
|
17
|
+
const value = bytes[at++];
|
|
18
|
+
if (value === undefined) throw new DexFormatError("string_truncated");
|
|
19
|
+
return value;
|
|
20
|
+
};
|
|
21
|
+
let length = 0;
|
|
22
|
+
for (let shift = 0; ; shift += 7) {
|
|
23
|
+
const part = byte();
|
|
24
|
+
if (shift === 28 && part > 15) throw new DexFormatError("uleb");
|
|
25
|
+
length += (part & 127) * 2 ** shift;
|
|
26
|
+
if (part < 128) break;
|
|
27
|
+
}
|
|
28
|
+
if (length > range.end - at) throw new DexFormatError("string_length");
|
|
29
|
+
let text = "";
|
|
30
|
+
for (;;) {
|
|
31
|
+
const lead = byte();
|
|
32
|
+
if (lead === 0) break;
|
|
33
|
+
let unit = lead;
|
|
34
|
+
if (lead >= 128) {
|
|
35
|
+
if (lead < 0xc0 || lead >= 0xf0) throw new DexFormatError("mutf8");
|
|
36
|
+
const second = byte();
|
|
37
|
+
if ((second & 0xc0) !== 0x80) throw new DexFormatError("mutf8");
|
|
38
|
+
if (lead < 0xe0) {
|
|
39
|
+
unit = ((lead & 31) << 6) | (second & 63);
|
|
40
|
+
if (unit !== 0 && unit < 128) throw new DexFormatError("mutf8");
|
|
41
|
+
} else {
|
|
42
|
+
const third = byte();
|
|
43
|
+
if ((third & 0xc0) !== 0x80) throw new DexFormatError("mutf8");
|
|
44
|
+
unit = ((lead & 15) << 12) | ((second & 63) << 6) | (third & 63);
|
|
45
|
+
if (unit < 2048) throw new DexFormatError("mutf8");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
text += String.fromCharCode(unit);
|
|
49
|
+
}
|
|
50
|
+
if (text.length !== length) throw new DexFormatError("string_length");
|
|
51
|
+
if (range.end - at > 3 || bytes.subarray(at, range.end).some((value) => value !== 0)) throw new DexFormatError("string_gap");
|
|
52
|
+
return text;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Magic selects the format, never a filename extension. Recognized malformed/unsupported DEX fails closed. */
|
|
56
|
+
export function dexStrings(bytes: Uint8Array): readonly DexString[] | null {
|
|
57
|
+
if (bytes[0] !== 100 || bytes[1] !== 101 || bytes[2] !== 120 || bytes[3] !== 10) return null;
|
|
58
|
+
if (bytes.length < 112) throw new DexFormatError("header");
|
|
59
|
+
const magic = new TextDecoder().decode(bytes.subarray(0, 8));
|
|
60
|
+
if (!/^dex\n0(?:35|37|38|39|40)\0$/.test(magic)) throw new DexFormatError("version");
|
|
61
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
62
|
+
const uint = (at: number) => view.getUint32(at, true);
|
|
63
|
+
if (uint(32) !== bytes.length || uint(36) !== 112 || uint(40) !== 0x12345678) throw new DexFormatError("header");
|
|
64
|
+
if (uint(44) !== 0 || uint(48) !== 0) throw new DexFormatError("linked");
|
|
65
|
+
const dataStart = uint(108);
|
|
66
|
+
if (dataStart < 112 || dataStart % 4 !== 0 || uint(104) !== bytes.length - dataStart) throw new DexFormatError("data");
|
|
67
|
+
const table = (at: number, width: number) => {
|
|
68
|
+
const count = uint(at);
|
|
69
|
+
const offset = uint(at + 4);
|
|
70
|
+
if (count === 0) {
|
|
71
|
+
if (offset !== 0) throw new DexFormatError("table");
|
|
72
|
+
} else if (offset < 112 || offset % 4 !== 0 || count > (dataStart - offset) / width) {
|
|
73
|
+
throw new DexFormatError("table");
|
|
74
|
+
}
|
|
75
|
+
return { count, offset };
|
|
76
|
+
};
|
|
77
|
+
const ids = table(56, 4);
|
|
78
|
+
const types = table(64, 4);
|
|
79
|
+
const protos = table(72, 12);
|
|
80
|
+
const fields = table(80, 8);
|
|
81
|
+
const methods = table(88, 8);
|
|
82
|
+
const classes = table(96, 32);
|
|
83
|
+
const map = uint(52);
|
|
84
|
+
if (map < dataStart || map % 4 !== 0 || map > bytes.length - 4) throw new DexFormatError("map");
|
|
85
|
+
const mapCount = uint(map);
|
|
86
|
+
if (mapCount > (bytes.length - map - 4) / 12) throw new DexFormatError("map");
|
|
87
|
+
let stringStart = 0;
|
|
88
|
+
let stringEnd = bytes.length;
|
|
89
|
+
let stringCount = 0;
|
|
90
|
+
let previous = -1;
|
|
91
|
+
let preceding: { end: number } | undefined;
|
|
92
|
+
const sections = new Map<number, { readonly count: number; readonly offset: number; end: number }>();
|
|
93
|
+
for (let i = 0; i < mapCount; i++) {
|
|
94
|
+
const at = map + 4 + i * 12;
|
|
95
|
+
const type = view.getUint16(at, true);
|
|
96
|
+
const offset = uint(at + 8);
|
|
97
|
+
const count = uint(at + 4);
|
|
98
|
+
if (sections.has(type) || offset <= previous || offset >= bytes.length || count === 0 || view.getUint16(at + 2, true) !== 0) throw new DexFormatError("map");
|
|
99
|
+
if (type >= 0x1000 && offset < dataStart) throw new DexFormatError("map_data");
|
|
100
|
+
if (preceding) preceding.end = offset;
|
|
101
|
+
const section = { count, offset, end: bytes.length };
|
|
102
|
+
sections.set(type, section);
|
|
103
|
+
preceding = section;
|
|
104
|
+
previous = offset;
|
|
105
|
+
if (stringStart > 0 && offset > stringStart && stringEnd === bytes.length) stringEnd = offset;
|
|
106
|
+
if (type === 0x2002) { stringStart = offset; stringCount = uint(at + 4); }
|
|
107
|
+
}
|
|
108
|
+
for (const [type, expected] of [
|
|
109
|
+
[0, { count: 1, offset: 0 }], [1, ids], [2, types], [3, protos],
|
|
110
|
+
[4, fields], [5, methods], [6, classes], [0x1000, { count: 1, offset: map }],
|
|
111
|
+
] as const) {
|
|
112
|
+
const section = sections.get(type);
|
|
113
|
+
if (expected.count === 0 ? section !== undefined : section?.count !== expected.count || section.offset !== expected.offset) throw new DexFormatError("map_header");
|
|
114
|
+
}
|
|
115
|
+
const mapEnd = sections.get(0x1000)?.end ?? 0;
|
|
116
|
+
if (map + 4 + mapCount * 12 > mapEnd) throw new DexFormatError("map");
|
|
117
|
+
// Without any strings no referenced data can be proved. Only the header and map
|
|
118
|
+
// may remain; do not silently discard an old string table or unclaimed payload.
|
|
119
|
+
if (ids.count === 0 && (sections.size !== 2 || dataStart !== 112 || map !== dataStart || mapEnd !== map + 4 + mapCount * 12)) throw new DexFormatError("empty_strings");
|
|
120
|
+
if (stringCount !== ids.count || (ids.count > 0 && stringStart < dataStart)) throw new DexFormatError("strings");
|
|
121
|
+
const offsets = Array.from({ length: ids.count }, (_, index) => ({ index, at: uint(ids.offset + index * 4) })).sort((a, b) => a.at - b.at);
|
|
122
|
+
const texts = new Map<number, string>();
|
|
123
|
+
for (const [position, item] of offsets.entries()) {
|
|
124
|
+
const end = offsets[position + 1]?.at ?? stringEnd;
|
|
125
|
+
if (position === 0 && item.at !== stringStart) throw new DexFormatError("string_offset");
|
|
126
|
+
if (item.at < stringStart || item.at >= end || end > stringEnd) throw new DexFormatError("string_offset");
|
|
127
|
+
texts.set(item.index, stringData(bytes, { start: item.at, end }));
|
|
128
|
+
}
|
|
129
|
+
const textAt = (index: number) => {
|
|
130
|
+
const text = texts.get(index);
|
|
131
|
+
if (text === undefined) throw new DexFormatError("string_index");
|
|
132
|
+
return text;
|
|
133
|
+
};
|
|
134
|
+
const descriptors = Array.from({ length: types.count }, (_, index) => textAt(uint(types.offset + index * 4)));
|
|
135
|
+
const shortType = (index: number) => {
|
|
136
|
+
const descriptor = descriptors[index];
|
|
137
|
+
if (descriptor === undefined) throw new DexFormatError("prototype_type");
|
|
138
|
+
return descriptor.startsWith("L") || descriptor.startsWith("[") ? "L" : descriptor;
|
|
139
|
+
};
|
|
140
|
+
const returns = Array.from({ length: protos.count }, (_, index) => {
|
|
141
|
+
const at = protos.offset + index * 12;
|
|
142
|
+
const result = shortType(uint(at + 4));
|
|
143
|
+
let shorty = result;
|
|
144
|
+
const parameters = uint(at + 8);
|
|
145
|
+
if (parameters !== 0) {
|
|
146
|
+
const section = sections.get(0x1001);
|
|
147
|
+
if (!section || parameters % 4 !== 0 || parameters < section.offset || parameters > section.end - 4) throw new DexFormatError("prototype_parameters");
|
|
148
|
+
const count = uint(parameters);
|
|
149
|
+
if (count > (section.end - parameters - 4) / 2) throw new DexFormatError("prototype_parameters");
|
|
150
|
+
for (let i = 0; i < count; i++) {
|
|
151
|
+
const type = shortType(view.getUint16(parameters + 4 + i * 2, true));
|
|
152
|
+
if (type === "V") throw new DexFormatError("prototype_type");
|
|
153
|
+
shorty += type;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (!/^[VZBSCIJFDL][ZBSCIJFDL]*$/.test(shorty) || textAt(uint(at)) !== shorty) throw new DexFormatError("prototype_shorty");
|
|
157
|
+
return result;
|
|
158
|
+
});
|
|
159
|
+
const sources = new Map<number, { readonly packageName: string; readonly file: string }>();
|
|
160
|
+
const owners = new Map<number, number>();
|
|
161
|
+
const classData = sections.get(0x2000);
|
|
162
|
+
for (let i = 0; i < classes.count; i++) {
|
|
163
|
+
const at = classes.offset + i * 32;
|
|
164
|
+
const type = uint(at);
|
|
165
|
+
const descriptor = descriptors[type];
|
|
166
|
+
if (descriptor === undefined || !/^L[^.;[\]\s]+;$/.test(descriptor)) throw new DexFormatError("class");
|
|
167
|
+
const data = uint(at + 24);
|
|
168
|
+
if (data !== 0) {
|
|
169
|
+
if (!classData || data < classData.offset || data >= classData.end || owners.has(data)) throw new DexFormatError("class_data");
|
|
170
|
+
owners.set(data, type);
|
|
171
|
+
}
|
|
172
|
+
const sourceIndex = uint(at + 16);
|
|
173
|
+
if (sourceIndex === 0xffffffff) continue;
|
|
174
|
+
sources.set(type, { packageName: descriptor.slice(1, descriptor.lastIndexOf("/")).replaceAll("/", "."), file: textAt(sourceIndex) });
|
|
175
|
+
}
|
|
176
|
+
const references = Array.from({ length: methods.count }, (_, i) => {
|
|
177
|
+
const at = methods.offset + i * 8;
|
|
178
|
+
const type = view.getUint16(at, true);
|
|
179
|
+
if (type >= types.count) throw new DexFormatError("method");
|
|
180
|
+
const result = returns[view.getUint16(at + 2, true)];
|
|
181
|
+
if (result === undefined) throw new DexFormatError("method_prototype");
|
|
182
|
+
const name = textAt(uint(at + 4));
|
|
183
|
+
return { type, name, result };
|
|
184
|
+
});
|
|
185
|
+
const declared = new Set<string>();
|
|
186
|
+
if (classData) {
|
|
187
|
+
if (classData.count !== owners.size) throw new DexFormatError("class_data");
|
|
188
|
+
let cursor = classData.offset;
|
|
189
|
+
const uleb = () => {
|
|
190
|
+
let value = 0;
|
|
191
|
+
for (let shift = 0; ; shift += 7) {
|
|
192
|
+
const part = bytes[cursor++];
|
|
193
|
+
if (cursor > classData.end || part === undefined || (shift === 28 && part > 15)) throw new DexFormatError("class_data_uleb");
|
|
194
|
+
value += (part & 127) * 2 ** shift;
|
|
195
|
+
if (part < 128) return value;
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
for (let i = 0; i < classData.count; i++) {
|
|
199
|
+
const owner = owners.get(cursor);
|
|
200
|
+
if (owner === undefined) throw new DexFormatError("class_data");
|
|
201
|
+
const counts = [uleb(), uleb(), uleb(), uleb()];
|
|
202
|
+
const usedFields = new Set<number>(), usedMethods = new Set<number>();
|
|
203
|
+
for (const [group, count] of counts.entries()) {
|
|
204
|
+
const isMethod = group >= 2;
|
|
205
|
+
if (count > (isMethod ? methods.count : fields.count)) throw new DexFormatError("class_data_count");
|
|
206
|
+
let index = 0;
|
|
207
|
+
for (let entry = 0; entry < count; entry++) {
|
|
208
|
+
const delta = uleb();
|
|
209
|
+
index += delta;
|
|
210
|
+
const access = uleb();
|
|
211
|
+
const used = isMethod ? usedMethods : usedFields;
|
|
212
|
+
if ((entry > 0 && delta === 0) || used.has(index)) throw new DexFormatError("class_data_index");
|
|
213
|
+
used.add(index);
|
|
214
|
+
if (!isMethod) {
|
|
215
|
+
if (index >= fields.count || view.getUint16(fields.offset + index * 8, true) !== owner) throw new DexFormatError("field");
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const method = references[index];
|
|
219
|
+
if (!method || method.type !== owner) throw new DexFormatError("method_owner");
|
|
220
|
+
const code = uleb();
|
|
221
|
+
const section = sections.get(0x2001);
|
|
222
|
+
const noCode = (access & (0x100 | 0x400)) !== 0;
|
|
223
|
+
if (noCode ? code !== 0 : !section || code % 4 !== 0 || code < section.offset || code > section.end - 16) throw new DexFormatError("method_code");
|
|
224
|
+
const source = sources.get(owner);
|
|
225
|
+
if (source && method.result !== "V") declared.add(`${source.packageName}\n${source.file}\n${method.name}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (classData.end - cursor > 3 || bytes.subarray(cursor, classData.end).some((value) => value !== 0)) throw new DexFormatError("class_data_gap");
|
|
230
|
+
}
|
|
231
|
+
return Array.from({ length: ids.count }, (_, index) => {
|
|
232
|
+
const text = textAt(index);
|
|
233
|
+
// Compose compiler irTraceEventStart: "$kotlinFqName ($file:$line)".
|
|
234
|
+
// Shape alone is insufficient: require the same package, source file and JVM getter declaration.
|
|
235
|
+
const match = /^((?:[A-Za-z_$][A-Za-z0-9_$]*\.)+)<get-([A-Za-z_$][A-Za-z0-9_$]*)> \(([A-Za-z_$][A-Za-z0-9_$.]*\.kt):[0-9]+\)$/.exec(text);
|
|
236
|
+
const prefix = match?.[1], property = match?.[2], file = match?.[3];
|
|
237
|
+
const packageName = prefix?.slice(0, -1);
|
|
238
|
+
const getter = property === undefined ? "" : `get${property.slice(0, 1).toUpperCase()}${property.slice(1)}`;
|
|
239
|
+
const sourcePackage = packageName !== undefined && file !== undefined && declared.has(`${packageName}\n${file}\n${getter}`) ? packageName : null;
|
|
240
|
+
return { text, sourcePackage };
|
|
241
|
+
});
|
|
242
|
+
}
|