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