yamlock 0.3.0 → 1.1.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.
@@ -1,12 +1,166 @@
1
1
  import { encryptValue } from '../crypto/encrypt.js';
2
2
  import { decryptValue } from '../crypto/decrypt.js';
3
- import { buildPath } from './path.js';
3
+ import { detectPayloadVersion } from '../crypto/payload-v2.js';
4
+ import { isYamlockPayload } from '../crypto/utils.js';
5
+ import { YamlockConfigError } from '../errors.js';
6
+ import { serializeLegacyPath, serializePath } from './path.js';
7
+ import {
8
+ compilePathPatterns,
9
+ matchesAnyPathPattern,
10
+ PathPatternSyntaxError
11
+ } from './path-pattern.js';
4
12
 
5
13
  const MODES = {
6
14
  ENCRYPT: 'encrypt',
7
15
  DECRYPT: 'decrypt'
8
16
  };
9
17
 
18
+ const NON_STRING_POLICIES = new Set(['ignore', 'stringify', 'error']);
19
+ const EXISTING_PAYLOAD_POLICIES = new Set(['preserve', 'error', 'encrypt']);
20
+
21
+ function createConfigError(code, message, cause) {
22
+ return new YamlockConfigError(message, {
23
+ code,
24
+ ...(cause === undefined ? {} : { cause })
25
+ });
26
+ }
27
+
28
+ function isConfigContainer(value) {
29
+ if (Array.isArray(value)) {
30
+ return true;
31
+ }
32
+
33
+ if (typeof value !== 'object' || value === null) {
34
+ return false;
35
+ }
36
+
37
+ const prototype = Object.getPrototypeOf(value);
38
+ return prototype === Object.prototype || prototype === null;
39
+ }
40
+
41
+ function createResultContainer(node) {
42
+ return Array.isArray(node)
43
+ ? new Array(node.length)
44
+ : Object.create(Object.getPrototypeOf(node));
45
+ }
46
+
47
+ function setResultValue(result, key, value) {
48
+ Object.defineProperty(result, key, {
49
+ value,
50
+ enumerable: true,
51
+ configurable: true,
52
+ writable: true
53
+ });
54
+ }
55
+
56
+ function validatePathSegments(segments, optionName) {
57
+ if (!Array.isArray(segments)) {
58
+ throw createConfigError(
59
+ 'ERR_INVALID_PATH_SEGMENTS',
60
+ `${optionName} must be an array of non-empty strings or non-negative integers.`
61
+ );
62
+ }
63
+
64
+ const hasInvalidSegment = segments.some((segment) => (
65
+ (typeof segment !== 'string' || segment.length === 0) &&
66
+ (!Number.isInteger(segment) || segment < 0)
67
+ ));
68
+ if (hasInvalidSegment) {
69
+ throw createConfigError(
70
+ 'ERR_INVALID_PATH_SEGMENTS',
71
+ `${optionName} must contain only non-empty strings or non-negative integers.`
72
+ );
73
+ }
74
+ }
75
+
76
+ function normalizePaths(paths) {
77
+ if (paths === undefined || (Array.isArray(paths) && paths.length === 0)) {
78
+ return null;
79
+ }
80
+
81
+ if (!Array.isArray(paths)) {
82
+ throw createConfigError('ERR_INVALID_PATHS', 'paths must be an array of non-empty strings.');
83
+ }
84
+
85
+ const normalized = paths.map((path) => {
86
+ if (typeof path !== 'string' || path.trim().length === 0) {
87
+ throw createConfigError('ERR_INVALID_PATHS', 'paths must contain only non-empty strings.');
88
+ }
89
+ return path.trim();
90
+ });
91
+ return new Set(normalized);
92
+ }
93
+
94
+ function normalizePathPatterns(pathPatterns) {
95
+ try {
96
+ return compilePathPatterns(pathPatterns);
97
+ } catch (error) {
98
+ if (!(error instanceof PathPatternSyntaxError)) {
99
+ throw error;
100
+ }
101
+
102
+ throw createConfigError(
103
+ 'ERR_INVALID_PATH_PATTERNS',
104
+ error.message,
105
+ error
106
+ );
107
+ }
108
+ }
109
+
110
+ function resolveCurrentPaths(segments, pathSerializer) {
111
+ let currentPath;
112
+ try {
113
+ currentPath = pathSerializer
114
+ ? pathSerializer([...segments])
115
+ : serializePath(segments);
116
+ } catch (error) {
117
+ throw createConfigError(
118
+ pathSerializer ? 'ERR_INVALID_PATH_SERIALIZER' : 'ERR_INVALID_PATH_SEGMENTS',
119
+ `Failed to serialize config path: ${error.message}`
120
+ );
121
+ }
122
+
123
+ if (typeof currentPath !== 'string' || currentPath.length === 0) {
124
+ throw createConfigError(
125
+ 'ERR_INVALID_PATH_SERIALIZER',
126
+ 'pathSerializer must return a non-empty string.'
127
+ );
128
+ }
129
+
130
+ return {
131
+ currentPath,
132
+ legacyPath: pathSerializer ? null : serializeLegacyPath(segments)
133
+ };
134
+ }
135
+
136
+ function decryptConfigValue(value, key, currentPath, legacyPath, cryptoOptions) {
137
+ try {
138
+ return decryptValue(value, key, currentPath, cryptoOptions);
139
+ } catch (error) {
140
+ if (!legacyPath || legacyPath === currentPath) {
141
+ throw error;
142
+ }
143
+
144
+ return decryptValue(value, key, legacyPath, cryptoOptions);
145
+ }
146
+ }
147
+
148
+ function stringifyConfigLeaf(value, currentPath) {
149
+ const isJsonPrimitive = (
150
+ value === null ||
151
+ typeof value === 'boolean' ||
152
+ (typeof value === 'number' && Number.isFinite(value))
153
+ );
154
+ if (!isJsonPrimitive) {
155
+ throw createConfigError(
156
+ 'ERR_UNSUPPORTED_CONFIG_VALUE',
157
+ `Value at ${currentPath} cannot be stringified without an explicit conversion.`
158
+ );
159
+ }
160
+
161
+ return JSON.stringify(value);
162
+ }
163
+
10
164
  /**
11
165
  * Recursively processes a config object or array, encrypting/decrypting string values.
12
166
  * @param {Object|Array} node
@@ -15,85 +169,208 @@ const MODES = {
15
169
  * @param {string|Buffer} options.key
16
170
  * @param {string|object} [options.algorithm]
17
171
  * @param {object} [options.algorithmOptions]
172
+ * @param {1|2} [options.formatVersion]
18
173
  * @param {"ignore"|"stringify"|"error"} [options.nonStringPolicy]
174
+ * @param {"preserve"|"error"|"encrypt"} [options.existingPayloadPolicy]
19
175
  * @param {(segments: Array<string|number>) => string} [options.pathSerializer]
20
176
  * @param {string[]} [options.paths]
177
+ * @param {string[]} [options.pathPatterns]
21
178
  * @param {Array<string|number>} [options.parentPath]
22
179
  * @returns {Object|Array}
23
180
  */
24
181
  export function processConfig(node, options) {
25
- if (typeof node !== 'object' || node === null) {
26
- throw new Error('processConfig expects a non-null object or array.');
182
+ if (!isConfigContainer(node)) {
183
+ throw createConfigError(
184
+ 'ERR_INVALID_CONFIG_ROOT',
185
+ 'processConfig expects an array or plain object.'
186
+ );
187
+ }
188
+
189
+ if (typeof options !== 'object' || options === null || Array.isArray(options)) {
190
+ throw createConfigError('ERR_INVALID_CONFIG_OPTIONS', 'processConfig options must be an object.');
27
191
  }
28
192
 
29
193
  const mode = options.mode;
30
194
  if (mode !== MODES.ENCRYPT && mode !== MODES.DECRYPT) {
31
- throw new Error(`Unknown processConfig mode: ${mode}`);
195
+ throw createConfigError('ERR_INVALID_MODE', `Unknown processConfig mode: ${mode}`);
32
196
  }
33
197
 
34
- const normalizedPaths = Array.isArray(options.paths) && options.paths.length > 0
35
- ? new Set(options.paths.map((path) => String(path).trim()).filter(Boolean))
36
- : null;
198
+ if (mode !== MODES.ENCRYPT && options.existingPayloadPolicy !== undefined) {
199
+ throw createConfigError(
200
+ 'ERR_INVALID_EXISTING_PAYLOAD_POLICY',
201
+ 'existingPayloadPolicy is available only in encrypt mode.'
202
+ );
203
+ }
204
+
205
+ const nonStringPolicy = options.nonStringPolicy ?? 'ignore';
206
+ if (!NON_STRING_POLICIES.has(nonStringPolicy)) {
207
+ throw createConfigError(
208
+ 'ERR_INVALID_NON_STRING_POLICY',
209
+ `Unknown nonStringPolicy: ${nonStringPolicy}`
210
+ );
211
+ }
212
+
213
+ if (options.pathSerializer !== undefined && typeof options.pathSerializer !== 'function') {
214
+ throw createConfigError(
215
+ 'ERR_INVALID_PATH_SERIALIZER',
216
+ 'pathSerializer must be a function.'
217
+ );
218
+ }
219
+
220
+ const parentPath = options.parentPath ?? [];
221
+ validatePathSegments(parentPath, 'parentPath');
222
+ const normalizedPaths = normalizePaths(options.paths);
223
+ const compiledPathPatterns = normalizePathPatterns(options.pathPatterns);
224
+ if (compiledPathPatterns.length > 0 && options.pathSerializer !== undefined) {
225
+ throw createConfigError(
226
+ 'ERR_INVALID_PATH_PATTERNS',
227
+ 'pathPatterns cannot be combined with pathSerializer.'
228
+ );
229
+ }
230
+ const existingPayloadPolicy = options.existingPayloadPolicy ?? 'preserve';
231
+ if (!EXISTING_PAYLOAD_POLICIES.has(existingPayloadPolicy)) {
232
+ throw createConfigError(
233
+ 'ERR_INVALID_EXISTING_PAYLOAD_POLICY',
234
+ `Unknown existingPayloadPolicy: ${existingPayloadPolicy}`
235
+ );
236
+ }
37
237
 
38
238
  return traverseConfig(node, {
39
239
  ...options,
40
240
  mode,
41
- parentPath: options.parentPath ?? [],
241
+ parentPath,
42
242
  normalizedPaths,
43
- nonStringPolicy: options.nonStringPolicy ?? 'ignore',
44
- pathSerializer: options.pathSerializer
243
+ compiledPathPatterns,
244
+ nonStringPolicy,
245
+ existingPayloadPolicy,
246
+ pathSerializer: options.pathSerializer,
247
+ ancestors: new WeakSet(),
248
+ seenPaths: new Set()
45
249
  });
46
250
  }
47
251
 
48
- function traverseConfig(node, { mode, key, algorithm, algorithmOptions, parentPath, normalizedPaths, nonStringPolicy, pathSerializer }) {
252
+ function traverseConfig(node, { mode, key, algorithm, algorithmOptions, formatVersion, parentPath, normalizedPaths, compiledPathPatterns, nonStringPolicy, existingPayloadPolicy, pathSerializer, ancestors, seenPaths }) {
49
253
  const isArrayNode = Array.isArray(node);
50
- const result = isArrayNode ? [] : {};
51
- const cryptoOptions = algorithmOptions ?? algorithm;
52
-
53
- Object.entries(node).forEach(([rawKey, value]) => {
54
- const segment = isArrayNode ? Number(rawKey) : rawKey;
55
- const targetKey = isArrayNode ? segment : rawKey;
56
- const currentPath = pathSerializer
57
- ? pathSerializer([...parentPath, segment])
58
- : buildPath(parentPath, segment);
59
-
60
- if (value !== null && typeof value === 'object') {
61
- result[targetKey] = traverseConfig(value, {
62
- mode,
63
- key,
64
- algorithm: cryptoOptions,
65
- algorithmOptions: cryptoOptions,
66
- parentPath: [...parentPath, segment],
67
- normalizedPaths,
68
- nonStringPolicy,
69
- pathSerializer
70
- });
71
- return;
72
- }
254
+ const result = createResultContainer(node);
255
+ const selectedCryptoOptions = algorithmOptions ?? algorithm;
256
+ const cryptoOptions = formatVersion === undefined
257
+ ? selectedCryptoOptions
258
+ : typeof selectedCryptoOptions === 'object' && selectedCryptoOptions !== null
259
+ ? { ...selectedCryptoOptions, formatVersion }
260
+ : { algorithm: selectedCryptoOptions, formatVersion };
73
261
 
74
- if (typeof value !== 'string') {
75
- if (nonStringPolicy === 'stringify') {
76
- value = JSON.stringify(value);
77
- } else if (nonStringPolicy === 'error') {
78
- throw new Error(`Non-string value encountered at ${currentPath}`);
79
- } else {
80
- result[targetKey] = value;
81
- return;
262
+ ancestors.add(node);
263
+ try {
264
+ for (const [rawKey, originalValue] of Object.entries(node)) {
265
+ const segment = isArrayNode ? Number(rawKey) : rawKey;
266
+ const targetKey = isArrayNode ? segment : rawKey;
267
+ const pathSegments = [...parentPath, segment];
268
+ const { currentPath, legacyPath } = resolveCurrentPaths(pathSegments, pathSerializer);
269
+
270
+ if (isConfigContainer(originalValue)) {
271
+ if (ancestors.has(originalValue)) {
272
+ throw createConfigError(
273
+ 'ERR_CIRCULAR_CONFIG',
274
+ `Circular reference encountered at ${currentPath}.`
275
+ );
276
+ }
277
+
278
+ setResultValue(result, targetKey, traverseConfig(originalValue, {
279
+ mode,
280
+ key,
281
+ algorithm: cryptoOptions,
282
+ algorithmOptions: cryptoOptions,
283
+ parentPath: pathSegments,
284
+ normalizedPaths,
285
+ compiledPathPatterns,
286
+ nonStringPolicy,
287
+ existingPayloadPolicy,
288
+ pathSerializer,
289
+ ancestors,
290
+ seenPaths
291
+ }));
292
+ continue;
82
293
  }
83
- }
84
294
 
85
- const shouldProcess = !normalizedPaths || normalizedPaths.has(currentPath);
86
- if (!shouldProcess) {
87
- result[targetKey] = value;
88
- return;
89
- }
295
+ if (seenPaths.has(currentPath)) {
296
+ throw createConfigError(
297
+ 'ERR_PATH_COLLISION',
298
+ `Multiple config values resolve to the path ${currentPath}.`
299
+ );
300
+ }
301
+ seenPaths.add(currentPath);
90
302
 
91
- if (mode === MODES.ENCRYPT) {
92
- result[targetKey] = encryptValue(value, key, currentPath, cryptoOptions);
93
- } else {
94
- result[targetKey] = decryptValue(value, key, currentPath, cryptoOptions);
303
+ const hasSelectors = normalizedPaths !== null || compiledPathPatterns.length > 0;
304
+ const shouldProcess = !hasSelectors ||
305
+ normalizedPaths?.has(currentPath) ||
306
+ matchesAnyPathPattern(compiledPathPatterns, pathSegments);
307
+ if (!shouldProcess) {
308
+ setResultValue(result, targetKey, originalValue);
309
+ continue;
310
+ }
311
+
312
+ let value = originalValue;
313
+ if (typeof value !== 'string') {
314
+ if (nonStringPolicy === 'ignore') {
315
+ setResultValue(result, targetKey, value);
316
+ continue;
317
+ }
318
+
319
+ if (nonStringPolicy === 'error' || mode === MODES.DECRYPT) {
320
+ throw createConfigError(
321
+ 'ERR_NON_STRING_VALUE',
322
+ `Non-string value encountered at ${currentPath}.`
323
+ );
324
+ }
325
+
326
+ value = stringifyConfigLeaf(value, currentPath);
327
+ }
328
+
329
+ if (mode === MODES.ENCRYPT) {
330
+ if (isYamlockPayload(value)) {
331
+ if (existingPayloadPolicy === 'encrypt') {
332
+ setResultValue(
333
+ result,
334
+ targetKey,
335
+ encryptValue(value, key, currentPath, cryptoOptions)
336
+ );
337
+ continue;
338
+ }
339
+
340
+ const payloadVersion = detectPayloadVersion(value);
341
+ decryptConfigValue(
342
+ value,
343
+ key,
344
+ currentPath,
345
+ legacyPath,
346
+ payloadVersion === 1 ? cryptoOptions : undefined
347
+ );
348
+
349
+ if (existingPayloadPolicy === 'error') {
350
+ throw createConfigError(
351
+ 'ERR_ALREADY_ENCRYPTED',
352
+ `Selected value at ${currentPath} is already encrypted.`
353
+ );
354
+ }
355
+
356
+ setResultValue(result, targetKey, value);
357
+ continue;
358
+ }
359
+
360
+ setResultValue(result, targetKey, encryptValue(value, key, currentPath, cryptoOptions));
361
+ } else {
362
+ setResultValue(result, targetKey, decryptConfigValue(
363
+ value,
364
+ key,
365
+ currentPath,
366
+ legacyPath,
367
+ cryptoOptions
368
+ ));
369
+ }
95
370
  }
96
- });
97
371
 
98
- return result;
372
+ return result;
373
+ } finally {
374
+ ancestors.delete(node);
375
+ }
99
376
  }
@@ -0,0 +1,58 @@
1
+ import {
2
+ closeSync,
3
+ fchmodSync,
4
+ fsyncSync,
5
+ linkSync,
6
+ openSync,
7
+ renameSync,
8
+ unlinkSync,
9
+ writeFileSync
10
+ } from 'node:fs';
11
+ import { basename, dirname, join } from 'node:path';
12
+ import { randomBytes } from 'node:crypto';
13
+
14
+ /**
15
+ * Writes a file through an exclusive temporary file in the destination
16
+ * directory and atomically installs it at the requested path.
17
+ *
18
+ * @param {string} filePath
19
+ * @param {string} content
20
+ * @param {{mode?: number, refuseExisting?: boolean}} [options]
21
+ */
22
+ export function writeFileAtomically(filePath, content, { mode, refuseExisting = false } = {}) {
23
+ const temporaryPath = join(
24
+ dirname(filePath),
25
+ `.${basename(filePath)}.yamlock-${process.pid}-${randomBytes(8).toString('hex')}.tmp`
26
+ );
27
+ let fileDescriptor;
28
+
29
+ try {
30
+ fileDescriptor = openSync(temporaryPath, 'wx', 0o600);
31
+ writeFileSync(fileDescriptor, content, 'utf8');
32
+ if (mode !== undefined) {
33
+ fchmodSync(fileDescriptor, mode);
34
+ }
35
+ fsyncSync(fileDescriptor);
36
+ closeSync(fileDescriptor);
37
+ fileDescriptor = undefined;
38
+
39
+ if (refuseExisting) {
40
+ linkSync(temporaryPath, filePath);
41
+ unlinkSync(temporaryPath);
42
+ return;
43
+ }
44
+
45
+ renameSync(temporaryPath, filePath);
46
+ } catch (error) {
47
+ if (fileDescriptor !== undefined) {
48
+ closeSync(fileDescriptor);
49
+ }
50
+
51
+ try {
52
+ unlinkSync(temporaryPath);
53
+ } catch {
54
+ // The temporary file may not exist or may already have been installed.
55
+ }
56
+ throw error;
57
+ }
58
+ }
@@ -0,0 +1,176 @@
1
+ import { decryptValue } from '../crypto/decrypt.js';
2
+ import { encryptValue } from '../crypto/encrypt.js';
3
+ import {
4
+ detectPayloadVersion,
5
+ V2_FORMAT_VERSION
6
+ } from '../crypto/payload-v2.js';
7
+ import { isYamlockPayload } from '../crypto/utils.js';
8
+ import { serializeLegacyPath, serializePath } from './path.js';
9
+ import { compilePathPatterns, matchesAnyPathPattern } from './path-pattern.js';
10
+
11
+ function createMigrationError(code, message) {
12
+ const error = new Error(message);
13
+ error.code = code;
14
+ return error;
15
+ }
16
+
17
+ function normalizePaths(paths) {
18
+ return Array.isArray(paths) && paths.length > 0
19
+ ? new Set(paths.map((path) => String(path).trim()).filter(Boolean))
20
+ : null;
21
+ }
22
+
23
+ function setResultValue(result, key, value) {
24
+ Object.defineProperty(result, key, {
25
+ value,
26
+ enumerable: true,
27
+ configurable: true,
28
+ writable: true
29
+ });
30
+ }
31
+
32
+ function decryptMigrationValue(value, key, currentPath, legacyPath) {
33
+ try {
34
+ return decryptValue(value, key, currentPath);
35
+ } catch (error) {
36
+ if (legacyPath === currentPath) {
37
+ throw error;
38
+ }
39
+
40
+ return decryptValue(value, key, legacyPath);
41
+ }
42
+ }
43
+
44
+ function traverse(node, context) {
45
+ const isArrayNode = Array.isArray(node);
46
+ const result = isArrayNode ? new Array(node.length) : {};
47
+
48
+ Object.entries(node).forEach(([rawKey, value]) => {
49
+ const segment = isArrayNode ? Number(rawKey) : rawKey;
50
+ const targetKey = isArrayNode ? segment : rawKey;
51
+ const pathSegments = [...context.parentPath, segment];
52
+ const currentPath = serializePath(pathSegments);
53
+ const legacyPath = serializeLegacyPath(pathSegments);
54
+
55
+ if (value !== null && typeof value === 'object') {
56
+ setResultValue(result, targetKey, traverse(value, {
57
+ ...context,
58
+ parentPath: pathSegments
59
+ }));
60
+ return;
61
+ }
62
+
63
+ const hasSelectors = context.paths !== null || context.pathPatterns.length > 0;
64
+ const selected = !hasSelectors ||
65
+ context.paths?.has(currentPath) ||
66
+ matchesAnyPathPattern(context.pathPatterns, pathSegments);
67
+ if (!selected) {
68
+ setResultValue(result, targetKey, value);
69
+ return;
70
+ }
71
+
72
+ context.stats.selected += 1;
73
+
74
+ if (typeof value !== 'string') {
75
+ throw createMigrationError(
76
+ 'ERR_MIGRATION_NON_STRING',
77
+ `Selected value at ${currentPath} is not a string.`
78
+ );
79
+ }
80
+
81
+ if (!isYamlockPayload(value)) {
82
+ throw createMigrationError(
83
+ 'ERR_MIGRATION_PLAINTEXT',
84
+ `Selected value at ${currentPath} is not encrypted; narrow the path selectors to legacy payloads.`
85
+ );
86
+ }
87
+
88
+ const version = detectPayloadVersion(value);
89
+ if (version === V2_FORMAT_VERSION) {
90
+ if (!context.allowMixed) {
91
+ throw createMigrationError(
92
+ 'ERR_MIGRATION_ALREADY_V2',
93
+ `Selected value at ${currentPath} is already v2; use --allow-mixed to authenticate and preserve it.`
94
+ );
95
+ }
96
+
97
+ decryptMigrationValue(value, context.key, currentPath, legacyPath);
98
+ setResultValue(result, targetKey, value);
99
+ context.stats.preservedV2 += 1;
100
+ return;
101
+ }
102
+
103
+ if (version !== 1) {
104
+ throw createMigrationError(
105
+ 'ERR_MIGRATION_UNSUPPORTED_VERSION',
106
+ `Selected value at ${currentPath} uses unsupported payload version ${version}.`
107
+ );
108
+ }
109
+
110
+ const plaintext = decryptMigrationValue(value, context.key, currentPath, legacyPath);
111
+ setResultValue(
112
+ result,
113
+ targetKey,
114
+ encryptValue(plaintext, context.key, currentPath, {
115
+ formatVersion: V2_FORMAT_VERSION
116
+ })
117
+ );
118
+ context.stats.migrated += 1;
119
+ });
120
+
121
+ return result;
122
+ }
123
+
124
+ /**
125
+ * Validates selected encrypted values and migrates legacy payloads to v2.
126
+ * The input object is not mutated.
127
+ * @param {Object|Array} node
128
+ * @param {Object} options
129
+ * @param {string|Buffer} options.key
130
+ * @param {string[]} [options.paths]
131
+ * @param {string[]} [options.pathPatterns]
132
+ * @param {boolean} [options.allowMixed=false]
133
+ * @returns {{ data: Object|Array, changed: boolean, stats: { selected: number, migrated: number, preservedV2: number } }}
134
+ */
135
+ export function migrateConfig(node, options) {
136
+ if (typeof node !== 'object' || node === null) {
137
+ throw createMigrationError(
138
+ 'ERR_MIGRATION_INPUT',
139
+ 'migrateConfig expects a non-null object or array.'
140
+ );
141
+ }
142
+
143
+ if (typeof options !== 'object' || options === null) {
144
+ throw createMigrationError(
145
+ 'ERR_MIGRATION_OPTIONS',
146
+ 'migrateConfig expects an options object.'
147
+ );
148
+ }
149
+
150
+ const stats = {
151
+ selected: 0,
152
+ migrated: 0,
153
+ preservedV2: 0
154
+ };
155
+ const data = traverse(node, {
156
+ key: options.key,
157
+ paths: normalizePaths(options.paths),
158
+ pathPatterns: compilePathPatterns(options.pathPatterns),
159
+ allowMixed: options.allowMixed === true,
160
+ parentPath: [],
161
+ stats
162
+ });
163
+
164
+ if (stats.migrated === 0 && stats.preservedV2 === 0) {
165
+ throw createMigrationError(
166
+ 'ERR_MIGRATION_NOTHING_TO_DO',
167
+ 'No selected encrypted values were found to migrate.'
168
+ );
169
+ }
170
+
171
+ return {
172
+ data,
173
+ changed: stats.migrated > 0,
174
+ stats
175
+ };
176
+ }