Repository navigation
Expand file tree
/
Copy pathutil.js
More file actions
352 lines (323 loc) · 11.3 KB
/
Copy pathutil.js
File metadata and controls
352 lines (323 loc) · 11.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
import tldts from 'tldts';
import crypto from 'crypto';
import fs from 'fs';
import { parse as parseJsonc } from 'jsonc-parser';
import { LISTS_DIR, UNPROTECTED_LIST_NAME } from './constants.js';
/**
* Read and parse a JSONC file (JSON with comments).
* Comments are stripped and regular JSON object is returned.
*
* @param {string} filePath - path to the JSONC file
* @returns {object} parsed JSON object
*/
export function readJsoncFile(filePath) {
const content = fs.readFileSync(filePath, 'utf-8');
const errors = [];
const result = parseJsonc(content, errors);
if (errors.length > 0) {
throw new Error(`Failed to parse JSONC in ${filePath}: ${JSON.stringify(errors)}`);
}
return result;
}
function getAllowlistedRule(rules, rulePath) {
return rules.find(function (x) {
return x.rule === rulePath;
});
}
function addDomainRules(allowlist, domain, rule) {
const found = allowlist[domain];
const existing = found || { rules: [] };
if (!found) {
allowlist[domain] = existing;
}
const newRules = rule.rules || [];
newRules.forEach(function (r) {
addPathRule(existing.rules, r);
});
// very basic substring checking to order more-specific rules earlier (doesn't deal with regexp rules)
existing.rules.sort(function (a, b) {
return a.rule.includes(b.rule) ? -1 : b.rule.includes(a.rule) ? 1 : 0;
});
}
export function addAllowlistRule(allowlist, rule) {
const dom = tldts.getDomain(rule.rule);
addDomainRules(allowlist, dom, {
rules: [
rule,
],
});
}
function addPathRule(rules, rule) {
const found = getAllowlistedRule(rules, rule.rule);
const existing = found || { rule: rule.rule, domains: [], reason: '' };
if (!found) {
rules.push(existing);
}
existing.domains = Array.from(new Set(existing.domains.concat(rule.domains).sort()));
if (existing.domains.includes('<all>')) {
existing.domains = [
'<all>',
];
}
if (existing.reason === undefined) {
return;
}
const reasons = existing.reason.split('; ');
const newReason = rule.reason;
if (!reasons.includes(rule.reason)) {
existing.reason = reasons
.concat([
newReason,
])
.filter(function (x) {
return x !== '';
})
.join('; ');
}
}
export function getBaseFeatureConfigs() {
const features = {};
// Grab all exception lists
const jsonListNames = fs.readdirSync(LISTS_DIR).filter((listName) => {
return listName !== UNPROTECTED_LIST_NAME && listName !== '_template.json';
});
for (const jsonList of jsonListNames) {
const listData = readJsoncFile(`${LISTS_DIR}/${jsonList}`);
const configKey = jsonList.replace(/[.]json$/, '').replace(/-([a-z0-9])/g, function (g) {
return g[1].toUpperCase();
});
delete listData._meta;
features[configKey] = listData;
}
return features;
}
export function mergeAllowlistedTrackers(t1, t2) {
const res = {};
for (const dom in t1) {
addDomainRules(res, dom, t1[dom]);
}
for (const dom in t2) {
addDomainRules(res, dom, t2[dom]);
}
// Sort the resulting generated object by domain keys.
// This makes working with the generated config easier and more human-navigable.
return Object.keys(res)
.sort()
.reduce(function (acc, k) {
acc[k] = res[k];
return acc;
}, {});
}
/**
* Merge a platform's eventHub telemetry overrides onto the base telemetry.
*
* Telemetry entries are merged per key: a platform override only needs to declare the
* entries it changes or adds, and inherits every other entry from the base event-hub
* config. This keeps platforms from drifting out of date as base entries change, and
* ensures they pick up newly-added entries automatically. An entry present in both is
* replaced wholesale by the override (no deep merge within an entry).
*
* @param {object} base - base telemetry record (entry name -> entry)
* @param {object} override - platform override telemetry record
*/
export function mergeEventHubTelemetry(base, override) {
return { ...base, ...override };
}
/**
* Merge a platform's webInterferenceDetection interferenceTypes overrides onto the base.
*
* Interference types are merged per key: a platform override only needs to declare the
* types it changes or adds, and inherits every other type from the base config. This keeps
* platforms from drifting out of date as base types change, and ensures they pick up
* newly-added types automatically. A type present in both is replaced wholesale by the
* override (no deep merge within a type).
*
* @param {object} base - base interferenceTypes record (type name -> type)
* @param {object} override - platform override interferenceTypes record
*/
export function mergeInterferenceTypes(base, override) {
return { ...base, ...override };
}
/**
* Traverse the input (JSON data) and ensure any "reason" fields are strings in the output.
*
* This allows specifying reasons as an array of strings, and converts these to
* strings in the resulting data.
*/
export function inlineReasonArrays(data) {
if (Array.isArray(data)) {
return data.map(inlineReasonArrays);
} else if (typeof data === 'object' && data !== null) {
const res = {};
for (const [
k,
v,
] of Object.entries(data)) {
if (k === 'reason') {
// we collapse list 'reason' field values into a single string
res[k] = Array.isArray(v) ? v.join(' ') : v;
} else {
res[k] = inlineReasonArrays(v);
}
}
return res;
} else {
return data;
}
}
const PERIOD_UNIT_SECONDS = {
seconds: 1,
minutes: 60,
hours: 3600,
days: 86400,
};
/**
* Collapse each eventHub telemetry entry's `trigger.period` into a single integer `{ seconds }`.
*
* Authors may express a period in any unit (`days` / `hours` / `minutes` / `seconds`, per the
* schema). Clients only read `trigger.period.seconds` and ignore the other units, so a period
* authored as e.g. `{ "days": 1 }` would parse as 0 seconds and the pixel would be silently
* dropped. Summing the units to seconds here is what fulfils that delivery contract.
*
* Mutates the config in place; no-ops when the feature, telemetry, or a period is absent.
*
* @param {object} config - a fully-merged platform config
*/
export function collapseEventHubTelemetryPeriods(config) {
const telemetry = config?.features?.eventHub?.settings?.telemetry;
if (!telemetry) {
return;
}
for (const entry of Object.values(telemetry)) {
const period = entry?.trigger?.period;
if (!period || typeof period !== 'object') {
continue;
}
let totalSeconds = 0;
for (const [
unit,
multiplier,
] of Object.entries(PERIOD_UNIT_SECONDS)) {
if (typeof period[unit] === 'number') {
totalSeconds += period[unit] * multiplier;
}
}
entry.trigger.period = { seconds: Math.round(totalSeconds) };
}
}
// The immediate trigger name each platform parses, where it differs from the authored `immediate`.
// Apple renamed the trigger to `immediate_v2` when web events began de-duplicating at the hub, and
// skips entries still carrying `immediate`.
const IMMEDIATE_TRIGGER_TYPE_BY_PLATFORM = {
ios: 'immediate_v2',
macos: 'immediate_v2',
};
/**
* Rewrite each eventHub telemetry entry's `immediate` trigger type to the name the platform parses.
*
* Authors write `immediate` for every platform. A client skips a trigger type it does not parse, so
* an authored `immediate` reaching a platform that only parses `immediate_v2` would silently drop
* the pixel. Other trigger types pass through unchanged.
*
* Mutates the config in place; no-ops when the feature or telemetry is absent, or the platform
* parses `immediate`.
*
* @param {object} config - a fully-merged platform config
* @param {string} platform - the platform the config is built for
*/
export function renameEventHubImmediateTriggers(config, platform) {
const telemetry = config?.features?.eventHub?.settings?.telemetry;
const immediateType = IMMEDIATE_TRIGGER_TYPE_BY_PLATFORM[platform];
if (!telemetry || !immediateType) {
return;
}
for (const entry of Object.values(telemetry)) {
if (entry?.trigger?.type === 'immediate') {
entry.trigger.type = immediateType;
}
}
}
/**
* All domains that may map to the given cnameTarget.
*/
function getCnameSources(tds, cnameTarget) {
return Object.entries(tds.cnames)
.filter(
([
k,
v,
]) => v.endsWith(cnameTarget),
)
.map((kv) => kv[0]);
}
/**
* Generate rules which CNAME to the CNAMEd rule.
*/
function generateCnameRules(tds, cnamedRule) {
const dom = cnamedRule.rule.split('/')[0];
const sources = getCnameSources(tds, dom);
const resultRules = [];
for (const source of sources) {
resultRules.push({
...cnamedRule,
rule: cnamedRule.rule.replace(dom, source),
reason: 'CNAME ENTRY GENERATED FROM: ' + dom,
});
}
return resultRules;
}
/**
* Add CNAME entries to the allowlist to support platforms with incorrect CNAME resolution.
*/
export function addCnameEntriesToAllowlist(tds, allowlist) {
Object.values(allowlist).forEach((ruleSet) =>
ruleSet.rules.forEach((rule) => {
generateCnameRules(tds, rule).forEach((rule) => addAllowlistRule(allowlist, rule));
}),
);
}
/**
* Adds a hash of each feature to each feature object of the provided config
*
* @param {object} config - the config object to update
*/
export function addHashToFeatures(config) {
for (const key of Object.keys(config.features)) {
const featureString = JSON.stringify(config.features[key]);
config.features[key].hash = crypto.createHash('md5').update(featureString).digest('hex');
}
}
/**
* Removes reason fields from the config object
*
* @param {object} config - the config object to update
*/
export function stripReasons(config) {
for (const key of Object.keys(config.features)) {
if (!config.features[key].exceptions) {
continue;
}
for (const exception of config.features[key].exceptions) {
delete exception.reason;
}
if (key === 'trackerAllowlist') {
for (const domain of Object.keys(config.features[key].settings.allowlistedTrackers)) {
for (const rule of config.features[key].settings.allowlistedTrackers[domain].rules) {
delete rule.reason;
}
}
}
if (key === 'customUserAgent') {
if (config.features[key].settings.omitApplicationSites) {
for (const exception of config.features[key].settings.omitApplicationSites) {
delete exception.reason;
}
}
if (config.features[key].settings.omitVersionSites) {
for (const exception of config.features[key].settings.omitVersionSites) {
delete exception.reason;
}
}
}
}
}