@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
package/src/platform/fs.ts
CHANGED
|
@@ -1,89 +1,89 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The filesystem peer, behind one lazy accessor.
|
|
3
|
-
*
|
|
4
|
-
* Required lazily for the same reason the engine is: a consuming app that
|
|
5
|
-
* only ever runs the in-memory adapter in tests must not have to install a
|
|
6
|
-
* native module to import this package. Confining the require to `platform/`
|
|
7
|
-
* also keeps the native surface four directories wide, which is the layering
|
|
8
|
-
* rule the ESLint config enforces.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { DiagnosticCodes } from '../contract/errors';
|
|
12
|
-
import { raise } from '../diagnostics/errors';
|
|
13
|
-
|
|
14
|
-
export type FileStat = {
|
|
15
|
-
path: string;
|
|
16
|
-
size: number;
|
|
17
|
-
mtime: number;
|
|
18
|
-
isFile(): boolean;
|
|
19
|
-
isDirectory(): boolean;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
export type MkdirOptions = {
|
|
23
|
-
/** iOS only. Set at creation so nothing beneath it is ever backed up. */
|
|
24
|
-
NSURLIsExcludedFromBackupKey?: boolean;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Only what the package actually uses. The peer's upload and download
|
|
29
|
-
* helpers are deliberately absent from this type: the package must never
|
|
30
|
-
* make a network request (FR-084, SC-016), and a surface that cannot express
|
|
31
|
-
* one is a stronger guarantee than a rule that says not to.
|
|
32
|
-
*/
|
|
33
|
-
export type FileSystem = {
|
|
34
|
-
DocumentDirectoryPath: string;
|
|
35
|
-
mkdir(path: string, options?: MkdirOptions): Promise<void>;
|
|
36
|
-
writeFile(path: string, contents: string, encoding?: string): Promise<void>;
|
|
37
|
-
appendFile(path: string, contents: string, encoding?: string): Promise<void>;
|
|
38
|
-
readFile(path: string, encoding?: string): Promise<string>;
|
|
39
|
-
/** Chunked read at an offset — what FR-107a's flat memory bound rests on. */
|
|
40
|
-
read(path: string, length: number, position: number, encoding?: string): Promise<string>;
|
|
41
|
-
stat(path: string): Promise<FileStat>;
|
|
42
|
-
readdir(path: string): Promise<string[]>;
|
|
43
|
-
unlink(path: string): Promise<void>;
|
|
44
|
-
exists(path: string): Promise<boolean>;
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
let override: FileSystem | null = null;
|
|
48
|
-
let cached: FileSystem | null = null;
|
|
49
|
-
|
|
50
|
-
/** Swap the peer. Tests and the demo app's in-memory mode only. */
|
|
51
|
-
export function setFileSystem(module: FileSystem | null): void {
|
|
52
|
-
override = module;
|
|
53
|
-
cached = null;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function fileSystem(): FileSystem {
|
|
57
|
-
if (override) {
|
|
58
|
-
return override;
|
|
59
|
-
}
|
|
60
|
-
if (!cached) {
|
|
61
|
-
try {
|
|
62
|
-
cached = require('@dr.pogodin/react-native-fs') as FileSystem;
|
|
63
|
-
} catch (error) {
|
|
64
|
-
raise(DiagnosticCodes.STORAGE_ACCESS, {
|
|
65
|
-
message: 'filesystem peer is not installed',
|
|
66
|
-
cause: error,
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
return cached!;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export async function pathExists(path: string): Promise<boolean> {
|
|
74
|
-
try {
|
|
75
|
-
return await fileSystem().exists(path);
|
|
76
|
-
} catch {
|
|
77
|
-
return false;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Remove a path, treating "already gone" as success (FR-062). */
|
|
82
|
-
export async function removeIfPresent(path: string): Promise<boolean> {
|
|
83
|
-
try {
|
|
84
|
-
await fileSystem().unlink(path);
|
|
85
|
-
return true;
|
|
86
|
-
} catch {
|
|
87
|
-
return false;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The filesystem peer, behind one lazy accessor.
|
|
3
|
+
*
|
|
4
|
+
* Required lazily for the same reason the engine is: a consuming app that
|
|
5
|
+
* only ever runs the in-memory adapter in tests must not have to install a
|
|
6
|
+
* native module to import this package. Confining the require to `platform/`
|
|
7
|
+
* also keeps the native surface four directories wide, which is the layering
|
|
8
|
+
* rule the ESLint config enforces.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { DiagnosticCodes } from '../contract/errors';
|
|
12
|
+
import { raise } from '../diagnostics/errors';
|
|
13
|
+
|
|
14
|
+
export type FileStat = {
|
|
15
|
+
path: string;
|
|
16
|
+
size: number;
|
|
17
|
+
mtime: number;
|
|
18
|
+
isFile(): boolean;
|
|
19
|
+
isDirectory(): boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type MkdirOptions = {
|
|
23
|
+
/** iOS only. Set at creation so nothing beneath it is ever backed up. */
|
|
24
|
+
NSURLIsExcludedFromBackupKey?: boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Only what the package actually uses. The peer's upload and download
|
|
29
|
+
* helpers are deliberately absent from this type: the package must never
|
|
30
|
+
* make a network request (FR-084, SC-016), and a surface that cannot express
|
|
31
|
+
* one is a stronger guarantee than a rule that says not to.
|
|
32
|
+
*/
|
|
33
|
+
export type FileSystem = {
|
|
34
|
+
DocumentDirectoryPath: string;
|
|
35
|
+
mkdir(path: string, options?: MkdirOptions): Promise<void>;
|
|
36
|
+
writeFile(path: string, contents: string, encoding?: string): Promise<void>;
|
|
37
|
+
appendFile(path: string, contents: string, encoding?: string): Promise<void>;
|
|
38
|
+
readFile(path: string, encoding?: string): Promise<string>;
|
|
39
|
+
/** Chunked read at an offset — what FR-107a's flat memory bound rests on. */
|
|
40
|
+
read(path: string, length: number, position: number, encoding?: string): Promise<string>;
|
|
41
|
+
stat(path: string): Promise<FileStat>;
|
|
42
|
+
readdir(path: string): Promise<string[]>;
|
|
43
|
+
unlink(path: string): Promise<void>;
|
|
44
|
+
exists(path: string): Promise<boolean>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
let override: FileSystem | null = null;
|
|
48
|
+
let cached: FileSystem | null = null;
|
|
49
|
+
|
|
50
|
+
/** Swap the peer. Tests and the demo app's in-memory mode only. */
|
|
51
|
+
export function setFileSystem(module: FileSystem | null): void {
|
|
52
|
+
override = module;
|
|
53
|
+
cached = null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function fileSystem(): FileSystem {
|
|
57
|
+
if (override) {
|
|
58
|
+
return override;
|
|
59
|
+
}
|
|
60
|
+
if (!cached) {
|
|
61
|
+
try {
|
|
62
|
+
cached = require('@dr.pogodin/react-native-fs') as FileSystem;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
raise(DiagnosticCodes.STORAGE_ACCESS, {
|
|
65
|
+
message: 'filesystem peer is not installed',
|
|
66
|
+
cause: error,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return cached!;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function pathExists(path: string): Promise<boolean> {
|
|
74
|
+
try {
|
|
75
|
+
return await fileSystem().exists(path);
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Remove a path, treating "already gone" as success (FR-062). */
|
|
82
|
+
export async function removeIfPresent(path: string): Promise<boolean> {
|
|
83
|
+
try {
|
|
84
|
+
await fileSystem().unlink(path);
|
|
85
|
+
return true;
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/platform/location.ts
CHANGED
|
@@ -1,135 +1,135 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The managed storage area, and the single guard every filesystem call routes
|
|
3
|
-
* through.
|
|
4
|
-
*
|
|
5
|
-
* POL-011(b) flags media-and-files as a risk because this package deletes
|
|
6
|
-
* files from disk on cascade. The mitigation is that there is exactly one
|
|
7
|
-
* function that turns a caller's intent into a path, and it refuses anything
|
|
8
|
-
* outside the root. One choke point is what makes SC-013a's filesystem
|
|
9
|
-
* tracing pass reliably rather than usually.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { DiagnosticCodes } from '../contract/errors';
|
|
13
|
-
import { raise } from '../diagnostics/errors';
|
|
14
|
-
import { fileSystem } from './fs';
|
|
15
|
-
|
|
16
|
-
export const MANAGED_ROOT_NAME = 'chipmobilesdk-localdb';
|
|
17
|
-
export const CATALOG_FILE = 'catalog.db';
|
|
18
|
-
export const SCOPES_DIR = 'scopes';
|
|
19
|
-
export const DATA_FILE = 'data.db';
|
|
20
|
-
export const ARTIFACTS_DIR = 'artifacts';
|
|
21
|
-
export const MARKER_FILE = '.localdb-marker';
|
|
22
|
-
|
|
23
|
-
/** Normalize separators and collapse redundant segments. No `..` survives. */
|
|
24
|
-
export function normalizePath(path: string): string {
|
|
25
|
-
const unified = path.replace(/\\/g, '/');
|
|
26
|
-
const segments: string[] = [];
|
|
27
|
-
for (const segment of unified.split('/')) {
|
|
28
|
-
if (segment === '' || segment === '.') {
|
|
29
|
-
continue;
|
|
30
|
-
}
|
|
31
|
-
if (segment === '..') {
|
|
32
|
-
segments.pop();
|
|
33
|
-
continue;
|
|
34
|
-
}
|
|
35
|
-
segments.push(segment);
|
|
36
|
-
}
|
|
37
|
-
const prefix = unified.startsWith('/') ? '/' : '';
|
|
38
|
-
return prefix + segments.join('/');
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export type StorageLayout = {
|
|
42
|
-
/** Absolute path to the managed root for one logical database. */
|
|
43
|
-
root: string;
|
|
44
|
-
catalogPath: string;
|
|
45
|
-
scopePath(scopeKey: string): string;
|
|
46
|
-
scopeDataPath(scopeKey: string): string;
|
|
47
|
-
scopeArtifactsPath(scopeKey: string): string;
|
|
48
|
-
artifactPath(scopeKey: string, attachmentId: string): string;
|
|
49
|
-
markerPath: string;
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
const SCOPE_KEY = /^[0-9a-f]{64}$/;
|
|
53
|
-
const ARTIFACT_ID = /^[A-Za-z0-9._-]{1,128}$/;
|
|
54
|
-
// `.` and `..` are made entirely of permitted characters and are the two that
|
|
55
|
-
// matter: `artifacts/..` resolves to the scope directory itself, still inside
|
|
56
|
-
// the managed root, so the boundary guard would wave it through and a cascade
|
|
57
|
-
// would unlink the whole scope. A leading dot is refused with them, since no
|
|
58
|
-
// attachment id needs one and hidden files are the package's own.
|
|
59
|
-
const DOT_PREFIXED = /^\./;
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* An attachment id must be a safe single path segment.
|
|
63
|
-
*
|
|
64
|
-
* Shared by both adapters rather than left to the one that touches a
|
|
65
|
-
* filesystem. The in-memory adapter has no paths to escape, but a consumer
|
|
66
|
-
* whose tests accept `../../etc/passwd` and whose device build refuses it has
|
|
67
|
-
* a test suite that stops predicting the device — which is the one thing the
|
|
68
|
-
* adapter exists to avoid (FR-096).
|
|
69
|
-
*/
|
|
70
|
-
export function assertValidAttachmentId(attachmentId: string): void {
|
|
71
|
-
if (!ARTIFACT_ID.test(attachmentId) || DOT_PREFIXED.test(attachmentId)) {
|
|
72
|
-
raise(DiagnosticCodes.ATTACHMENT_OUT_OF_BOUNDS, {
|
|
73
|
-
message: 'attachment id is not a valid path segment',
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function createLayout(documentDirectory: string, databaseName: string): StorageLayout {
|
|
79
|
-
const root = normalizePath(`${documentDirectory}/${MANAGED_ROOT_NAME}/${databaseName}`);
|
|
80
|
-
|
|
81
|
-
const scopePath = (scopeKey: string): string => {
|
|
82
|
-
// The scope key is our own SHA-256 output. Validating its shape here
|
|
83
|
-
// means a caller cannot smuggle a traversal through it even if some
|
|
84
|
-
// future path reaches this function with unvalidated input.
|
|
85
|
-
if (!SCOPE_KEY.test(scopeKey)) {
|
|
86
|
-
raise(DiagnosticCodes.ATTACHMENT_OUT_OF_BOUNDS, {
|
|
87
|
-
message: 'scope key is not a valid digest',
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
return `${root}/${SCOPES_DIR}/${scopeKey}`;
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
return {
|
|
94
|
-
root,
|
|
95
|
-
catalogPath: `${root}/${CATALOG_FILE}`,
|
|
96
|
-
markerPath: `${root}/${MARKER_FILE}`,
|
|
97
|
-
scopePath,
|
|
98
|
-
scopeDataPath: scopeKey => `${scopePath(scopeKey)}/${DATA_FILE}`,
|
|
99
|
-
scopeArtifactsPath: scopeKey => `${scopePath(scopeKey)}/${ARTIFACTS_DIR}`,
|
|
100
|
-
artifactPath: (scopeKey, attachmentId) => {
|
|
101
|
-
assertValidAttachmentId(attachmentId);
|
|
102
|
-
return `${scopePath(scopeKey)}/${ARTIFACTS_DIR}/${attachmentId}`;
|
|
103
|
-
},
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* The layout for one logical database, rooted at the app's private documents
|
|
109
|
-
* directory.
|
|
110
|
-
*
|
|
111
|
-
* Resolved lazily through the filesystem peer rather than captured at module
|
|
112
|
-
* load, so importing this package does not require the peer to be installed —
|
|
113
|
-
* a consumer running only the in-memory adapter never reaches this function.
|
|
114
|
-
*/
|
|
115
|
-
export function layoutFor(databaseName: string): StorageLayout {
|
|
116
|
-
return createLayout(fileSystem().DocumentDirectoryPath, databaseName);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* The guard. Every read, write, and delete passes through this, and anything
|
|
121
|
-
* resolving outside the managed root is refused before it reaches the
|
|
122
|
-
* filesystem (FR-073, FR-073f).
|
|
123
|
-
*/
|
|
124
|
-
export function assertWithinManagedArea(layout: StorageLayout, candidate: string): string {
|
|
125
|
-
const normalized = normalizePath(candidate);
|
|
126
|
-
const root = normalizePath(layout.root);
|
|
127
|
-
if (normalized !== root && !normalized.startsWith(`${root}/`)) {
|
|
128
|
-
raise(DiagnosticCodes.ATTACHMENT_OUT_OF_BOUNDS, {
|
|
129
|
-
// The offending path is NOT reported: it may contain a scope
|
|
130
|
-
// component or a user-chosen filename.
|
|
131
|
-
message: 'path resolves outside the managed storage area',
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
return normalized;
|
|
135
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The managed storage area, and the single guard every filesystem call routes
|
|
3
|
+
* through.
|
|
4
|
+
*
|
|
5
|
+
* POL-011(b) flags media-and-files as a risk because this package deletes
|
|
6
|
+
* files from disk on cascade. The mitigation is that there is exactly one
|
|
7
|
+
* function that turns a caller's intent into a path, and it refuses anything
|
|
8
|
+
* outside the root. One choke point is what makes SC-013a's filesystem
|
|
9
|
+
* tracing pass reliably rather than usually.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { DiagnosticCodes } from '../contract/errors';
|
|
13
|
+
import { raise } from '../diagnostics/errors';
|
|
14
|
+
import { fileSystem } from './fs';
|
|
15
|
+
|
|
16
|
+
export const MANAGED_ROOT_NAME = 'chipmobilesdk-localdb';
|
|
17
|
+
export const CATALOG_FILE = 'catalog.db';
|
|
18
|
+
export const SCOPES_DIR = 'scopes';
|
|
19
|
+
export const DATA_FILE = 'data.db';
|
|
20
|
+
export const ARTIFACTS_DIR = 'artifacts';
|
|
21
|
+
export const MARKER_FILE = '.localdb-marker';
|
|
22
|
+
|
|
23
|
+
/** Normalize separators and collapse redundant segments. No `..` survives. */
|
|
24
|
+
export function normalizePath(path: string): string {
|
|
25
|
+
const unified = path.replace(/\\/g, '/');
|
|
26
|
+
const segments: string[] = [];
|
|
27
|
+
for (const segment of unified.split('/')) {
|
|
28
|
+
if (segment === '' || segment === '.') {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (segment === '..') {
|
|
32
|
+
segments.pop();
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
segments.push(segment);
|
|
36
|
+
}
|
|
37
|
+
const prefix = unified.startsWith('/') ? '/' : '';
|
|
38
|
+
return prefix + segments.join('/');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type StorageLayout = {
|
|
42
|
+
/** Absolute path to the managed root for one logical database. */
|
|
43
|
+
root: string;
|
|
44
|
+
catalogPath: string;
|
|
45
|
+
scopePath(scopeKey: string): string;
|
|
46
|
+
scopeDataPath(scopeKey: string): string;
|
|
47
|
+
scopeArtifactsPath(scopeKey: string): string;
|
|
48
|
+
artifactPath(scopeKey: string, attachmentId: string): string;
|
|
49
|
+
markerPath: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const SCOPE_KEY = /^[0-9a-f]{64}$/;
|
|
53
|
+
const ARTIFACT_ID = /^[A-Za-z0-9._-]{1,128}$/;
|
|
54
|
+
// `.` and `..` are made entirely of permitted characters and are the two that
|
|
55
|
+
// matter: `artifacts/..` resolves to the scope directory itself, still inside
|
|
56
|
+
// the managed root, so the boundary guard would wave it through and a cascade
|
|
57
|
+
// would unlink the whole scope. A leading dot is refused with them, since no
|
|
58
|
+
// attachment id needs one and hidden files are the package's own.
|
|
59
|
+
const DOT_PREFIXED = /^\./;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* An attachment id must be a safe single path segment.
|
|
63
|
+
*
|
|
64
|
+
* Shared by both adapters rather than left to the one that touches a
|
|
65
|
+
* filesystem. The in-memory adapter has no paths to escape, but a consumer
|
|
66
|
+
* whose tests accept `../../etc/passwd` and whose device build refuses it has
|
|
67
|
+
* a test suite that stops predicting the device — which is the one thing the
|
|
68
|
+
* adapter exists to avoid (FR-096).
|
|
69
|
+
*/
|
|
70
|
+
export function assertValidAttachmentId(attachmentId: string): void {
|
|
71
|
+
if (!ARTIFACT_ID.test(attachmentId) || DOT_PREFIXED.test(attachmentId)) {
|
|
72
|
+
raise(DiagnosticCodes.ATTACHMENT_OUT_OF_BOUNDS, {
|
|
73
|
+
message: 'attachment id is not a valid path segment',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function createLayout(documentDirectory: string, databaseName: string): StorageLayout {
|
|
79
|
+
const root = normalizePath(`${documentDirectory}/${MANAGED_ROOT_NAME}/${databaseName}`);
|
|
80
|
+
|
|
81
|
+
const scopePath = (scopeKey: string): string => {
|
|
82
|
+
// The scope key is our own SHA-256 output. Validating its shape here
|
|
83
|
+
// means a caller cannot smuggle a traversal through it even if some
|
|
84
|
+
// future path reaches this function with unvalidated input.
|
|
85
|
+
if (!SCOPE_KEY.test(scopeKey)) {
|
|
86
|
+
raise(DiagnosticCodes.ATTACHMENT_OUT_OF_BOUNDS, {
|
|
87
|
+
message: 'scope key is not a valid digest',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return `${root}/${SCOPES_DIR}/${scopeKey}`;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
root,
|
|
95
|
+
catalogPath: `${root}/${CATALOG_FILE}`,
|
|
96
|
+
markerPath: `${root}/${MARKER_FILE}`,
|
|
97
|
+
scopePath,
|
|
98
|
+
scopeDataPath: scopeKey => `${scopePath(scopeKey)}/${DATA_FILE}`,
|
|
99
|
+
scopeArtifactsPath: scopeKey => `${scopePath(scopeKey)}/${ARTIFACTS_DIR}`,
|
|
100
|
+
artifactPath: (scopeKey, attachmentId) => {
|
|
101
|
+
assertValidAttachmentId(attachmentId);
|
|
102
|
+
return `${scopePath(scopeKey)}/${ARTIFACTS_DIR}/${attachmentId}`;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The layout for one logical database, rooted at the app's private documents
|
|
109
|
+
* directory.
|
|
110
|
+
*
|
|
111
|
+
* Resolved lazily through the filesystem peer rather than captured at module
|
|
112
|
+
* load, so importing this package does not require the peer to be installed —
|
|
113
|
+
* a consumer running only the in-memory adapter never reaches this function.
|
|
114
|
+
*/
|
|
115
|
+
export function layoutFor(databaseName: string): StorageLayout {
|
|
116
|
+
return createLayout(fileSystem().DocumentDirectoryPath, databaseName);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The guard. Every read, write, and delete passes through this, and anything
|
|
121
|
+
* resolving outside the managed root is refused before it reaches the
|
|
122
|
+
* filesystem (FR-073, FR-073f).
|
|
123
|
+
*/
|
|
124
|
+
export function assertWithinManagedArea(layout: StorageLayout, candidate: string): string {
|
|
125
|
+
const normalized = normalizePath(candidate);
|
|
126
|
+
const root = normalizePath(layout.root);
|
|
127
|
+
if (normalized !== root && !normalized.startsWith(`${root}/`)) {
|
|
128
|
+
raise(DiagnosticCodes.ATTACHMENT_OUT_OF_BOUNDS, {
|
|
129
|
+
// The offending path is NOT reported: it may contain a scope
|
|
130
|
+
// component or a user-chosen filename.
|
|
131
|
+
message: 'path resolves outside the managed storage area',
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return normalized;
|
|
135
|
+
}
|
package/src/query/budgets.ts
CHANGED
|
@@ -1,57 +1,54 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Budget enforcement.
|
|
3
|
-
*
|
|
4
|
-
* Per-record size, page size, and batch size are HARD limits reported through
|
|
5
|
-
* typed errors (FR-088a). The rest are soft: exceeding them must not crash,
|
|
6
|
-
* hang, or drop data silently, and the degraded behavior is documented.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { Budgets } from '../contract/config';
|
|
10
|
-
import { DiagnosticCodes } from '../contract/errors';
|
|
11
|
-
import type { StorableRecordData } from '../contract/record';
|
|
12
|
-
import { raise } from '../diagnostics/errors';
|
|
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
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Budget enforcement.
|
|
3
|
+
*
|
|
4
|
+
* Per-record size, page size, and batch size are HARD limits reported through
|
|
5
|
+
* typed errors (FR-088a). The rest are soft: exceeding them must not crash,
|
|
6
|
+
* hang, or drop data silently, and the degraded behavior is documented.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Budgets } from '../contract/config';
|
|
10
|
+
import { DiagnosticCodes } from '../contract/errors';
|
|
11
|
+
import type { StorableRecordData } from '../contract/record';
|
|
12
|
+
import { raise } from '../diagnostics/errors';
|
|
13
|
+
import { utf8Bytes } from '../platform/bytes';
|
|
14
|
+
|
|
15
|
+
/** UTF-8 byte length of the serialized payload — what actually hits disk. */
|
|
16
|
+
export function serializedSize(data: StorableRecordData): number {
|
|
17
|
+
const json = JSON.stringify(data ?? {});
|
|
18
|
+
return utf8Bytes(json).length;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function assertRecordWithinBudget(collection: string, data: StorableRecordData): void {
|
|
22
|
+
const size = serializedSize(data);
|
|
23
|
+
if (size > Budgets.MAX_RECORD_BYTES) {
|
|
24
|
+
// Truncating would be silent data loss; failing opaquely would leave
|
|
25
|
+
// the consumer guessing. The size and the limit are both safe to
|
|
26
|
+
// report — neither can carry a field value.
|
|
27
|
+
raise(DiagnosticCodes.RECORD_TOO_LARGE, {
|
|
28
|
+
message: 'record exceeds the per-record size budget',
|
|
29
|
+
detail: { collection, sizeBytes: size, limitBytes: Budgets.MAX_RECORD_BYTES },
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function assertBatchWithinBudget(count: number): void {
|
|
35
|
+
if (count > Budgets.MAX_BATCH_OPERATIONS) {
|
|
36
|
+
raise(DiagnosticCodes.BATCH_TOO_LARGE, {
|
|
37
|
+
message: 'batch exceeds the operation budget',
|
|
38
|
+
detail: { count, limit: Budgets.MAX_BATCH_OPERATIONS },
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function assertArtifactWithinBudget(id: string, size: number): void {
|
|
44
|
+
if (size > Budgets.MAX_ARTIFACT_BYTES) {
|
|
45
|
+
raise(DiagnosticCodes.ATTACHMENT_WRITE_FAILED, {
|
|
46
|
+
message: 'artifact exceeds the size budget',
|
|
47
|
+
detail: {
|
|
48
|
+
attachmentId: id,
|
|
49
|
+
sizeBytes: size,
|
|
50
|
+
limitBytes: Budgets.MAX_ARTIFACT_BYTES,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|