@chipmobilesdk/rn-local-db 0.1.2 → 0.2.1
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 +122 -52
- package/README.md +663 -661
- package/package.json +59 -62
- package/skills/sdk-rn-local-db/SKILL.md +0 -1
- package/src/adapter/budgets.ts +67 -67
- package/src/adapter/collection.ts +303 -298
- package/src/adapter/deletion.ts +252 -238
- package/src/adapter/index.ts +64 -68
- package/src/adapter/lifecycle.ts +517 -444
- package/src/adapter/transaction.ts +71 -65
- package/src/artifacts/availability.ts +67 -69
- package/src/artifacts/cascade.ts +114 -78
- package/src/artifacts/paths.ts +29 -29
- package/src/artifacts/reconcile.ts +63 -63
- package/src/artifacts/store.ts +334 -323
- package/src/contract/adapter.ts +76 -78
- package/src/contract/attachment.ts +75 -72
- package/src/contract/backup.ts +28 -28
- package/src/contract/config.ts +54 -54
- package/src/contract/encryption.ts +34 -34
- package/src/contract/errors.ts +76 -76
- package/src/contract/migration.ts +42 -40
- package/src/contract/query.ts +81 -81
- package/src/contract/record.ts +46 -42
- package/src/contract/schema.ts +36 -36
- package/src/contract/scope.ts +36 -36
- package/src/contract/transaction.ts +52 -46
- package/src/crypto/chunked-cipher.ts +153 -144
- package/src/crypto/derive.ts +64 -63
- package/src/crypto/key-provider.ts +78 -78
- package/src/crypto/provider.ts +81 -81
- package/src/diagnostics/dev-log.ts +112 -108
- package/src/diagnostics/errors.ts +62 -66
- package/src/diagnostics/faults.ts +58 -64
- package/src/diagnostics/map-native.ts +108 -106
- package/src/diagnostics/redact.ts +137 -141
- package/src/index.ts +80 -110
- package/src/migration/executor.ts +132 -136
- package/src/migration/ledger.ts +95 -97
- package/src/migration/validate-chain.ts +65 -65
- package/src/platform/backup-android-verify.ts +88 -88
- package/src/platform/backup-ios.ts +48 -48
- package/src/platform/bytes.ts +144 -0
- package/src/platform/fs.ts +89 -89
- package/src/platform/location.ts +135 -135
- package/src/query/budgets.ts +54 -57
- package/src/query/compare.ts +91 -91
- package/src/query/cursor.ts +100 -99
- package/src/query/filter.ts +82 -82
- package/src/query/paginate.ts +51 -51
- package/src/query/sort.ts +55 -56
- package/src/query/validate.ts +171 -171
- package/src/schema/declare.ts +42 -45
- package/src/schema/validate-config.ts +158 -158
- package/src/scope/hash.ts +131 -134
- package/src/scope/normalize.ts +83 -90
- package/src/scope/validate.ts +83 -83
- package/src/sqlite/adapter.ts +26 -26
- package/src/sqlite/batch.ts +242 -232
- package/src/sqlite/catalog.ts +105 -108
- package/src/sqlite/connection.ts +227 -205
- package/src/sqlite/index-coverage.ts +54 -53
- package/src/sqlite/native.ts +83 -83
- package/src/sqlite/row-codec.ts +217 -201
- package/src/sqlite/sql-builder.ts +244 -244
- package/src/sqlite/transaction-queue.ts +87 -0
- package/src/sqlite/transactions.ts +33 -33
- package/src/sqlite/values.ts +24 -24
- package/src/testing/conformance/attachments.ts +138 -136
- package/src/testing/conformance/encryption.ts +150 -150
- package/src/testing/conformance/index.ts +161 -161
- package/src/testing/conformance/lifecycle.ts +99 -99
- package/src/testing/conformance/migrations.ts +177 -177
- package/src/testing/conformance/queries.ts +204 -167
- package/src/testing/conformance/records.ts +169 -169
- package/src/testing/conformance/scopes.ts +86 -88
- package/src/testing/conformance/transactions.ts +149 -149
- package/src/testing/faults.ts +21 -21
- package/src/testing/index.ts +17 -21
- package/src/testing/memory-adapter/collection.ts +214 -219
- package/src/testing/memory-adapter/index.ts +510 -388
- package/src/testing/memory-adapter/migrations.ts +117 -119
- package/src/testing/memory-adapter/reset.ts +50 -50
- package/src/testing/memory-adapter/store.ts +128 -134
|
@@ -1,65 +1,65 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Migration chain validation, run before any migration executes (FR-053).
|
|
3
|
-
*
|
|
4
|
-
* Validating first is what stops a half-run chain from being the thing that
|
|
5
|
-
* discovers the problem. A duplicate version, a gap, or a chain that does not
|
|
6
|
-
* reach the declared `schemaVersion` are all configuration errors the
|
|
7
|
-
* developer can fix; discovering them after migration 3 of 5 has already
|
|
8
|
-
* rewritten a user's data is not.
|
|
9
|
-
*
|
|
10
|
-
* A gap is refused rather than skipped over deliberately. A user upgrading
|
|
11
|
-
* from the version inside the gap has no path across it, and opening anyway
|
|
12
|
-
* would hand them a database the code believes is current and is not.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import type { DatabaseConfig } from '../contract/config';
|
|
16
|
-
import { DiagnosticCodes } from '../contract/errors';
|
|
17
|
-
import type { Migration } from '../contract/migration';
|
|
18
|
-
import { raise } from '../diagnostics/errors';
|
|
19
|
-
|
|
20
|
-
function invalid(message: string, detail?: Record<string, unknown>): never {
|
|
21
|
-
raise(DiagnosticCodes.MIGRATION_CONFIG_INVALID, { message, detail });
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function validateMigrationChain(config: DatabaseConfig): void {
|
|
25
|
-
const migrations = config.migrations ?? [];
|
|
26
|
-
|
|
27
|
-
if (!Number.isInteger(config.schemaVersion) || config.schemaVersion < 1) {
|
|
28
|
-
invalid('schemaVersion must be a positive integer');
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const versions = new Set<number>();
|
|
32
|
-
for (const migration of migrations) {
|
|
33
|
-
if (!Number.isInteger(migration.version) || migration.version < 1) {
|
|
34
|
-
invalid('migration version must be a positive integer');
|
|
35
|
-
}
|
|
36
|
-
if (versions.has(migration.version)) {
|
|
37
|
-
invalid('duplicate migration version', { version: migration.version });
|
|
38
|
-
}
|
|
39
|
-
versions.add(migration.version);
|
|
40
|
-
if (typeof migration.migrate !== 'function') {
|
|
41
|
-
invalid('migration has no migrate function', { version: migration.version });
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
for (let version = 1; version <= config.schemaVersion; version += 1) {
|
|
46
|
-
if (!versions.has(version)) {
|
|
47
|
-
invalid('migration chain has a gap', { version });
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
for (const version of versions) {
|
|
52
|
-
if (version > config.schemaVersion) {
|
|
53
|
-
invalid('migration version exceeds the declared schemaVersion', { version });
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Ascending, because the order a consumer declared them in is not a contract. */
|
|
59
|
-
export function orderedMigrations(config: DatabaseConfig): Migration[] {
|
|
60
|
-
return [...(config.migrations ?? [])].sort((a, b) => a.version - b.version);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function pendingMigrations(config: DatabaseConfig, recordedVersion: number): Migration[] {
|
|
64
|
-
return orderedMigrations(config).filter(migration => migration.version > recordedVersion);
|
|
65
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Migration chain validation, run before any migration executes (FR-053).
|
|
3
|
+
*
|
|
4
|
+
* Validating first is what stops a half-run chain from being the thing that
|
|
5
|
+
* discovers the problem. A duplicate version, a gap, or a chain that does not
|
|
6
|
+
* reach the declared `schemaVersion` are all configuration errors the
|
|
7
|
+
* developer can fix; discovering them after migration 3 of 5 has already
|
|
8
|
+
* rewritten a user's data is not.
|
|
9
|
+
*
|
|
10
|
+
* A gap is refused rather than skipped over deliberately. A user upgrading
|
|
11
|
+
* from the version inside the gap has no path across it, and opening anyway
|
|
12
|
+
* would hand them a database the code believes is current and is not.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { DatabaseConfig } from '../contract/config';
|
|
16
|
+
import { DiagnosticCodes } from '../contract/errors';
|
|
17
|
+
import type { Migration } from '../contract/migration';
|
|
18
|
+
import { raise } from '../diagnostics/errors';
|
|
19
|
+
|
|
20
|
+
function invalid(message: string, detail?: Record<string, unknown>): never {
|
|
21
|
+
raise(DiagnosticCodes.MIGRATION_CONFIG_INVALID, { message, detail });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function validateMigrationChain(config: DatabaseConfig): void {
|
|
25
|
+
const migrations = config.migrations ?? [];
|
|
26
|
+
|
|
27
|
+
if (!Number.isInteger(config.schemaVersion) || config.schemaVersion < 1) {
|
|
28
|
+
invalid('schemaVersion must be a positive integer');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const versions = new Set<number>();
|
|
32
|
+
for (const migration of migrations) {
|
|
33
|
+
if (!Number.isInteger(migration.version) || migration.version < 1) {
|
|
34
|
+
invalid('migration version must be a positive integer');
|
|
35
|
+
}
|
|
36
|
+
if (versions.has(migration.version)) {
|
|
37
|
+
invalid('duplicate migration version', { version: migration.version });
|
|
38
|
+
}
|
|
39
|
+
versions.add(migration.version);
|
|
40
|
+
if (typeof migration.migrate !== 'function') {
|
|
41
|
+
invalid('migration has no migrate function', { version: migration.version });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (let version = 1; version <= config.schemaVersion; version += 1) {
|
|
46
|
+
if (!versions.has(version)) {
|
|
47
|
+
invalid('migration chain has a gap', { version });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const version of versions) {
|
|
52
|
+
if (version > config.schemaVersion) {
|
|
53
|
+
invalid('migration version exceeds the declared schemaVersion', { version });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Ascending, because the order a consumer declared them in is not a contract. */
|
|
59
|
+
export function orderedMigrations(config: DatabaseConfig): Migration[] {
|
|
60
|
+
return [...(config.migrations ?? [])].sort((a, b) => a.version - b.version);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function pendingMigrations(config: DatabaseConfig, recordedVersion: number): Migration[] {
|
|
64
|
+
return orderedMigrations(config).filter(migration => migration.version > recordedVersion);
|
|
65
|
+
}
|
|
@@ -1,88 +1,88 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Android backup posture: verified and reported, never enforced.
|
|
3
|
-
*
|
|
4
|
-
* Android decides backup participation from `android:dataExtractionRules` in
|
|
5
|
-
* the app's manifest. That is build-time and app-owned, and this package
|
|
6
|
-
* authors no native code, so it cannot contribute an entry and cannot change
|
|
7
|
-
* the answer at runtime (research.md R5).
|
|
8
|
-
*
|
|
9
|
-
* What it can do is check the one thing that silently breaks the documented
|
|
10
|
-
* fix: the rule the README tells an app to ship addresses the `file` domain,
|
|
11
|
-
* which is the app's `files/` directory. If the managed root did not land
|
|
12
|
-
* under that directory, the app can ship the XML exactly as documented and
|
|
13
|
-
* still have an unprotected database. That mismatch is detectable here, and
|
|
14
|
-
* an app learning it from this report is the whole point of FR-111 — the
|
|
15
|
-
* alternative is learning it from an incident.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { Platform } from 'react-native';
|
|
19
|
-
|
|
20
|
-
import type { EffectivePosture } from '../contract/backup';
|
|
21
|
-
import { MANAGED_ROOT_NAME, normalizePath, type StorageLayout } from './location';
|
|
22
|
-
|
|
23
|
-
export function isAndroid(): boolean {
|
|
24
|
-
return Platform.OS === 'android';
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** The `path` an app's exclusion rule must name for the root it actually got. */
|
|
28
|
-
export function coveragePathFor(layout: StorageLayout): string | null {
|
|
29
|
-
const root = normalizePath(layout.root);
|
|
30
|
-
const marker = `/files/`;
|
|
31
|
-
const index = root.indexOf(marker);
|
|
32
|
-
if (index === -1) {
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
|
-
return root.slice(index + marker.length);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function androidPosture(
|
|
39
|
-
layout: StorageLayout,
|
|
40
|
-
includeInPlatformBackup: boolean,
|
|
41
|
-
): EffectivePosture {
|
|
42
|
-
const requested = includeInPlatformBackup ? 'included' : 'excluded';
|
|
43
|
-
const coverage = coveragePathFor(layout);
|
|
44
|
-
|
|
45
|
-
if (coverage === null) {
|
|
46
|
-
return {
|
|
47
|
-
requested,
|
|
48
|
-
// Not "excluded" and not "included": the package genuinely does not
|
|
49
|
-
// know, and saying either would be a claim it cannot support.
|
|
50
|
-
observed: 'unknown',
|
|
51
|
-
enforcedBy: 'app-manifest',
|
|
52
|
-
warning:
|
|
53
|
-
'The managed storage area is not under the Android file domain, so the ' +
|
|
54
|
-
'documented data_extraction_rules.xml entry will not cover it. Backup ' +
|
|
55
|
-
'posture cannot be honoured on this platform until the app supplies a ' +
|
|
56
|
-
'rule matching the actual storage location.',
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
return {
|
|
61
|
-
requested,
|
|
62
|
-
observed: 'unknown',
|
|
63
|
-
enforcedBy: 'app-manifest',
|
|
64
|
-
warning:
|
|
65
|
-
`Android backup participation is declared by the app, not this package. ` +
|
|
66
|
-
`Confirm android/app/src/main/res/xml/data_extraction_rules.xml contains an ` +
|
|
67
|
-
`entry for domain="file" path="${MANAGED_ROOT_NAME}/" and that ` +
|
|
68
|
-
`AndroidManifest.xml references it; the observed posture cannot be verified ` +
|
|
69
|
-
`from JavaScript.`,
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* The posture the handle reports (FR-111).
|
|
75
|
-
*
|
|
76
|
-
* Split by platform rather than smoothed over, because a consumer writing a
|
|
77
|
-
* privacy disclosure needs to know which of the two answers it is holding.
|
|
78
|
-
*/
|
|
79
|
-
export function effectivePostureFor(
|
|
80
|
-
layout: StorageLayout,
|
|
81
|
-
includeInPlatformBackup: boolean,
|
|
82
|
-
iosPostureFor: (include: boolean) => EffectivePosture,
|
|
83
|
-
): EffectivePosture {
|
|
84
|
-
if (isAndroid()) {
|
|
85
|
-
return androidPosture(layout, includeInPlatformBackup);
|
|
86
|
-
}
|
|
87
|
-
return iosPostureFor(includeInPlatformBackup);
|
|
88
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Android backup posture: verified and reported, never enforced.
|
|
3
|
+
*
|
|
4
|
+
* Android decides backup participation from `android:dataExtractionRules` in
|
|
5
|
+
* the app's manifest. That is build-time and app-owned, and this package
|
|
6
|
+
* authors no native code, so it cannot contribute an entry and cannot change
|
|
7
|
+
* the answer at runtime (research.md R5).
|
|
8
|
+
*
|
|
9
|
+
* What it can do is check the one thing that silently breaks the documented
|
|
10
|
+
* fix: the rule the README tells an app to ship addresses the `file` domain,
|
|
11
|
+
* which is the app's `files/` directory. If the managed root did not land
|
|
12
|
+
* under that directory, the app can ship the XML exactly as documented and
|
|
13
|
+
* still have an unprotected database. That mismatch is detectable here, and
|
|
14
|
+
* an app learning it from this report is the whole point of FR-111 — the
|
|
15
|
+
* alternative is learning it from an incident.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { Platform } from 'react-native';
|
|
19
|
+
|
|
20
|
+
import type { EffectivePosture } from '../contract/backup';
|
|
21
|
+
import { MANAGED_ROOT_NAME, normalizePath, type StorageLayout } from './location';
|
|
22
|
+
|
|
23
|
+
export function isAndroid(): boolean {
|
|
24
|
+
return Platform.OS === 'android';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The `path` an app's exclusion rule must name for the root it actually got. */
|
|
28
|
+
export function coveragePathFor(layout: StorageLayout): string | null {
|
|
29
|
+
const root = normalizePath(layout.root);
|
|
30
|
+
const marker = `/files/`;
|
|
31
|
+
const index = root.indexOf(marker);
|
|
32
|
+
if (index === -1) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return root.slice(index + marker.length);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function androidPosture(
|
|
39
|
+
layout: StorageLayout,
|
|
40
|
+
includeInPlatformBackup: boolean,
|
|
41
|
+
): EffectivePosture {
|
|
42
|
+
const requested = includeInPlatformBackup ? 'included' : 'excluded';
|
|
43
|
+
const coverage = coveragePathFor(layout);
|
|
44
|
+
|
|
45
|
+
if (coverage === null) {
|
|
46
|
+
return {
|
|
47
|
+
requested,
|
|
48
|
+
// Not "excluded" and not "included": the package genuinely does not
|
|
49
|
+
// know, and saying either would be a claim it cannot support.
|
|
50
|
+
observed: 'unknown',
|
|
51
|
+
enforcedBy: 'app-manifest',
|
|
52
|
+
warning:
|
|
53
|
+
'The managed storage area is not under the Android file domain, so the ' +
|
|
54
|
+
'documented data_extraction_rules.xml entry will not cover it. Backup ' +
|
|
55
|
+
'posture cannot be honoured on this platform until the app supplies a ' +
|
|
56
|
+
'rule matching the actual storage location.',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
requested,
|
|
62
|
+
observed: 'unknown',
|
|
63
|
+
enforcedBy: 'app-manifest',
|
|
64
|
+
warning:
|
|
65
|
+
`Android backup participation is declared by the app, not this package. ` +
|
|
66
|
+
`Confirm android/app/src/main/res/xml/data_extraction_rules.xml contains an ` +
|
|
67
|
+
`entry for domain="file" path="${MANAGED_ROOT_NAME}/" and that ` +
|
|
68
|
+
`AndroidManifest.xml references it; the observed posture cannot be verified ` +
|
|
69
|
+
`from JavaScript.`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The posture the handle reports (FR-111).
|
|
75
|
+
*
|
|
76
|
+
* Split by platform rather than smoothed over, because a consumer writing a
|
|
77
|
+
* privacy disclosure needs to know which of the two answers it is holding.
|
|
78
|
+
*/
|
|
79
|
+
export function effectivePostureFor(
|
|
80
|
+
layout: StorageLayout,
|
|
81
|
+
includeInPlatformBackup: boolean,
|
|
82
|
+
iosPostureFor: (include: boolean) => EffectivePosture,
|
|
83
|
+
): EffectivePosture {
|
|
84
|
+
if (isAndroid()) {
|
|
85
|
+
return androidPosture(layout, includeInPlatformBackup);
|
|
86
|
+
}
|
|
87
|
+
return iosPostureFor(includeInPlatformBackup);
|
|
88
|
+
}
|
|
@@ -1,48 +1,48 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* iOS backup exclusion.
|
|
3
|
-
*
|
|
4
|
-
* `NSURLIsExcludedFromBackupKey` is set on the managed root at creation, and
|
|
5
|
-
* the attribute is inherited by everything beneath it — so one call covers
|
|
6
|
-
* the catalog, every scope database, and every artifact, including ones
|
|
7
|
-
* created years later. Setting it per file would leave a window in which a
|
|
8
|
-
* freshly written artifact is backed up before the attribute lands.
|
|
9
|
-
*
|
|
10
|
-
* This is the half of FR-109 the package can actually enforce. Android is
|
|
11
|
-
* manifest-owned; see backup-android-verify.ts and research.md R5.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { Platform } from 'react-native';
|
|
15
|
-
|
|
16
|
-
import type { EffectivePosture } from '../contract/backup';
|
|
17
|
-
import { fileSystem } from './fs';
|
|
18
|
-
import type { StorageLayout } from './location';
|
|
19
|
-
|
|
20
|
-
export function isIos(): boolean {
|
|
21
|
-
return Platform.OS === 'ios';
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Create the managed root with the exclusion attribute already on it.
|
|
26
|
-
*
|
|
27
|
-
* Idempotent: `mkdir` on an existing directory re-applies the attribute,
|
|
28
|
-
* which matters because an app that flips the posture between launches must
|
|
29
|
-
* get the new one without a reinstall.
|
|
30
|
-
*/
|
|
31
|
-
export async function ensureManagedRoot(
|
|
32
|
-
layout: StorageLayout,
|
|
33
|
-
includeInPlatformBackup: boolean,
|
|
34
|
-
): Promise<void> {
|
|
35
|
-
await fileSystem().mkdir(layout.root, {
|
|
36
|
-
NSURLIsExcludedFromBackupKey: isIos() ? !includeInPlatformBackup : undefined,
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** What the package can state about iOS, where it enforces rather than reports. */
|
|
41
|
-
export function iosPosture(includeInPlatformBackup: boolean): EffectivePosture {
|
|
42
|
-
const requested = includeInPlatformBackup ? 'included' : 'excluded';
|
|
43
|
-
return {
|
|
44
|
-
requested,
|
|
45
|
-
observed: requested,
|
|
46
|
-
enforcedBy: 'package',
|
|
47
|
-
};
|
|
48
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* iOS backup exclusion.
|
|
3
|
+
*
|
|
4
|
+
* `NSURLIsExcludedFromBackupKey` is set on the managed root at creation, and
|
|
5
|
+
* the attribute is inherited by everything beneath it — so one call covers
|
|
6
|
+
* the catalog, every scope database, and every artifact, including ones
|
|
7
|
+
* created years later. Setting it per file would leave a window in which a
|
|
8
|
+
* freshly written artifact is backed up before the attribute lands.
|
|
9
|
+
*
|
|
10
|
+
* This is the half of FR-109 the package can actually enforce. Android is
|
|
11
|
+
* manifest-owned; see backup-android-verify.ts and research.md R5.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Platform } from 'react-native';
|
|
15
|
+
|
|
16
|
+
import type { EffectivePosture } from '../contract/backup';
|
|
17
|
+
import { fileSystem } from './fs';
|
|
18
|
+
import type { StorageLayout } from './location';
|
|
19
|
+
|
|
20
|
+
export function isIos(): boolean {
|
|
21
|
+
return Platform.OS === 'ios';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Create the managed root with the exclusion attribute already on it.
|
|
26
|
+
*
|
|
27
|
+
* Idempotent: `mkdir` on an existing directory re-applies the attribute,
|
|
28
|
+
* which matters because an app that flips the posture between launches must
|
|
29
|
+
* get the new one without a reinstall.
|
|
30
|
+
*/
|
|
31
|
+
export async function ensureManagedRoot(
|
|
32
|
+
layout: StorageLayout,
|
|
33
|
+
includeInPlatformBackup: boolean,
|
|
34
|
+
): Promise<void> {
|
|
35
|
+
await fileSystem().mkdir(layout.root, {
|
|
36
|
+
NSURLIsExcludedFromBackupKey: isIos() ? !includeInPlatformBackup : undefined,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** What the package can state about iOS, where it enforces rather than reports. */
|
|
41
|
+
export function iosPosture(includeInPlatformBackup: boolean): EffectivePosture {
|
|
42
|
+
const requested = includeInPlatformBackup ? 'included' : 'excluded';
|
|
43
|
+
return {
|
|
44
|
+
requested,
|
|
45
|
+
observed: requested,
|
|
46
|
+
enforcedBy: 'package',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Byte handling without Node's `Buffer`.
|
|
3
|
+
*
|
|
4
|
+
* Hermes has no global `Buffer`, and `react-native-quick-crypto` installs one
|
|
5
|
+
* only when the app calls its `install()` — which nothing in this package or its
|
|
6
|
+
* documentation asked for. Until 0.2.0 attachment writes and reads used the
|
|
7
|
+
* global anyway, so on a device every attachment operation threw. Everything
|
|
8
|
+
* here is plain `Uint8Array` and a self-contained base64 codec, identical on
|
|
9
|
+
* Hermes and in Node.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
13
|
+
const LOOKUP = (() => {
|
|
14
|
+
const table = new Int16Array(128).fill(-1);
|
|
15
|
+
for (let index = 0; index < ALPHABET.length; index += 1) {
|
|
16
|
+
table[ALPHABET.charCodeAt(index)] = index;
|
|
17
|
+
}
|
|
18
|
+
// The URL-safe variants decode too, since some file APIs return them.
|
|
19
|
+
table['-'.charCodeAt(0)] = 62;
|
|
20
|
+
table['_'.charCodeAt(0)] = 63;
|
|
21
|
+
return table;
|
|
22
|
+
})();
|
|
23
|
+
|
|
24
|
+
/* eslint-disable no-bitwise -- base64 is bit packing; there is no clearer way to write it. */
|
|
25
|
+
|
|
26
|
+
/** Characters per `String.fromCharCode` call; large spreads overflow the stack. */
|
|
27
|
+
const CHUNK = 0x8000;
|
|
28
|
+
|
|
29
|
+
export function base64ToBytes(base64: string): Uint8Array {
|
|
30
|
+
if (typeof globalThis.atob === 'function') {
|
|
31
|
+
let binary: string | undefined;
|
|
32
|
+
try {
|
|
33
|
+
binary = globalThis.atob(base64.replace(/-/g, '+').replace(/_/g, '/'));
|
|
34
|
+
} catch {
|
|
35
|
+
// atob is strict where Buffer was lenient; fall through to the
|
|
36
|
+
// tolerant decoder rather than turn stray characters into a crash.
|
|
37
|
+
}
|
|
38
|
+
if (binary !== undefined) {
|
|
39
|
+
const bytes = new Uint8Array(binary.length);
|
|
40
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
41
|
+
bytes[index] = binary.charCodeAt(index);
|
|
42
|
+
}
|
|
43
|
+
return bytes;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Decode directly, skipping padding and anything outside the alphabet.
|
|
47
|
+
const bytes = new Uint8Array(Math.ceil((base64.length * 3) / 4));
|
|
48
|
+
let out = 0;
|
|
49
|
+
let buffer = 0;
|
|
50
|
+
let bits = 0;
|
|
51
|
+
for (let index = 0; index < base64.length; index += 1) {
|
|
52
|
+
const code = base64.charCodeAt(index);
|
|
53
|
+
const value = code < 128 ? LOOKUP[code]! : -1;
|
|
54
|
+
if (value < 0) continue;
|
|
55
|
+
buffer = (buffer << 6) | value;
|
|
56
|
+
bits += 6;
|
|
57
|
+
if (bits >= 8) {
|
|
58
|
+
bits -= 8;
|
|
59
|
+
bytes[out++] = (buffer >> bits) & 0xff;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return bytes.subarray(0, out);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function bytesToBase64(bytes: Uint8Array): string {
|
|
66
|
+
if (typeof globalThis.btoa === 'function') {
|
|
67
|
+
const parts: string[] = [];
|
|
68
|
+
for (let index = 0; index < bytes.length; index += CHUNK) {
|
|
69
|
+
parts.push(String.fromCharCode(...bytes.subarray(index, index + CHUNK)));
|
|
70
|
+
}
|
|
71
|
+
return globalThis.btoa(parts.join(''));
|
|
72
|
+
}
|
|
73
|
+
const parts: string[] = [];
|
|
74
|
+
for (let start = 0; start < bytes.length; start += CHUNK * 3) {
|
|
75
|
+
let piece = '';
|
|
76
|
+
const end = Math.min(bytes.length, start + CHUNK * 3);
|
|
77
|
+
let index = start;
|
|
78
|
+
for (; index + 2 < end; index += 3) {
|
|
79
|
+
const triple = (bytes[index]! << 16) | (bytes[index + 1]! << 8) | bytes[index + 2]!;
|
|
80
|
+
piece +=
|
|
81
|
+
ALPHABET[triple >> 18]! +
|
|
82
|
+
ALPHABET[(triple >> 12) & 63]! +
|
|
83
|
+
ALPHABET[(triple >> 6) & 63]! +
|
|
84
|
+
ALPHABET[triple & 63]!;
|
|
85
|
+
}
|
|
86
|
+
const remaining = end - index;
|
|
87
|
+
if (remaining === 1) {
|
|
88
|
+
const value = bytes[index]!;
|
|
89
|
+
piece += `${ALPHABET[value >> 2]}${ALPHABET[(value & 3) << 4]}==`;
|
|
90
|
+
} else if (remaining === 2) {
|
|
91
|
+
const value = (bytes[index]! << 8) | bytes[index + 1]!;
|
|
92
|
+
piece += `${ALPHABET[value >> 10]}${ALPHABET[(value >> 4) & 63]}${ALPHABET[(value & 15) << 2]}=`;
|
|
93
|
+
}
|
|
94
|
+
parts.push(piece);
|
|
95
|
+
}
|
|
96
|
+
return parts.join('');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** UTF-8 encoding. `TextEncoder` is in Hermes and Node; the loop is the fallback. */
|
|
100
|
+
export function utf8Bytes(value: string): Uint8Array {
|
|
101
|
+
if (typeof TextEncoder !== 'undefined') {
|
|
102
|
+
return new TextEncoder().encode(value);
|
|
103
|
+
}
|
|
104
|
+
const encoded = unescape(encodeURIComponent(value));
|
|
105
|
+
const bytes = new Uint8Array(encoded.length);
|
|
106
|
+
for (let index = 0; index < encoded.length; index += 1) {
|
|
107
|
+
bytes[index] = encoded.charCodeAt(index);
|
|
108
|
+
}
|
|
109
|
+
return bytes;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** UTF-8 decoding, the inverse of `utf8Bytes`. */
|
|
113
|
+
export function utf8String(bytes: Uint8Array): string {
|
|
114
|
+
if (typeof TextDecoder !== 'undefined') {
|
|
115
|
+
return new TextDecoder().decode(bytes);
|
|
116
|
+
}
|
|
117
|
+
let binary = '';
|
|
118
|
+
for (const byte of bytes) {
|
|
119
|
+
binary += String.fromCharCode(byte);
|
|
120
|
+
}
|
|
121
|
+
return decodeURIComponent(escape(binary));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* eslint-enable no-bitwise */
|
|
125
|
+
|
|
126
|
+
export function concatBytes(parts: readonly Uint8Array[]): Uint8Array {
|
|
127
|
+
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
|
128
|
+
const joined = new Uint8Array(total);
|
|
129
|
+
let offset = 0;
|
|
130
|
+
for (const part of parts) {
|
|
131
|
+
joined.set(part, offset);
|
|
132
|
+
offset += part.length;
|
|
133
|
+
}
|
|
134
|
+
return joined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The ASCII text of `length` bytes from `start`, for magic numbers. */
|
|
138
|
+
export function asciiAt(bytes: Uint8Array, start: number, length: number): string {
|
|
139
|
+
let text = '';
|
|
140
|
+
for (let index = start; index < start + length && index < bytes.length; index += 1) {
|
|
141
|
+
text += String.fromCharCode(bytes[index]!);
|
|
142
|
+
}
|
|
143
|
+
return text;
|
|
144
|
+
}
|