@invarn/cibuild 2.8.1 → 2.8.2

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.
@@ -215,4 +215,140 @@ describe("the Secrets-plugin warning path is unchanged", () => {
215
215
  expect(result.missingPropertyKeys).toEqual(["MAPS_API_KEY"]);
216
216
  });
217
217
  });
218
+ /**
219
+ * The commonest shape of all, and the one the first three sources missed.
220
+ *
221
+ * The Secrets plugin's own documentation, and every Google Maps setup guide,
222
+ * puts the key in the manifest and nowhere else. There is no `BuildConfig`
223
+ * reference and no `manifestPlaceholders` block — the plugin substitutes the
224
+ * value during manifest merging. A scan reading only build files and source
225
+ * returns nothing for such a repository, which is the majority of them.
226
+ */
227
+ describe("a key referenced only in AndroidManifest.xml", () => {
228
+ test("names a placeholder the plugin fills", async () => {
229
+ projectApplyingTheSecretsPlugin();
230
+ write("app/src/main/AndroidManifest.xml", '<manifest xmlns:android="http://schemas.android.com/apk/res/android">\n' +
231
+ " <application>\n" +
232
+ ' <meta-data android:name="com.google.android.geo.API_KEY"\n' +
233
+ ' android:value="${MAPS_API_KEY}" />\n' +
234
+ " </application>\n</manifest>\n");
235
+ expect(await keys()).toEqual(["MAPS_API_KEY"]);
236
+ });
237
+ // AGP supplies this one from the variant, so it is not a value anybody owes.
238
+ test("ignores ${applicationId}", async () => {
239
+ projectApplyingTheSecretsPlugin();
240
+ write("app/src/main/AndroidManifest.xml", '<manifest>\n <provider android:authorities="${applicationId}.provider" />\n</manifest>\n');
241
+ expect(await keys()).toEqual([]);
242
+ });
243
+ test("finds them in flavor source sets too", async () => {
244
+ projectApplyingTheSecretsPlugin();
245
+ write("app/src/main/AndroidManifest.xml", '<manifest a="${BASE_KEY}" />\n');
246
+ write("app/src/prod/AndroidManifest.xml", '<manifest a="${PROD_KEY}" />\n');
247
+ expect(await keys()).toEqual(["BASE_KEY", "PROD_KEY"]);
248
+ });
249
+ test("skips a merged manifest under build/", async () => {
250
+ projectApplyingTheSecretsPlugin();
251
+ write("app/src/main/AndroidManifest.xml", '<manifest a="${REAL_KEY}" />\n');
252
+ write("app/build/intermediates/merged_manifest/AndroidManifest.xml", '<manifest a="${STALE}" />\n');
253
+ expect(await keys()).toEqual(["REAL_KEY"]);
254
+ });
255
+ });
256
+ /**
257
+ * A build that loads the properties file itself, with no Secrets plugin
258
+ * anywhere. The file existing is not the fix for these — only the file
259
+ * existing *with the key in it* is, because `getProperty` on an absent key
260
+ * returns null whether or not the file is there.
261
+ */
262
+ describe("a build that reads the properties file directly", () => {
263
+ test("names a getProperty key, with no plugin applied", async () => {
264
+ write("settings.gradle.kts", 'include(":app")\n');
265
+ write("build.gradle.kts", "// root\n");
266
+ write("app/build.gradle.kts", 'import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties\n' +
267
+ 'val url: String = gradleLocalProperties(rootDir).getProperty("api.url.release")\n');
268
+ expect(await keys()).toEqual(["api.url.release"]);
269
+ });
270
+ test("keeps dotted keys, which a direct reader may use freely", async () => {
271
+ write("settings.gradle", "include ':app'\n");
272
+ write("build.gradle", "// root\n");
273
+ write("app/build.gradle", "def props = new Properties()\n" +
274
+ "props.load(rootProject.file('local.properties').newDataInputStream())\n" +
275
+ "def a = props.getProperty('api.url.debug')\n" +
276
+ "def b = props.getProperty('tv.isDebug')\n");
277
+ expect(await keys()).toEqual(["api.url.debug", "tv.isDebug"]);
278
+ });
279
+ test("honours a default argument without treating it as a key", async () => {
280
+ write("settings.gradle.kts", 'include(":app")\n');
281
+ write("build.gradle.kts", "// root\n");
282
+ write("app/build.gradle.kts", 'val p = java.util.Properties()\n' +
283
+ 'p.load(rootProject.file("local.properties").reader())\n' +
284
+ 'val u = p.getProperty("api.url.release", "")\n');
285
+ expect(await keys()).toEqual(["api.url.release"]);
286
+ });
287
+ /**
288
+ * The idiom that hides every key behind a name the repository chose. Without
289
+ * following it, a repository like this reads as having no keys at all.
290
+ */
291
+ test("follows a hand-rolled accessor to its call sites", async () => {
292
+ write("settings.gradle", "include ':app'\n");
293
+ write("build.gradle", "// root\n");
294
+ write("app/build.gradle", "def getProps(String propName) {\n" +
295
+ " def propsFile = rootProject.file('local.properties')\n" +
296
+ " if (propsFile.exists()) {\n" +
297
+ " def props = new Properties()\n" +
298
+ " props.load(new FileInputStream(propsFile))\n" +
299
+ " return props[propName]\n" +
300
+ " } else {\n" +
301
+ " return \"\";\n" +
302
+ " }\n" +
303
+ "}\n" +
304
+ "\n" +
305
+ "String signFilePath = getProps(\"sign.file\")\n" +
306
+ "Boolean noObfuscate = getProps(\"app.dontobfuscate\")?.toBoolean() ?: false\n");
307
+ expect(await keys()).toEqual(["app.dontobfuscate", "sign.file"]);
308
+ });
309
+ test("does not follow a function that reads something else", async () => {
310
+ write("settings.gradle", "include ':app'\n");
311
+ write("build.gradle", "// root\n");
312
+ write("app/build.gradle", "def getProps(String n) {\n" +
313
+ " def p = new Properties()\n" +
314
+ " p.load(rootProject.file('local.properties').newDataInputStream())\n" +
315
+ " return p[n]\n" +
316
+ "}\n" +
317
+ "def version(String n) {\n" +
318
+ " def p = new Properties()\n" +
319
+ " p.load(rootProject.file('version.properties').newDataInputStream())\n" +
320
+ " return p[n]\n" +
321
+ "}\n" +
322
+ "def a = getProps('wanted')\n" +
323
+ "def b = version('unwanted')\n");
324
+ expect(await keys()).toEqual(["wanted"]);
325
+ });
326
+ /**
327
+ * `properties["x"]` is the Gradle *project* property channel —
328
+ * `gradle.properties` and `-P` — which the scan already reports separately.
329
+ * Declaring those in a properties file would be the wrong file entirely.
330
+ */
331
+ test("does not confuse Gradle project properties for properties-file keys", async () => {
332
+ write("settings.gradle.kts", 'include(":app")\n');
333
+ write("build.gradle.kts", "// root\n");
334
+ write("app/build.gradle.kts", 'val p = java.util.Properties()\n' +
335
+ 'p.load(rootProject.file("local.properties").reader())\n' +
336
+ 'val a = p.getProperty("wanted")\n' +
337
+ 'val b = properties["projectScoped"]\n' +
338
+ 'val c = findProperty("alsoProjectScoped")\n');
339
+ expect(await keys()).toEqual(["wanted"]);
340
+ });
341
+ /** A developer's own SDK path is not a value anybody owes the build. */
342
+ test("never names a machine-local key", async () => {
343
+ write("settings.gradle.kts", 'include(":app")\n');
344
+ write("build.gradle.kts", "// root\n");
345
+ write("app/build.gradle.kts", 'val p = java.util.Properties()\n' +
346
+ 'p.load(rootProject.file("local.properties").reader())\n' +
347
+ 'val sdk = p.getProperty("sdk.dir")\n' +
348
+ 'val ndk = p.getProperty("ndk.dir")\n' +
349
+ 'val cmake = p.getProperty("cmake.dir")\n' +
350
+ 'val real = p.getProperty("api.key")\n');
351
+ expect(await keys()).toEqual(["api.key"]);
352
+ });
353
+ });
218
354
  //# sourceMappingURL=a-properties-stand-in-declares-the-keys-the-build-reads.test.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"android-scanner.d.ts","sourceRoot":"","sources":["../../../src/commands/android-scanner.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"android-scanner.d.ts","sourceRoot":"","sources":["../../../src/commands/android-scanner.ts"],"names":[],"mappings":"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"}
@@ -316,6 +316,118 @@ function extractManifestPlaceholderKeys(content) {
316
316
  }
317
317
  return names;
318
318
  }
319
+ /**
320
+ * Placeholder names referenced in an `AndroidManifest.xml`, other than the ones
321
+ * the Android Gradle Plugin injects itself.
322
+ *
323
+ * **This is what the Secrets plugin is mostly for.** Its own documentation, and
324
+ * every Google Maps setup guide, puts the key in the manifest and nowhere else:
325
+ *
326
+ * <meta-data android:name="com.google.android.geo.API_KEY"
327
+ * android:value="${MAPS_API_KEY}" />
328
+ *
329
+ * There is no `BuildConfig` reference and no `manifestPlaceholders` block —
330
+ * the plugin reads `MAPS_API_KEY` from the properties file and substitutes it
331
+ * during manifest merging. A scan that reads only build files and source sees
332
+ * nothing at all in such a repository.
333
+ *
334
+ * `applicationId` is excluded because AGP supplies it from the variant. A name
335
+ * a build file *declares* in `manifestPlaceholders` is not excluded: the build
336
+ * supplies that one, so naming it costs the usual unused line, and telling the
337
+ * two apart would mean resolving which module's manifest merges with which
338
+ * module's build file.
339
+ */
340
+ const AGP_INJECTED_PLACEHOLDERS = new Set(["applicationId"]);
341
+ function extractManifestXmlPlaceholders(content) {
342
+ const names = [];
343
+ const re = /\$\{([A-Za-z_][A-Za-z0-9_.]*)\}/g;
344
+ let m;
345
+ while ((m = re.exec(content)) !== null) {
346
+ if (!AGP_INJECTED_PLACEHOLDERS.has(m[1]))
347
+ names.push(m[1]);
348
+ }
349
+ return names;
350
+ }
351
+ /**
352
+ * True when this build file loads the properties file itself, rather than
353
+ * leaving it to the Secrets plugin.
354
+ *
355
+ * Every spelling seen in the wild reduces to naming the file or calling a
356
+ * helper that names it: `rootProject.file("local.properties")`, AGP's own
357
+ * `gradleLocalProperties(rootDir)`, and Compose's `localPropertiesFile`.
358
+ */
359
+ function readsPropertiesFileDirectly(content) {
360
+ return (/\blocal\.properties\b/.test(content) ||
361
+ /\bgradleLocalProperties\s*\(/.test(content) ||
362
+ /\blocalPropertiesFile\b/.test(content));
363
+ }
364
+ /**
365
+ * Keys a build file reads out of a properties file it loaded itself.
366
+ *
367
+ * `getProperty("x")` is the direct form, with or without a default. The rest is
368
+ * for the idiom that hides it: a repository defines its own one-line accessor —
369
+ *
370
+ * def getProps(String name) {
371
+ * def f = rootProject.file('local.properties')
372
+ * if (f.exists()) { def p = new Properties(); p.load(...); return p[name] }
373
+ * return ""
374
+ * }
375
+ *
376
+ * — and every key then arrives as `getProps("sign.file")`, under a name chosen
377
+ * by that repository. So a function whose *body* reads the properties file is
378
+ * treated as an accessor for it, and the string literals at its call sites are
379
+ * keys. Without that, a repository like this reads as having none.
380
+ *
381
+ * `properties["x"]` is deliberately not matched: that is the Gradle *project*
382
+ * property channel — `gradle.properties` and `-P` — which `extractPropertyRefs`
383
+ * already reports, and which is not this file.
384
+ */
385
+ function extractDirectPropertyReads(content) {
386
+ const names = [];
387
+ const direct = /\.\s*getProperty\s*\(\s*["']([^"']+)["']/g;
388
+ let m;
389
+ while ((m = direct.exec(content)) !== null)
390
+ names.push(m[1]);
391
+ for (const accessor of propertiesAccessorNames(content)) {
392
+ const call = new RegExp(`\\b${escapeForRegExp(accessor)}\\s*\\(\\s*["']([^"']+)["']`, "g");
393
+ let c;
394
+ while ((c = call.exec(content)) !== null)
395
+ names.push(c[1]);
396
+ }
397
+ return names;
398
+ }
399
+ function escapeForRegExp(value) {
400
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
401
+ }
402
+ /** Functions declared in this file whose body reads the properties file. */
403
+ function propertiesAccessorNames(content) {
404
+ const names = [];
405
+ // Groovy `def name(...) {` and Kotlin `fun name(...) {`, which is how a
406
+ // build script declares one.
407
+ const decl = /\b(?:def|fun)\s+([A-Za-z_]\w*)\s*\([^)]*\)\s*(?::[^{]*)?\{/g;
408
+ let m;
409
+ while ((m = decl.exec(content)) !== null) {
410
+ // `matchingBrace` counts from inside the block, so hand it the index after
411
+ // the opening brace the declaration regex ended on.
412
+ const body = m.index + m[0].length;
413
+ const close = matchingBrace(content, body);
414
+ if (close === -1)
415
+ continue;
416
+ if (readsPropertiesFileDirectly(content.slice(body, close)))
417
+ names.push(m[1]);
418
+ }
419
+ return names;
420
+ }
421
+ /**
422
+ * Keys that name a location on the machine running the build, not a value the
423
+ * build needs supplied.
424
+ *
425
+ * Writing one into a generated file puts a developer's own SDK path onto a
426
+ * runner, where it is wrong — and Gradle finds the SDK from `ANDROID_HOME`
427
+ * when the key is absent, which is how every build on a runner resolves it.
428
+ * The Secrets plugin ignores `sdk.dir` for the same reason.
429
+ */
430
+ const MACHINE_LOCAL_KEYS = new Set(["sdk.dir", "ndk.dir", "cmake.dir"]);
319
431
  // Bounds on the source walk, the way `findGradleFiles` bounds itself to the
320
432
  // files a build actually declares. A repository is an unbounded tree and this
321
433
  // runs on every scan of one.
@@ -360,12 +472,49 @@ function findSourceFiles(dir, budget, depth = 0) {
360
472
  function outermost(dirs) {
361
473
  return dirs.filter((dir) => !dirs.some((other) => other !== dir && dir.startsWith(other + "/")));
362
474
  }
475
+ /** Manifests under `dir`, up to the shared file budget. */
476
+ function findManifests(dir, budget, depth = 0) {
477
+ if (depth > SOURCE_DEPTH_CAP || budget.left <= 0)
478
+ return [];
479
+ let entries;
480
+ try {
481
+ entries = readdirSync(dir, { withFileTypes: true });
482
+ }
483
+ catch {
484
+ return [];
485
+ }
486
+ const found = [];
487
+ for (const entry of entries) {
488
+ if (entry.name.startsWith("."))
489
+ continue;
490
+ if (entry.isDirectory()) {
491
+ if (!SOURCE_SKIP_DIRS.has(entry.name)) {
492
+ found.push(...findManifests(join(dir, entry.name), budget, depth + 1));
493
+ }
494
+ }
495
+ else if (entry.name === "AndroidManifest.xml") {
496
+ if (budget.left <= 0)
497
+ break;
498
+ budget.left--;
499
+ found.push(join(dir, entry.name));
500
+ }
501
+ }
502
+ return found;
503
+ }
363
504
  /**
364
- * The property names the build reads out of the Secrets plugin's properties
365
- * file — see `ScanResult.missingPropertyKeys`.
505
+ * The property names the build reads out of the properties file — see
506
+ * `ScanResult.missingPropertyKeys`. Four sources, because a repository may use
507
+ * any of them and most use exactly one:
508
+ *
509
+ * - `${KEY}` in an `AndroidManifest.xml`, which the Secrets plugin fills. The
510
+ * commonest of the four by a distance, and the one its documentation shows.
511
+ * - `manifestPlaceholders` declared in a build file.
512
+ * - `BuildConfig.<FIELD>` in source, for a key read as a generated constant.
513
+ * - `getProperty("key")` in a build file that loads the properties file
514
+ * itself, for a build that does not use the plugin at all.
366
515
  *
367
- * Bounded to the modules that apply the plugin, because that is where its
368
- * generated fields are visible and where the references to them live.
516
+ * Bounded to the modules that either apply the plugin or read the file
517
+ * directly. A module that does neither cannot be reading a key out of it.
369
518
  */
370
519
  function collectPropertyKeys(gradleFiles) {
371
520
  // A module with both a `build.gradle` and a `build.gradle.kts` yields its
@@ -374,11 +523,16 @@ function collectPropertyKeys(gradleFiles) {
374
523
  const keys = new Set();
375
524
  for (const filePath of gradleFiles) {
376
525
  const content = safeRead(filePath);
377
- if (!appliesSecretsPlugin(content))
526
+ const plugin = appliesSecretsPlugin(content);
527
+ const direct = readsPropertiesFileDirectly(content);
528
+ if (!plugin && !direct)
378
529
  continue;
379
530
  moduleDirs.add(dirname(filePath));
380
531
  for (const key of extractManifestPlaceholderKeys(content))
381
532
  keys.add(key);
533
+ if (direct)
534
+ for (const key of extractDirectPropertyReads(content))
535
+ keys.add(key);
382
536
  }
383
537
  if (moduleDirs.size === 0)
384
538
  return [];
@@ -388,7 +542,13 @@ function collectPropertyKeys(gradleFiles) {
388
542
  for (const name of extractBuildConfigFieldRefs(safeRead(file)))
389
543
  keys.add(name);
390
544
  }
545
+ for (const manifest of findManifests(dir, budget)) {
546
+ for (const name of extractManifestXmlPlaceholders(safeRead(manifest)))
547
+ keys.add(name);
548
+ }
391
549
  }
550
+ for (const key of MACHINE_LOCAL_KEYS)
551
+ keys.delete(key);
392
552
  return [...keys].sort();
393
553
  }
394
554
  /** Parse property keys from a standard .properties file, skipping comments and blank lines. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invarn/cibuild",
3
- "version": "2.8.1",
3
+ "version": "2.8.2",
4
4
  "description": "CI Build CLI — local pipeline orchestration and validation",
5
5
  "type": "module",
6
6
  "main": "dist/cli.cjs",