Skip to content

Commit ccb1ad4

Browse files
committed
refactor(ios): inline the plist merge helper
plist-merge-patch was 96 lines of compiled JavaScript that only this CLI consumed - the three packages depending on it on npm are two registry-wide scraper bots and a fork of this repository. It was last published in March 2023 and pinned lodash 4.17.21 and plist 3.0.6 exactly, so it nested older, vulnerable copies of two packages the CLI already depends on directly. The port keeps the merge semantics byte for byte, verified against plist-merge-patch@0.2.0 over scalar overwrite, array replacement, nested objects, CFBundleURLTypes with matching, differing and absent roles, LSApplicationQueriesSchemes deduplication, and a three-patch accumulation - including the warning emitted when an entry omits CFBundleTypeRole. Those semantics had no direct coverage before; they do now. The entitlements service reached into the session's private patches array through an `any` cast to decide whether anything was scheduled, so the session exposes hasPatches instead. Original code is Apache-2.0 and NativeScript-owned, matching this repository.
1 parent cc5716b commit ccb1ad4

6 files changed

Lines changed: 304 additions & 35 deletions

File tree

lib/services/ios-entitlements-service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as path from "path";
2-
import { PlistSession } from "plist-merge-patch";
2+
import { PlistSession } from "../tools/plist-merge/plist-session";
33
import { IPluginsService, IPluginData } from "../definitions/plugins";
44
import { IProjectData } from "../definitions/project";
55
import { IFileSystem } from "../common/declarations";
@@ -84,7 +84,7 @@ export class IOSEntitlementsService {
8484
makePatch(appEntitlementsPath);
8585
}
8686

87-
if ((<any>session).patches && (<any>session).patches.length > 0) {
87+
if (session.hasPatches) {
8888
const plistContent = session.build();
8989
this.$logger.trace(
9090
"App.entitlements: Write to: " +

lib/services/ios-project-service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Configurations } from "../common/constants";
66
import * as helpers from "../common/helpers";
77
import { attachAwaitDetach } from "../common/helpers";
88
import * as projectServiceBaseLib from "./platform-project-service-base";
9-
import { PlistSession, Reporter } from "plist-merge-patch";
9+
import { PlistSession, Reporter } from "../tools/plist-merge/plist-session";
1010
import { EOL } from "os";
1111
import * as plist from "plist";
1212
import * as fastGlob from "fast-glob";
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// Inlined from the plist-merge-patch package (NativeScript, Apache-2.0), which
2+
// was unmaintained and pinned older copies of plist and lodash than the CLI
3+
// already depends on.
4+
import * as plist from "plist";
5+
import * as _ from "lodash";
6+
7+
export interface Reporter {
8+
log?(msg: string): void;
9+
warn?(msg: string): void;
10+
}
11+
12+
export interface Patch {
13+
name: string;
14+
read(): string;
15+
}
16+
17+
export interface ICFBundleURLType {
18+
CFBundleTypeRole: string;
19+
CFBundleURLSchemes: string[];
20+
}
21+
22+
const CF_BUNDLE_URL_TYPES = "CFBundleURLTypes";
23+
const LS_APPLICATION_QUERIES_SCHEMES = "LSApplicationQueriesSchemes";
24+
25+
export class PlistMerger {
26+
constructor(private reporter?: Reporter) {}
27+
28+
public merge(base: any, patch: any): any {
29+
const baseClone = _.cloneDeep(base);
30+
_.mergeWith(baseClone, patch, this.customizer.bind(this));
31+
32+
return baseClone;
33+
}
34+
35+
/**
36+
* Entries declaring the same role are folded into one, so an app and its
37+
* plugins can each contribute schemes to a role without displacing each
38+
* other. Roles not already present are appended.
39+
*/
40+
private mergeCFBundleURLTypes(
41+
baseValue: ICFBundleURLType[],
42+
patchValue: ICFBundleURLType[],
43+
): ICFBundleURLType[] {
44+
for (const patchElement of patchValue) {
45+
let shouldAddToBase = true;
46+
47+
for (const baseElement of baseValue) {
48+
if (!patchElement.CFBundleTypeRole || !baseElement.CFBundleTypeRole) {
49+
this.warn(
50+
`Merging ${CF_BUNDLE_URL_TYPES}: Property CFBundleTypeRole is required!`,
51+
);
52+
}
53+
54+
if (patchElement.CFBundleTypeRole === baseElement.CFBundleTypeRole) {
55+
baseElement.CFBundleURLSchemes =
56+
baseElement.CFBundleURLSchemes.concat(
57+
patchElement.CFBundleURLSchemes,
58+
);
59+
shouldAddToBase = false;
60+
}
61+
}
62+
63+
if (shouldAddToBase) {
64+
baseValue.push(patchElement);
65+
}
66+
}
67+
68+
return baseValue;
69+
}
70+
71+
private mergeLSApplicationQueriesSchemes(
72+
baseValue: string[],
73+
patchValue: string[],
74+
): string[] {
75+
for (const patchElement of patchValue) {
76+
if (!baseValue.some((x) => x === patchElement)) {
77+
baseValue.push(patchElement);
78+
}
79+
}
80+
81+
return baseValue;
82+
}
83+
84+
private customizer(baseValue: any, patchValue: any, key: string): any {
85+
if (key === CF_BUNDLE_URL_TYPES && !!baseValue) {
86+
return this.mergeCFBundleURLTypes(baseValue, patchValue);
87+
} else if (key === LS_APPLICATION_QUERIES_SCHEMES && !!baseValue) {
88+
return this.mergeLSApplicationQueriesSchemes(baseValue, patchValue);
89+
}
90+
91+
// every other array is replaced rather than concatenated, which is what
92+
// lodash would otherwise do for two arrays
93+
if (_.isArray(baseValue)) {
94+
return patchValue;
95+
}
96+
}
97+
98+
private warn(msg: string): void {
99+
if (this.reporter && this.reporter.warn) {
100+
this.reporter.warn(msg);
101+
}
102+
}
103+
}
104+
105+
export class PlistSession {
106+
private patches: Patch[] = [];
107+
108+
constructor(private reporter?: Reporter) {}
109+
110+
public get hasPatches(): boolean {
111+
return this.patches.length > 0;
112+
}
113+
114+
public patch(patch: Patch): void {
115+
this.patches.push(patch);
116+
}
117+
118+
public build(): string {
119+
this.log(`Start`);
120+
121+
const plistMerger = new PlistMerger(this.reporter);
122+
let jsonPlist: any = {};
123+
124+
for (const patch of this.patches) {
125+
this.log(`Patch '${patch.name}'`);
126+
const patchJson = plist.parse(patch.read());
127+
jsonPlist = plistMerger.merge(jsonPlist, patchJson);
128+
}
129+
130+
const resultString = plist.build(jsonPlist);
131+
this.log(`Complete`);
132+
133+
return resultString;
134+
}
135+
136+
private log(msg: string): void {
137+
if (this.reporter && this.reporter.log) {
138+
this.reporter.log(msg);
139+
}
140+
}
141+
}

package-lock.json

Lines changed: 1 addition & 31 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,6 @@
7474
"pacote": "21.0.4",
7575
"pbxproj-dom": "1.2.0",
7676
"plist": "3.1.0",
77-
"plist-merge-patch": "0.2.0",
7877
"prettier": "3.9.6",
7978
"prompts": "2.4.2",
8079
"proper-lockfile": "4.1.2",
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { assert } from "chai";
2+
import * as plist from "plist";
3+
import {
4+
PlistSession,
5+
Reporter,
6+
} from "../../../lib/tools/plist-merge/plist-session";
7+
8+
const build = (patches: any[], reporter?: Reporter) => {
9+
const session = new PlistSession(reporter);
10+
11+
patches.forEach((patch, index) =>
12+
session.patch({ name: `patch-${index}`, read: () => plist.build(patch) }),
13+
);
14+
15+
return session.build();
16+
};
17+
18+
const merge = (patches: any[], reporter?: Reporter): any =>
19+
plist.parse(build(patches, reporter));
20+
21+
describe("PlistSession", () => {
22+
it("reports no patches until one is scheduled", () => {
23+
const session = new PlistSession();
24+
assert.isFalse(session.hasPatches);
25+
26+
session.patch({ name: "p", read: () => plist.build({ A: "1" }) });
27+
assert.isTrue(session.hasPatches);
28+
});
29+
30+
it("builds a plist from a single patch", () => {
31+
assert.deepStrictEqual(merge([{ CFBundleName: "app" }]), {
32+
CFBundleName: "app",
33+
});
34+
});
35+
36+
it("lets a later patch overwrite a scalar", () => {
37+
assert.deepStrictEqual(merge([{ A: "1", B: "keep" }, { A: "2" }]), {
38+
A: "2",
39+
B: "keep",
40+
});
41+
});
42+
43+
it("merges nested objects rather than replacing them", () => {
44+
assert.deepStrictEqual(
45+
merge([{ N: { x: "1", y: "2" } }, { N: { y: "9", z: "3" } }]),
46+
{ N: { x: "1", y: "9", z: "3" } },
47+
);
48+
});
49+
50+
it("replaces plain arrays instead of concatenating them", () => {
51+
// lodash would merge these element-wise, which is not what a plist patch means
52+
assert.deepStrictEqual(merge([{ Arr: ["a", "b", "c"] }, { Arr: ["z"] }]), {
53+
Arr: ["z"],
54+
});
55+
});
56+
57+
describe("CFBundleURLTypes", () => {
58+
it("folds schemes into an entry that declares the same role", () => {
59+
const result = merge([
60+
{
61+
CFBundleURLTypes: [
62+
{ CFBundleTypeRole: "Editor", CFBundleURLSchemes: ["a"] },
63+
],
64+
},
65+
{
66+
CFBundleURLTypes: [
67+
{ CFBundleTypeRole: "Editor", CFBundleURLSchemes: ["b"] },
68+
],
69+
},
70+
]);
71+
72+
assert.deepStrictEqual(result.CFBundleURLTypes, [
73+
{ CFBundleTypeRole: "Editor", CFBundleURLSchemes: ["a", "b"] },
74+
]);
75+
});
76+
77+
it("appends an entry declaring a different role", () => {
78+
const result = merge([
79+
{
80+
CFBundleURLTypes: [
81+
{ CFBundleTypeRole: "Editor", CFBundleURLSchemes: ["a"] },
82+
],
83+
},
84+
{
85+
CFBundleURLTypes: [
86+
{ CFBundleTypeRole: "Viewer", CFBundleURLSchemes: ["b"] },
87+
],
88+
},
89+
]);
90+
91+
assert.deepStrictEqual(result.CFBundleURLTypes, [
92+
{ CFBundleTypeRole: "Editor", CFBundleURLSchemes: ["a"] },
93+
{ CFBundleTypeRole: "Viewer", CFBundleURLSchemes: ["b"] },
94+
]);
95+
});
96+
97+
it("accumulates schemes across three patches", () => {
98+
const patchFor = (scheme: string) => ({
99+
CFBundleURLTypes: [
100+
{ CFBundleTypeRole: "Editor", CFBundleURLSchemes: [scheme] },
101+
],
102+
});
103+
104+
const result = merge([patchFor("a"), patchFor("b"), patchFor("c")]);
105+
106+
assert.deepStrictEqual(result.CFBundleURLTypes, [
107+
{ CFBundleTypeRole: "Editor", CFBundleURLSchemes: ["a", "b", "c"] },
108+
]);
109+
});
110+
111+
it("warns when an entry omits the role it would be matched on", () => {
112+
const warnings: string[] = [];
113+
const result = merge(
114+
[
115+
{ CFBundleURLTypes: [{ CFBundleURLSchemes: ["a"] }] },
116+
{
117+
CFBundleURLTypes: [
118+
{ CFBundleTypeRole: "Editor", CFBundleURLSchemes: ["b"] },
119+
],
120+
},
121+
],
122+
{ warn: (msg: string) => warnings.push(msg) },
123+
);
124+
125+
assert.lengthOf(warnings, 1);
126+
assert.include(warnings[0], "CFBundleTypeRole is required");
127+
// the roles do not match, so the patch is appended rather than folded in
128+
assert.lengthOf(result.CFBundleURLTypes, 2);
129+
});
130+
});
131+
132+
describe("LSApplicationQueriesSchemes", () => {
133+
it("unions schemes and drops duplicates", () => {
134+
const result = merge([
135+
{ LSApplicationQueriesSchemes: ["a", "b"] },
136+
{ LSApplicationQueriesSchemes: ["b", "c"] },
137+
]);
138+
139+
assert.deepStrictEqual(result.LSApplicationQueriesSchemes, [
140+
"a",
141+
"b",
142+
"c",
143+
]);
144+
});
145+
});
146+
147+
it("reports progress through the reporter", () => {
148+
const messages: string[] = [];
149+
build([{ A: "1" }], { log: (msg: string) => messages.push(msg) });
150+
151+
assert.include(messages, "Start");
152+
assert.include(messages, "Complete");
153+
assert.include(messages, "Patch 'patch-0'");
154+
});
155+
156+
it("works without a reporter", () => {
157+
assert.deepStrictEqual(merge([{ A: "1" }]), { A: "1" });
158+
});
159+
});

0 commit comments

Comments
 (0)