@umutcansu/react-native-pinvault 2.4.2 → 2.5.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.5.0 — 2026-10-12
4
+
5
+ ### Fixed
6
+
7
+ - **Expo: apps with their own Android backup rules build again, with
8
+ PinVault's excludes kept.** An app or plugin (`expo-secure-store`) that sets
9
+ `fullBackupContent` / `dataExtractionRules` failed the manifest merge
10
+ against the library's rules. At the end of prebuild the plugin now copies
11
+ those rules to `res/xml/pinvault_merged_<name>.xml` with PinVault's excludes
12
+ added (only where the app's includes reach) and points the manifest at the
13
+ copy with `tools:replace`; rules it cannot find stop prebuild. Built with
14
+ Expo SDK 54 + `expo-secure-store`, Android release.
15
+
16
+ ### Changed
17
+
18
+ - Depends on PinVault 2.5.0 (Maven Central, Swift package tag `v2.5.0`).
19
+
3
20
  ## 2.4.2 — 2026-10-10
4
21
 
5
22
  ### Added
package/README.md CHANGED
@@ -29,9 +29,9 @@ npm install @umutcansu/react-native-pinvault
29
29
  cd ios && pod install
30
30
  ```
31
31
 
32
- The native libraries come with it, at the package's own version: npm 2.4.2
33
- uses `io.github.umutcansu:pinvault:2.4.2` from Maven Central and the Swift
34
- package at tag `v2.4.2`. The New Architecture must stay on (React Native's
32
+ The native libraries come with it, at the package's own version: npm 2.5.0
33
+ uses `io.github.umutcansu:pinvault:2.5.0` from Maven Central and the Swift
34
+ package at tag `v2.5.0`. The New Architecture must stay on (React Native's
35
35
  default since 0.76).
36
36
 
37
37
  **iOS.** The pod depends on the PinVault Swift package through React Native's
@@ -102,6 +102,18 @@ npx expo install @umutcansu/react-native-pinvault
102
102
  Unknown options and wrong types stop prebuild. Tested with Expo SDK 54
103
103
  (React Native 0.81) on Android and iOS, release builds.
104
104
 
105
+ **Backup rules.** The Android library keeps its stores out of cloud backup
106
+ and device transfer with its own `fullBackupContent` / `dataExtractionRules`.
107
+ An app (or a plugin such as `expo-secure-store`) that sets its own rules used
108
+ to fail the manifest merge; replacing them by hand would drop PinVault's
109
+ excludes. At the end of prebuild the plugin copies each such rules
110
+ file (from the app or a library under `node_modules`) to
111
+ `res/xml/pinvault_merged_<name>.xml` with PinVault's excludes added, and
112
+ points the manifest at the copy with `tools:replace`. Rules it cannot find stop
113
+ prebuild. A bare React Native app with its own rules adds the
114
+ `<exclude>` lines of the library's `pinvault_backup_rules.xml` /
115
+ `pinvault_data_extraction_rules.xml` to them and uses `tools:replace`.
116
+
105
117
  ## Quick start
106
118
 
107
119
  ```ts
@@ -4,7 +4,7 @@ package = JSON.parse(File.read(File.join(__dir__, "package.json")))
4
4
 
5
5
  # PinVault for iOS is a Swift package (Package.swift at the repository root,
6
6
  # product "PinVault"). React Native's spm_dependency adds it to the Pods project:
7
- # - consumers: the git URL at the tag of this package's version (v2.4.2);
7
+ # - consumers: the git URL at the tag of this package's version (v2.5.0);
8
8
  # - development: PINVAULT_IOS_PACKAGE_PATH = a PinVault checkout (the sample's
9
9
  # Podfile sets it to this repository), the counterpart of pinvault.localPath.
10
10
  pinvault_url = "https://github.com/umutcansu/PinVault.git"
@@ -9,7 +9,7 @@ buildscript {
9
9
  kotlinVersion: "2.2.0",
10
10
  minSdkVersion: 24,
11
11
  compileSdkVersion: 36,
12
- pinvaultVersion: "2.4.2",
12
+ pinvaultVersion: "2.5.0",
13
13
  ]
14
14
  ext.pinvaultRnExt = { prop ->
15
15
  rootProject.ext.has(prop) ? rootProject.ext.get(prop) : PinVaultRN[prop]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umutcansu/react-native-pinvault",
3
- "version": "2.4.2",
3
+ "version": "2.5.0",
4
4
  "description": "PinVault for React Native: dynamic certificate pinning with signed remote configs, mTLS enrollment and signed vault files, as a thin bridge over the native PinVault libraries",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",
@@ -29,9 +29,11 @@ const {
29
29
  withAppDelegate,
30
30
  withDangerousMod,
31
31
  withInfoPlist,
32
+ withFinalizedMod,
32
33
  withPodfileProperties,
33
34
  withXcodeProject,
34
35
  createRunOncePlugin,
36
+ XML,
35
37
  } = configPlugins;
36
38
 
37
39
  const pkg = require('../package.json');
@@ -41,6 +43,29 @@ const BG_TASK_ID = 'io.github.umutcansu.pinvault.refresh';
41
43
  const MIN_IOS = '16.0';
42
44
  const ALLOW_NO_FILE_META = 'io.github.umutcansu.pinvault.ALLOW_NO_NATIVE_SECURITY_FILE';
43
45
  const NETWORKING_PROVIDER = 'io.github.umutcansu.pinvault.reactnative.PinVaultNetworkingInitializer';
46
+ // PinVault's own backup rules (pinvault/src/main/res/xml): what must never
47
+ // reach a backup or a device transfer. The test checks this list against them.
48
+ const BACKUP_EXCLUDES = [
49
+ ['sharedpref', 'pinvault_secure_config.xml'],
50
+ ['sharedpref', 'pinvault_secure_client_cert.xml'],
51
+ ['sharedpref', 'pinvault_secure_signing_keys.xml'],
52
+ ['sharedpref', 'pinvault_secure_vault_files.xml'],
53
+ ['sharedpref', 'pinvault_vault_file_versions.xml'],
54
+ ['file', 'vault_files'],
55
+ ['sharedpref', 'pinvault_client_cert.xml'],
56
+ ['sharedpref', 'ssl_cert_config.xml'],
57
+ ['sharedpref', 'ssl_cert_config_default.xml'],
58
+ ['sharedpref', 'ssl_cert_config_default-tls.xml'],
59
+ ['sharedpref', 'ssl_cert_config_secure-mtls.xml'],
60
+ ['sharedpref', 'pinvault_signing_keys.xml'],
61
+ ['sharedpref', 'pinvault_vault_files.xml'],
62
+ ];
63
+ const OWN_RULES = {
64
+ 'android:fullBackupContent': '@xml/pinvault_backup_rules',
65
+ 'android:dataExtractionRules': '@xml/pinvault_data_extraction_rules',
66
+ };
67
+ const MERGED_PREFIX = 'pinvault_merged_';
68
+ const MERGED_FROM = /<!-- pinvault-merged-from: @xml\/([A-Za-z0-9_]+) -->/;
44
69
  const KNOWN_OPTIONS = [
45
70
  'nativeSecurityFile',
46
71
  'faceIDPermission',
@@ -159,6 +184,136 @@ function applyAndroidManifest(manifest, props) {
159
184
  return manifest;
160
185
  }
161
186
 
187
+ /** Whether an `<include>` list (empty = everything) takes in [domain]/[file]. */
188
+ function included(includes, domain, file) {
189
+ if (includes.length === 0) return true;
190
+ return includes.some((i) => {
191
+ const d = i.$ && i.$.domain;
192
+ const p = String((i.$ && i.$.path) || '.').replace(/^\.\/?/, '').replace(/\/$/, '');
193
+ return d === domain && (p === '' || file === p || file.startsWith(`${p}/`));
194
+ });
195
+ }
196
+
197
+ /**
198
+ * Adds PinVault's excludes to one rules element (a parsed `<full-backup-content>`
199
+ * or a section). Only where the element's includes reach: lint refuses an
200
+ * exclude outside every include (FullBackupContent), and such a file is not
201
+ * backed up anyway.
202
+ */
203
+ function addExcludes(node) {
204
+ const out = node && typeof node === 'object' ? node : {};
205
+ const includes = Array.isArray(out.include) ? out.include : [];
206
+ out.exclude = Array.isArray(out.exclude) ? out.exclude : [];
207
+ for (const [domain, file] of BACKUP_EXCLUDES) {
208
+ if (!included(includes, domain, file)) continue;
209
+ if (!out.exclude.some((e) => e.$ && e.$.domain === domain && e.$.path === file)) {
210
+ out.exclude.push({ $: { domain, path: file } });
211
+ }
212
+ }
213
+ return out;
214
+ }
215
+
216
+ /**
217
+ * An app's backup rules (parsed with XML.parseXMLAsync) with PinVault's
218
+ * excludes added. Excludes win over includes, so an app that includes
219
+ * `sharedpref/.` still keeps PinVault's stores out. A data-extraction section
220
+ * the app does not write (= everything) gets one with PinVault's excludes.
221
+ */
222
+ function mergeBackupRules(xml) {
223
+ if (xml && 'full-backup-content' in xml) {
224
+ return { 'full-backup-content': addExcludes(xml['full-backup-content']) };
225
+ }
226
+ if (xml && 'data-extraction-rules' in xml) {
227
+ const root = xml['data-extraction-rules'] && typeof xml['data-extraction-rules'] === 'object' ? xml['data-extraction-rules'] : {};
228
+ for (const section of ['cloud-backup', 'device-transfer']) {
229
+ root[section] = (Array.isArray(root[section]) ? root[section] : [{}]).map(addExcludes);
230
+ }
231
+ if (Array.isArray(root['cross-platform-transfer'])) root['cross-platform-transfer'] = root['cross-platform-transfer'].map(addExcludes);
232
+ return { 'data-extraction-rules': root };
233
+ }
234
+ throw new Error(`[${pkg.name}] backup rules file has neither <full-backup-content> nor <data-extraction-rules>`);
235
+ }
236
+
237
+ /** `res/xml/<name>.xml` of the app, or of a library under node_modules (Expo modules ship theirs there). */
238
+ function findRulesFile(projectRoot, platformRoot, name) {
239
+ const file = path.join('android', 'src', 'main', 'res', 'xml', `${name}.xml`);
240
+ const candidates = [path.join(platformRoot, 'app', 'src', 'main', 'res', 'xml', `${name}.xml`)];
241
+ // node_modules of the project and its parents (monorepos hoist packages up).
242
+ for (let dir = path.resolve(projectRoot); ; dir = path.dirname(dir)) {
243
+ const modules = path.join(dir, 'node_modules');
244
+ if (fs.existsSync(modules)) {
245
+ for (const entry of fs.readdirSync(modules)) {
246
+ if (entry.startsWith('@')) {
247
+ for (const scoped of fs.readdirSync(path.join(modules, entry))) candidates.push(path.join(modules, entry, scoped, file));
248
+ } else {
249
+ candidates.push(path.join(modules, entry, file));
250
+ }
251
+ }
252
+ }
253
+ if (path.dirname(dir) === dir) break;
254
+ }
255
+ return candidates.find((c) => fs.existsSync(c));
256
+ }
257
+
258
+ /**
259
+ * AndroidManifest.xml on disk, after every other plugin: an app (or a plugin
260
+ * such as expo-secure-store) that sets its own `fullBackupContent` /
261
+ * `dataExtractionRules` clashed with PinVault's at the manifest merge, and a
262
+ * hand-written `tools:replace` would drop PinVault's excludes. Each such file
263
+ * is copied to `res/xml/pinvault_merged_<name>.xml` with PinVault's excludes
264
+ * added, and the manifest points at the copy with `tools:replace`. Idempotent:
265
+ * a rerun without --clean merges from the original again.
266
+ */
267
+ async function mergeAndroidBackupRules(projectRoot, platformRoot) {
268
+ const manifestPath = path.join(platformRoot, 'app', 'src', 'main', 'AndroidManifest.xml');
269
+ const manifest = await XML.readXMLAsync({ path: manifestPath });
270
+ const app = AndroidConfig.Manifest.getMainApplicationOrThrow(manifest);
271
+ const resDir = path.join(platformRoot, 'app', 'src', 'main', 'res', 'xml');
272
+ const replace = new Set(String(app.$['tools:replace'] || '').split(',').map((s) => s.trim()).filter(Boolean));
273
+ let changed = false;
274
+ for (const attr of Object.keys(OWN_RULES)) {
275
+ const value = app.$[attr];
276
+ if (value === undefined || value === OWN_RULES[attr]) continue;
277
+ // fullBackupContent="false": no full backup at all, stricter than PinVault's rules.
278
+ if (attr === 'android:fullBackupContent' && value === 'false') {
279
+ if (!replace.has(attr)) { replace.add(attr); changed = true; }
280
+ continue;
281
+ }
282
+ const ref = /^@xml\/([A-Za-z0-9_]+)$/.exec(value);
283
+ if (!ref) throw new Error(`[${pkg.name}] ${attr}="${value}" is not an @xml resource; merge PinVault's backup excludes by hand (README, "Expo")`);
284
+ let name = ref[1];
285
+ if (name.startsWith(MERGED_PREFIX)) {
286
+ const previous = path.join(resDir, `${name}.xml`);
287
+ const from = fs.existsSync(previous) ? MERGED_FROM.exec(fs.readFileSync(previous, 'utf8')) : null;
288
+ if (!from) throw new Error(`[${pkg.name}] ${previous} is missing or not ours; run expo prebuild --clean`);
289
+ name = from[1];
290
+ }
291
+ const source = findRulesFile(projectRoot, platformRoot, name);
292
+ if (!source) {
293
+ throw new Error(`[${pkg.name}] ${attr} points at @xml/${name}, which was not found in the app or node_modules; ` +
294
+ "PinVault's backup excludes cannot be merged into it (README, \"Expo\")");
295
+ }
296
+ const merged = mergeBackupRules(await XML.parseXMLAsync(fs.readFileSync(source, 'utf8')));
297
+ const out = `${MERGED_PREFIX}${name}`;
298
+ fs.mkdirSync(resDir, { recursive: true });
299
+ fs.writeFileSync(
300
+ path.join(resDir, `${out}.xml`),
301
+ '<?xml version="1.0" encoding="utf-8"?>\n' +
302
+ `<!-- pinvault-merged-from: @xml/${name} -->\n` +
303
+ `<!-- Written by ${pkg.name} at expo prebuild: these rules plus PinVault's excludes. -->\n` +
304
+ XML.format(merged) + '\n'
305
+ );
306
+ app.$[attr] = `@xml/${out}`;
307
+ replace.add(attr);
308
+ changed = true;
309
+ }
310
+ if (!changed) return false;
311
+ manifest.manifest.$['xmlns:tools'] = manifest.manifest.$['xmlns:tools'] || 'http://schemas.android.com/tools';
312
+ app.$['tools:replace'] = [...replace].join(',');
313
+ await XML.writeXMLAsync({ path: manifestPath, xml: manifest });
314
+ return true;
315
+ }
316
+
162
317
  function withPinVault(config, options) {
163
318
  const props = checkOptions(options);
164
319
 
@@ -189,6 +344,15 @@ function withPinVault(config, options) {
189
344
  return c;
190
345
  });
191
346
 
347
+ // Last of all: other plugins have written their backup rules by then.
348
+ config = withFinalizedMod(config, [
349
+ 'android',
350
+ async (c) => {
351
+ await mergeAndroidBackupRules(c.modRequest.projectRoot, c.modRequest.platformProjectRoot);
352
+ return c;
353
+ },
354
+ ]);
355
+
192
356
  if (props.backgroundUpdates) {
193
357
  config = withAppDelegate(config, (c) => {
194
358
  c.modResults.contents = addBackgroundTaskToAppDelegate(c.modResults.contents, c.modResults.language);
@@ -229,4 +393,7 @@ function withPinVault(config, options) {
229
393
  }
230
394
 
231
395
  module.exports = createRunOncePlugin(withPinVault, pkg.name, pkg.version);
232
- module.exports.internal = { addBackgroundTaskToAppDelegate, applyInfoPlist, applyAndroidManifest, checkOptions, versionLess };
396
+ module.exports.internal = {
397
+ addBackgroundTaskToAppDelegate, applyInfoPlist, applyAndroidManifest, checkOptions, versionLess,
398
+ mergeBackupRules, mergeAndroidBackupRules, BACKUP_EXCLUDES,
399
+ };