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.
- package/README.md +249 -36
- package/bin/yamlock +8 -0
- package/dist/cli/cli.js +457 -74
- package/dist/crypto/decrypt.js +112 -13
- package/dist/crypto/encrypt.js +107 -18
- package/dist/crypto/payload-v2.js +371 -0
- package/dist/crypto/utils.js +65 -14
- package/dist/errors.js +59 -0
- package/dist/index.d.ts +123 -0
- package/dist/index.js +10 -0
- package/dist/utils/config.js +298 -44
- package/dist/utils/file.js +58 -0
- package/dist/utils/migrate.js +170 -0
- package/dist/utils/path.js +58 -9
- package/docs/api.md +56 -0
- package/docs/design/payload-v2.md +344 -0
- package/docs/errors.md +71 -0
- package/docs/yaml-behavior.md +51 -0
- package/examples/basic.js +31 -0
- package/examples/docs/ci-cd.md +58 -0
- package/examples/docs/key-rotation.md +65 -0
- package/package.json +19 -7
package/dist/utils/config.js
CHANGED
|
@@ -1,12 +1,142 @@
|
|
|
1
1
|
import { encryptValue } from '../crypto/encrypt.js';
|
|
2
2
|
import { decryptValue } from '../crypto/decrypt.js';
|
|
3
|
-
import {
|
|
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 (
|
|
24
|
-
throw
|
|
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
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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 =
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/utils/path.js
CHANGED
|
@@ -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
|
|
3
|
-
*
|
|
4
|
-
* Example: ["db", "users", 0
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
73
|
+
return index === 0 ? segment : `.${segment}`;
|
|
25
74
|
})
|
|
26
75
|
.join('');
|
|
27
76
|
}
|