bplist-lossless 0.1.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.
@@ -0,0 +1,298 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { test as propTest, fc } from "@fast-check/vitest";
3
+
4
+ import {
5
+ parseBplist as parse,
6
+ serializeBplist as serialize,
7
+ } from "../src/exports/main.js";
8
+ import { PlistDate } from "../src/classes/plist-date.js";
9
+ import { UID } from "../src/classes/uid.js";
10
+ import { Utf16String } from "../src/classes/utf16-string.js";
11
+
12
+ const FC_RUNS = Number(process.env.FC_RUNS ?? 100_000);
13
+ const FC_SEED = process.env.FC_SEED ? Number(process.env.FC_SEED) : 42;
14
+
15
+ function createSafeObject(): Record<string, unknown> {
16
+ return Object.create(null);
17
+ }
18
+
19
+ function asBytes(value: Uint8Array): Buffer {
20
+ return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
21
+ }
22
+
23
+ function stableNumberString(value: number): string {
24
+ if (Object.is(value, -0)) return "-0";
25
+ if (Number.isNaN(value)) return "NaN";
26
+ if (value === Infinity) return "Infinity";
27
+ if (value === -Infinity) return "-Infinity";
28
+ return value.toString();
29
+ }
30
+
31
+ function asciiFrom(xs: readonly number[]): string {
32
+ return String.fromCharCode(...xs);
33
+ }
34
+
35
+ function toUtf16String(text: string): Utf16String {
36
+ const le = Buffer.from(text, "utf16le");
37
+ const be = Buffer.allocUnsafe(le.length);
38
+
39
+ for (let i = 0; i < le.length; i += 2) {
40
+ be[i] = le[i + 1]!;
41
+ be[i + 1] = le[i]!;
42
+ }
43
+
44
+ return Utf16String.from(be);
45
+ }
46
+
47
+ function encodeUid(value: bigint): UID {
48
+ if (value < 0n) {
49
+ throw new RangeError("UID must be unsigned");
50
+ }
51
+
52
+ let hex = value.toString(16);
53
+ if (hex.length % 2 !== 0) hex = `0${hex}`;
54
+ if (hex.length === 0) hex = "00";
55
+
56
+ const raw = Buffer.from(hex, "hex");
57
+ const minimal = raw.length === 0 ? Buffer.from([0]) : raw;
58
+
59
+ if (minimal.length > 16) {
60
+ throw new RangeError("UID too large for binary plist");
61
+ }
62
+
63
+ return new UID(
64
+ minimal.buffer as ArrayBuffer,
65
+ minimal.byteOffset,
66
+ minimal.byteLength,
67
+ );
68
+ }
69
+
70
+ /**
71
+ * Semantic normalizer for comparisons:
72
+ * - Date and PlistDate compare by timestamp
73
+ * - Buffer / Uint8Array compare by bytes
74
+ * - UID compares by bytes
75
+ * - Utf16String compares by text
76
+ * - null-prototype object and normal object compare the same by keys/values
77
+ */
78
+ function normalizePlist(value: unknown): unknown {
79
+ if (value instanceof UID) {
80
+ return { $uidHex: asBytes(value).toString("hex") };
81
+ }
82
+
83
+ if (value instanceof Date) {
84
+ const d = PlistDate.from(value);
85
+ return { $dateMs: stableNumberString(d.getTime()) };
86
+ }
87
+
88
+ if (value instanceof Utf16String) {
89
+ return value.toString();
90
+ }
91
+
92
+ if (Buffer.isBuffer(value)) {
93
+ return { $dataHex: value.toString("hex") };
94
+ }
95
+
96
+ if (value instanceof Uint8Array) {
97
+ return { $dataHex: asBytes(value).toString("hex") };
98
+ }
99
+
100
+ if (typeof value === "bigint") {
101
+ return { $int: value.toString() };
102
+ }
103
+
104
+ if (typeof value === "number") {
105
+ return { $real: stableNumberString(value) };
106
+ }
107
+
108
+ if (
109
+ value === null ||
110
+ typeof value === "string" ||
111
+ typeof value === "boolean"
112
+ ) {
113
+ return value;
114
+ }
115
+
116
+ if (Array.isArray(value)) {
117
+ return value.map(normalizePlist);
118
+ }
119
+
120
+ if (typeof value === "object" && value !== null) {
121
+ const out = createSafeObject();
122
+ const obj = value as Record<string, unknown>;
123
+
124
+ for (const key of Object.keys(obj).sort()) {
125
+ out[key] = normalizePlist(obj[key]);
126
+ }
127
+
128
+ return out;
129
+ }
130
+
131
+ throw new Error(`Unsupported value in test normalizer: ${String(value)}`);
132
+ }
133
+
134
+ function expectPlistSemanticsEqual(actual: unknown, expected: unknown) {
135
+ expect(normalizePlist(actual)).toStrictEqual(normalizePlist(expected));
136
+ }
137
+
138
+ function expectRoundTrip(input: unknown) {
139
+ const output = parse(serialize(input));
140
+ expectPlistSemanticsEqual(output, input);
141
+ }
142
+
143
+ // ---- Apple-valid semantic plist domain ----
144
+
145
+ // plain ASCII strings
146
+ const asciiStringArb = fc
147
+ .array(fc.integer({ min: 0x00, max: 0x7f }), { maxLength: 24 })
148
+ .map(asciiFrom);
149
+
150
+ // definitely non-ASCII strings to exercise UTF-16 paths too
151
+ const nonAsciiStringArb = fc
152
+ .tuple(
153
+ fc.array(fc.integer({ min: 0x00, max: 0x7f }), { maxLength: 8 }),
154
+ fc.integer({ min: 0x80, max: 0xd7ff }),
155
+ fc.array(fc.integer({ min: 0x00, max: 0x7f }), { maxLength: 8 }),
156
+ )
157
+ .map(([left, cp, right]) => {
158
+ return asciiFrom(left) + String.fromCodePoint(cp) + asciiFrom(right);
159
+ });
160
+
161
+ const plistKeyArb = fc.oneof(asciiStringArb, nonAsciiStringArb);
162
+
163
+ const plistStringArb = fc.oneof(
164
+ asciiStringArb,
165
+ nonAsciiStringArb,
166
+ nonAsciiStringArb.map(toUtf16String),
167
+ );
168
+
169
+ function signedIntArb(bytes: 1 | 2 | 4 | 8 | 16): fc.Arbitrary<bigint> {
170
+ const bits = BigInt(bytes * 8);
171
+ return fc.bigInt({
172
+ min: -(1n << (bits - 1n)),
173
+ max: (1n << (bits - 1n)) - 1n,
174
+ });
175
+ }
176
+
177
+ const plistIntegerArb = fc.oneof(
178
+ signedIntArb(1),
179
+ signedIntArb(2),
180
+ signedIntArb(4),
181
+ signedIntArb(8),
182
+ signedIntArb(16),
183
+ );
184
+
185
+ // values that are definitely "real", not integers
186
+ const plistRealArb = fc
187
+ .double({ noNaN: true })
188
+ .filter((n) => !Number.isInteger(n) || Object.is(n, -0));
189
+
190
+ const plistDateArb = fc.oneof(
191
+ fc.date().map((d) => new Date(d.getTime())),
192
+ fc.date().map((d) => PlistDate.fromUnixMilliseconds(d.getTime())),
193
+ );
194
+
195
+ const plistDataArb = fc
196
+ .uint8Array({ maxLength: 32 })
197
+ .map((xs) => Buffer.from(xs));
198
+
199
+ const plistUidArb = fc
200
+ .bigInt({ min: 0n, max: (1n << 128n) - 1n })
201
+ .map(encodeUid);
202
+
203
+ const depthIdentifier = fc.createDepthIdentifier();
204
+
205
+ const plistValueArb: fc.Arbitrary<unknown> = fc.letrec((tie) => ({
206
+ leaf: fc.oneof(
207
+ { withCrossShrink: true },
208
+ fc.boolean(),
209
+ plistIntegerArb,
210
+ plistRealArb,
211
+ plistStringArb,
212
+ plistDateArb,
213
+ plistDataArb,
214
+ plistUidArb,
215
+ ),
216
+
217
+ array: fc.array(tie("value"), { maxLength: 8 }),
218
+
219
+ dict: fc
220
+ .uniqueArray(fc.tuple(plistKeyArb, tie("value")), {
221
+ maxLength: 8,
222
+ selector: ([k]) => k,
223
+ })
224
+ .map((entries) => {
225
+ const out = createSafeObject();
226
+ for (const [k, v] of entries) {
227
+ out[k] = v;
228
+ }
229
+ return out;
230
+ }),
231
+
232
+ value: fc.oneof(
233
+ { depthSize: "small", withCrossShrink: true, depthIdentifier },
234
+ tie("leaf"),
235
+ tie("array"),
236
+ tie("dict"),
237
+ ),
238
+ })).value;
239
+
240
+ // Use @fast-check/vitest here
241
+ propTest.prop([plistValueArb], {
242
+ numRuns: FC_RUNS,
243
+ seed: FC_SEED,
244
+ verbose: 1,
245
+ endOnFailure: true,
246
+ })("parse(serialize(x)) preserves plist semantics for valid Apple bplist values", (input) => {
247
+ const output = parse(serialize(input));
248
+ expectPlistSemanticsEqual(output, input);
249
+ });
250
+
251
+ describe("edge cases", () => {
252
+ test("empty arrays", () => {
253
+ expectRoundTrip([]);
254
+ expectRoundTrip([[]]);
255
+ expectRoundTrip([[[]]]);
256
+ expectRoundTrip([[], []]);
257
+ });
258
+
259
+ test("empty objects", () => {
260
+ expectRoundTrip(createSafeObject());
261
+ expectRoundTrip([createSafeObject()]);
262
+ expectRoundTrip([createSafeObject(), createSafeObject()]);
263
+ });
264
+
265
+ test("negative bigints", () => {
266
+ const outer = createSafeObject();
267
+ const inner = createSafeObject();
268
+ inner[""] = -9007199254740992n;
269
+ outer[""] = inner;
270
+ expectRoundTrip([outer]);
271
+ });
272
+
273
+ test("plain Date input compares equal to parsed PlistDate output", () => {
274
+ expectRoundTrip(new Date("2000-01-01T00:00:00.000Z"));
275
+ });
276
+
277
+ test("dangerous dictionary keys", () => {
278
+ const input = createSafeObject();
279
+ input["__proto__"] = true;
280
+ input["constructor"] = false;
281
+ input["prototype"] = [1n, 2n, 3n];
282
+ expectRoundTrip(input);
283
+ });
284
+
285
+ test("UID extremes", () => {
286
+ expectRoundTrip(encodeUid(0n));
287
+ expectRoundTrip(encodeUid((1n << 128n) - 1n));
288
+ });
289
+ });
290
+
291
+ // Keep this only if your serializer/parser intentionally supports null.
292
+ // null is not a normal Apple property-list semantic value.
293
+ describe("serializer extensions", () => {
294
+ test("null round-trips if you support it", () => {
295
+ const output = parse(serialize(null));
296
+ expect(output).toBeNull();
297
+ });
298
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "compilerOptions": {
3
+ // Environment setup & latest features
4
+ "lib": ["ESNext"],
5
+ "target": "ESNext",
6
+ "module": "node16",
7
+ "moduleDetection": "force",
8
+ "allowJs": true,
9
+ "rootDir": "./src",
10
+
11
+ // Bundler mode
12
+ "moduleResolution": "node16",
13
+ "verbatimModuleSyntax": true,
14
+ "outDir": "dist",
15
+ "declaration": true,
16
+
17
+ // Best practices
18
+ "strict": true,
19
+ "skipLibCheck": true,
20
+ "noFallthroughCasesInSwitch": true,
21
+ "noUncheckedIndexedAccess": true,
22
+ "noImplicitOverride": true,
23
+
24
+ // Some stricter flags (disabled by default)
25
+ "noUnusedLocals": false,
26
+ "noUnusedParameters": false,
27
+ "noPropertyAccessFromIndexSignature": false
28
+ },
29
+ "include": ["src"]
30
+ }
@@ -0,0 +1,16 @@
1
+ // vitest.config.ts
2
+ import { defineConfig } from "vitest/config";
3
+
4
+ export default defineConfig({
5
+ test: {
6
+ environment: "node",
7
+ include: ["test/**/*.test.ts"],
8
+ testTimeout: 600_000,
9
+ hookTimeout: 600_000,
10
+
11
+ // Nice for one heavy fuzz file
12
+ pool: "threads",
13
+ isolate: false,
14
+ fileParallelism: false,
15
+ },
16
+ });