@faizahmed/secret-keystore 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.
- package/LICENSE +21 -0
- package/README.md +1203 -0
- package/SECURITY.md +505 -0
- package/bin/cli.js +969 -0
- package/package.json +77 -0
- package/src/attestation/attestation-client.js +146 -0
- package/src/attestation/attestation-manager.js +339 -0
- package/src/attestation/cms-unwrap.js +166 -0
- package/src/attestation/index.js +66 -0
- package/src/attestation/key-pair.js +129 -0
- package/src/config.js +130 -0
- package/src/content-operations.js +494 -0
- package/src/errors.js +372 -0
- package/src/index.d.ts +641 -0
- package/src/index.js +438 -0
- package/src/keystore.js +678 -0
- package/src/kms.js +858 -0
- package/src/object-operations.js +232 -0
- package/src/options.js +541 -0
- package/src/path-matcher.js +319 -0
- package/src/rotate.js +92 -0
- package/src/yaml-utils.js +265 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @faizahmed/secret-keystore - Path Matching
|
|
3
|
+
*
|
|
4
|
+
* Utilities for matching paths in nested objects using ** patterns.
|
|
5
|
+
* Only ** (any-depth) pattern is supported, not * (single-level).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { PathError, PATH_ERROR_CODES } = require('./errors');
|
|
9
|
+
|
|
10
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
11
|
+
// PATH UTILITIES
|
|
12
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get a value from an object by dot-notation path
|
|
16
|
+
* @param {Object} obj - Object to get value from
|
|
17
|
+
* @param {string} path - Dot-notation path (e.g., 'a.b.c')
|
|
18
|
+
* @returns {*} Value at path or undefined
|
|
19
|
+
*/
|
|
20
|
+
function getByPath(obj, path) {
|
|
21
|
+
if (!path) return obj;
|
|
22
|
+
|
|
23
|
+
const parts = path.split('.');
|
|
24
|
+
let current = obj;
|
|
25
|
+
|
|
26
|
+
for (const part of parts) {
|
|
27
|
+
if (current === null || current === undefined) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
current = current[part];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return current;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Set a value in an object by dot-notation path
|
|
38
|
+
* @param {Object} obj - Object to set value in
|
|
39
|
+
* @param {string} path - Dot-notation path (e.g., 'a.b.c')
|
|
40
|
+
* @param {*} value - Value to set
|
|
41
|
+
* @returns {Object} Modified object
|
|
42
|
+
*/
|
|
43
|
+
function setByPath(obj, path, value) {
|
|
44
|
+
if (!path) return value;
|
|
45
|
+
|
|
46
|
+
const parts = path.split('.');
|
|
47
|
+
let current = obj;
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
50
|
+
const part = parts[i];
|
|
51
|
+
if (current[part] === undefined || current[part] === null) {
|
|
52
|
+
current[part] = {};
|
|
53
|
+
}
|
|
54
|
+
current = current[part];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
current[parts[parts.length - 1]] = value;
|
|
58
|
+
return obj;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Get all paths in an object (leaf nodes only)
|
|
63
|
+
* @param {Object} obj - Object to traverse
|
|
64
|
+
* @param {string} [prefix=''] - Current path prefix
|
|
65
|
+
* @returns {string[]} Array of dot-notation paths
|
|
66
|
+
*/
|
|
67
|
+
function getAllPaths(obj, prefix = '') {
|
|
68
|
+
const paths = [];
|
|
69
|
+
|
|
70
|
+
if (obj === null || obj === undefined) {
|
|
71
|
+
return paths;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (typeof obj !== 'object' || Array.isArray(obj) || Buffer.isBuffer(obj)) {
|
|
75
|
+
// Leaf node
|
|
76
|
+
if (prefix) {
|
|
77
|
+
paths.push(prefix);
|
|
78
|
+
}
|
|
79
|
+
return paths;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
83
|
+
const currentPath = prefix ? `${prefix}.${key}` : key;
|
|
84
|
+
|
|
85
|
+
if (
|
|
86
|
+
value !== null &&
|
|
87
|
+
typeof value === 'object' &&
|
|
88
|
+
!Array.isArray(value) &&
|
|
89
|
+
!Buffer.isBuffer(value)
|
|
90
|
+
) {
|
|
91
|
+
// Recurse into nested object
|
|
92
|
+
paths.push(...getAllPaths(value, currentPath));
|
|
93
|
+
} else {
|
|
94
|
+
// Leaf node
|
|
95
|
+
paths.push(currentPath);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return paths;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
103
|
+
// PATTERN MATCHING (** only)
|
|
104
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Convert a ** pattern to a regex
|
|
108
|
+
* @param {string} pattern - Pattern with ** wildcards
|
|
109
|
+
* @returns {RegExp}
|
|
110
|
+
*/
|
|
111
|
+
function patternToRegex(pattern) {
|
|
112
|
+
// Escape special regex characters except **
|
|
113
|
+
let escaped = pattern
|
|
114
|
+
.replaceAll(/[.+^${}()|[\]\\]/g, String.raw`\$&`) // Escape special chars
|
|
115
|
+
.replaceAll('**', '{{DOUBLE_STAR}}'); // Temporarily replace **
|
|
116
|
+
|
|
117
|
+
// Replace ** with regex pattern (match any depth)
|
|
118
|
+
escaped = escaped.replaceAll('{{DOUBLE_STAR}}', '.*');
|
|
119
|
+
|
|
120
|
+
// Anchor the pattern
|
|
121
|
+
return new RegExp(`^${escaped}$`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Check if a path matches a ** pattern
|
|
126
|
+
* @param {string} path - Dot-notation path
|
|
127
|
+
* @param {string} pattern - Pattern with ** wildcards
|
|
128
|
+
* @returns {boolean}
|
|
129
|
+
*/
|
|
130
|
+
function matchesPattern(path, pattern) {
|
|
131
|
+
// Handle patterns that start with **
|
|
132
|
+
if (pattern.startsWith('**.')) {
|
|
133
|
+
// **.foo matches any path ending with .foo or just foo
|
|
134
|
+
const suffix = pattern.slice(3);
|
|
135
|
+
if (path === suffix) return true;
|
|
136
|
+
if (path.endsWith('.' + suffix)) return true;
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Handle patterns that end with **
|
|
141
|
+
if (pattern.endsWith('.**')) {
|
|
142
|
+
// foo.** matches foo and any path starting with foo.
|
|
143
|
+
const prefix = pattern.slice(0, -3);
|
|
144
|
+
if (path === prefix) return true;
|
|
145
|
+
if (path.startsWith(prefix + '.')) return true;
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Handle patterns with ** in the middle or complex patterns
|
|
150
|
+
const regex = patternToRegex(pattern);
|
|
151
|
+
return regex.test(path);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Add explicit paths to selected set
|
|
156
|
+
* @private
|
|
157
|
+
*/
|
|
158
|
+
function addExplicitPaths(selectedPaths, allPaths, paths) {
|
|
159
|
+
if (!paths || !Array.isArray(paths)) return;
|
|
160
|
+
|
|
161
|
+
for (const path of paths) {
|
|
162
|
+
if (allPaths.includes(path)) {
|
|
163
|
+
selectedPaths.add(path);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Add pattern-matched paths to selected set
|
|
170
|
+
* @private
|
|
171
|
+
*/
|
|
172
|
+
function addPatternMatches(selectedPaths, allPaths, patterns) {
|
|
173
|
+
if (!patterns || !Array.isArray(patterns)) return;
|
|
174
|
+
|
|
175
|
+
for (const pattern of patterns) {
|
|
176
|
+
for (const path of allPaths) {
|
|
177
|
+
if (matchesPattern(path, pattern)) {
|
|
178
|
+
selectedPaths.add(path);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Filter paths using patterns and explicit paths
|
|
186
|
+
* @param {string[]} allPaths - All available paths
|
|
187
|
+
* @param {Object} options - Selection options
|
|
188
|
+
* @param {string[]} [options.paths] - Explicit paths to include
|
|
189
|
+
* @param {string[]} [options.patterns] - Patterns to match
|
|
190
|
+
* @param {Object} [options.exclude] - Exclusions
|
|
191
|
+
* @param {string[]} [options.exclude.paths] - Explicit paths to exclude
|
|
192
|
+
* @param {string[]} [options.exclude.patterns] - Patterns to exclude
|
|
193
|
+
* @returns {string[]} Matching paths
|
|
194
|
+
*/
|
|
195
|
+
function filterPaths(allPaths, options = {}) {
|
|
196
|
+
const { paths, patterns, exclude } = options;
|
|
197
|
+
|
|
198
|
+
// If no selection criteria, return all paths
|
|
199
|
+
if (!paths && !patterns) {
|
|
200
|
+
return excludePaths(allPaths, exclude);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const selectedPaths = new Set();
|
|
204
|
+
|
|
205
|
+
addExplicitPaths(selectedPaths, allPaths, paths);
|
|
206
|
+
addPatternMatches(selectedPaths, allPaths, patterns);
|
|
207
|
+
|
|
208
|
+
return excludePaths(Array.from(selectedPaths), exclude);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Exclude paths based on exclusion options
|
|
213
|
+
* @param {string[]} paths - Paths to filter
|
|
214
|
+
* @param {Object} [exclude] - Exclusion options
|
|
215
|
+
* @returns {string[]}
|
|
216
|
+
*/
|
|
217
|
+
function excludePaths(paths, exclude) {
|
|
218
|
+
if (!exclude) return paths;
|
|
219
|
+
|
|
220
|
+
return paths.filter(path => {
|
|
221
|
+
// Check explicit exclusions
|
|
222
|
+
if (exclude.paths && exclude.paths.includes(path)) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Check pattern exclusions
|
|
227
|
+
if (exclude.patterns) {
|
|
228
|
+
for (const pattern of exclude.patterns) {
|
|
229
|
+
if (matchesPattern(path, pattern)) {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return true;
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Validate that all explicit paths exist
|
|
241
|
+
* @param {string[]} requestedPaths - Requested paths
|
|
242
|
+
* @param {string[]} existingPaths - Paths that exist
|
|
243
|
+
* @throws {PathError}
|
|
244
|
+
*/
|
|
245
|
+
function validatePathsExist(requestedPaths, existingPaths) {
|
|
246
|
+
for (const path of requestedPaths) {
|
|
247
|
+
if (!existingPaths.includes(path)) {
|
|
248
|
+
throw new PathError(`Path not found: ${path}`, PATH_ERROR_CODES.NOT_FOUND, path);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
254
|
+
// OBJECT TRANSFORMATION
|
|
255
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Transform values at selected paths in an object
|
|
259
|
+
* @param {Object} obj - Source object
|
|
260
|
+
* @param {string[]} paths - Paths to transform
|
|
261
|
+
* @param {Function} transformer - Async function (value, path) => newValue
|
|
262
|
+
* @param {Object} [options] - Options
|
|
263
|
+
* @param {boolean} [options.continueOnError=false] - Continue on transformation errors
|
|
264
|
+
* @returns {Promise<Object>} Result with transformed object
|
|
265
|
+
*/
|
|
266
|
+
async function transformAtPaths(obj, paths, transformer, options = {}) {
|
|
267
|
+
const continueOnError = options.continueOnError === true;
|
|
268
|
+
|
|
269
|
+
const result = {
|
|
270
|
+
object: structuredClone(obj),
|
|
271
|
+
transformed: [],
|
|
272
|
+
skipped: [],
|
|
273
|
+
failed: []
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
for (const path of paths) {
|
|
277
|
+
const value = getByPath(obj, path);
|
|
278
|
+
|
|
279
|
+
if (value === undefined) {
|
|
280
|
+
result.skipped.push(path);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
const newValue = await transformer(value, path);
|
|
286
|
+
setByPath(result.object, path, newValue);
|
|
287
|
+
result.transformed.push(path);
|
|
288
|
+
} catch (error) {
|
|
289
|
+
if (continueOnError) {
|
|
290
|
+
result.failed.push({ path, error });
|
|
291
|
+
} else {
|
|
292
|
+
throw error;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return result;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
301
|
+
// EXPORTS
|
|
302
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
303
|
+
|
|
304
|
+
module.exports = {
|
|
305
|
+
// Path utilities
|
|
306
|
+
getByPath,
|
|
307
|
+
setByPath,
|
|
308
|
+
getAllPaths,
|
|
309
|
+
|
|
310
|
+
// Pattern matching
|
|
311
|
+
patternToRegex,
|
|
312
|
+
matchesPattern,
|
|
313
|
+
filterPaths,
|
|
314
|
+
excludePaths,
|
|
315
|
+
validatePathsExist,
|
|
316
|
+
|
|
317
|
+
// Object transformation
|
|
318
|
+
transformAtPaths
|
|
319
|
+
};
|
package/src/rotate.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @faizahmed/secret-keystore - Key Rotation
|
|
3
|
+
*
|
|
4
|
+
* Re-encrypt the already-encrypted values in a config file under a NEW KMS Key
|
|
5
|
+
* ID, decrypting them with the OLD key first. Values that were plaintext stay
|
|
6
|
+
* plaintext; only previously-encrypted entries are rotated.
|
|
7
|
+
*
|
|
8
|
+
* Decryption happens in memory; nothing plaintext is written to disk by this
|
|
9
|
+
* helper (the caller decides what to do with the returned content string).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { encryptKMSEnvContent, decryptKMSEnvContent } = require('./content-operations');
|
|
13
|
+
const { encryptKMSObject, decryptKMSObject } = require('./object-operations');
|
|
14
|
+
const { getAllPaths, getByPath } = require('./path-matcher');
|
|
15
|
+
const { isAlreadyEncrypted } = require('./kms');
|
|
16
|
+
const { parseEnvContent } = require('./content-operations');
|
|
17
|
+
const { parseYaml, serializeYaml } = require('./yaml-utils');
|
|
18
|
+
const { ContentError, CONTENT_ERROR_CODES } = require('./errors');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Find the keys/paths in a parsed config that are currently encrypted.
|
|
22
|
+
* @private
|
|
23
|
+
*/
|
|
24
|
+
function findEncryptedEnvKeys(content) {
|
|
25
|
+
return parseEnvContent(content)
|
|
26
|
+
.filter(entry => entry.type === 'keyvalue' && isAlreadyEncrypted(entry.value))
|
|
27
|
+
.map(entry => entry.key);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function findEncryptedObjectPaths(obj) {
|
|
31
|
+
return getAllPaths(obj).filter(path => {
|
|
32
|
+
const value = getByPath(obj, path);
|
|
33
|
+
return typeof value === 'string' && isAlreadyEncrypted(value);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Rotate encrypted values from oldKmsKeyId to newKmsKeyId.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} content - Raw file content
|
|
41
|
+
* @param {'env'|'json'|'yaml'} format - Content format
|
|
42
|
+
* @param {string} oldKmsKeyId - KMS Key ID the content is currently encrypted with
|
|
43
|
+
* @param {string} newKmsKeyId - KMS Key ID to re-encrypt with
|
|
44
|
+
* @param {Object} [options] - Forwarded encrypt/decrypt options (aws, logger, etc.)
|
|
45
|
+
* @returns {Promise<{ content: string, rotated: string[] }>}
|
|
46
|
+
*/
|
|
47
|
+
async function rotateKMSContent(content, format, oldKmsKeyId, newKmsKeyId, options = {}) {
|
|
48
|
+
if (format === 'env') {
|
|
49
|
+
const encryptedKeys = findEncryptedEnvKeys(content);
|
|
50
|
+
if (encryptedKeys.length === 0) {
|
|
51
|
+
return { content, rotated: [] };
|
|
52
|
+
}
|
|
53
|
+
const decrypted = await decryptKMSEnvContent(content, oldKmsKeyId, options);
|
|
54
|
+
const reEncrypted = await encryptKMSEnvContent(decrypted.content, newKmsKeyId, {
|
|
55
|
+
...options,
|
|
56
|
+
paths: encryptedKeys
|
|
57
|
+
});
|
|
58
|
+
return { content: reEncrypted.content, rotated: reEncrypted.encrypted };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (format === 'json' || format === 'yaml') {
|
|
62
|
+
const obj = format === 'json' ? JSON.parse(content) : parseYaml(content);
|
|
63
|
+
const encryptedPaths = findEncryptedObjectPaths(obj);
|
|
64
|
+
if (encryptedPaths.length === 0) {
|
|
65
|
+
return { content, rotated: [] };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const decrypted = await decryptKMSObject(obj, oldKmsKeyId, {
|
|
69
|
+
...options,
|
|
70
|
+
paths: encryptedPaths
|
|
71
|
+
});
|
|
72
|
+
const reEncrypted = await encryptKMSObject(decrypted.object, newKmsKeyId, {
|
|
73
|
+
...options,
|
|
74
|
+
paths: encryptedPaths
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const output =
|
|
78
|
+
format === 'json'
|
|
79
|
+
? JSON.stringify(reEncrypted.object, null, 2)
|
|
80
|
+
: serializeYaml(reEncrypted.object);
|
|
81
|
+
|
|
82
|
+
return { content: output, rotated: reEncrypted.encrypted };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
throw new ContentError(
|
|
86
|
+
`Unsupported format for rotation: ${format}`,
|
|
87
|
+
CONTENT_ERROR_CODES.INVALID_FORMAT,
|
|
88
|
+
format
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { rotateKMSContent };
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @faizahmed/secret-keystore - YAML Utilities
|
|
3
|
+
*
|
|
4
|
+
* Handles YAML parsing/serialization with optional js-yaml dependency.
|
|
5
|
+
* Falls back to simple parser for basic YAML when js-yaml is not installed.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { ContentError, CONTENT_ERROR_CODES } = require('./errors');
|
|
9
|
+
|
|
10
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
11
|
+
// JS-YAML LOADER
|
|
12
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Cached reference to js-yaml module (or null if not available)
|
|
16
|
+
* @type {Object|null|undefined}
|
|
17
|
+
*/
|
|
18
|
+
let jsYamlModule;
|
|
19
|
+
let jsYamlChecked = false;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Check if js-yaml is available
|
|
23
|
+
* @returns {boolean}
|
|
24
|
+
*/
|
|
25
|
+
function isJsYamlAvailable() {
|
|
26
|
+
if (!jsYamlChecked) {
|
|
27
|
+
try {
|
|
28
|
+
jsYamlModule = require('js-yaml');
|
|
29
|
+
} catch {
|
|
30
|
+
jsYamlModule = null;
|
|
31
|
+
}
|
|
32
|
+
jsYamlChecked = true;
|
|
33
|
+
}
|
|
34
|
+
return jsYamlModule !== null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Get the js-yaml module
|
|
39
|
+
* @returns {Object|null}
|
|
40
|
+
*/
|
|
41
|
+
function getJsYaml() {
|
|
42
|
+
if (!jsYamlChecked) {
|
|
43
|
+
isJsYamlAvailable();
|
|
44
|
+
}
|
|
45
|
+
return jsYamlModule;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
49
|
+
// SIMPLE YAML PARSER (FALLBACK)
|
|
50
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Features NOT supported by simple parser:
|
|
54
|
+
* - Multi-line strings (| and >)
|
|
55
|
+
* - Anchors and aliases (&, *)
|
|
56
|
+
* - Complex nested arrays
|
|
57
|
+
* - Type tags (!!str, !!int, etc.)
|
|
58
|
+
* - Document separators (---, ...)
|
|
59
|
+
*/
|
|
60
|
+
const COMPLEX_YAML_PATTERNS = [
|
|
61
|
+
/&\w+/, // Anchors (&anchor)
|
|
62
|
+
/\*\w+/, // Aliases (*anchor)
|
|
63
|
+
/<<:/, // Merge key
|
|
64
|
+
/:\s*[|>]/m, // Multi-line strings
|
|
65
|
+
/^---/m, // Document separator
|
|
66
|
+
/^\.\.\./m, // Document end
|
|
67
|
+
/!!\w+/, // Type tags
|
|
68
|
+
/^\s*-\s*[^#\n]+:/m // Nested objects in arrays
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Check if YAML content has complex features
|
|
73
|
+
* @param {string} content
|
|
74
|
+
* @returns {boolean}
|
|
75
|
+
*/
|
|
76
|
+
function hasComplexYamlFeatures(content) {
|
|
77
|
+
return COMPLEX_YAML_PATTERNS.some(pattern => pattern.test(content));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Simple YAML parser (handles basic key: value structures)
|
|
82
|
+
* @param {string} content - YAML content
|
|
83
|
+
* @returns {Object} Parsed object
|
|
84
|
+
*/
|
|
85
|
+
function parseYamlSimple(content) {
|
|
86
|
+
const lines = content.split('\n');
|
|
87
|
+
const result = {};
|
|
88
|
+
const stack = [{ obj: result, indent: -1 }];
|
|
89
|
+
|
|
90
|
+
for (const line of lines) {
|
|
91
|
+
// Skip empty lines and comments
|
|
92
|
+
if (!line.trim() || line.trim().startsWith('#')) continue;
|
|
93
|
+
|
|
94
|
+
const match = line.match(/^(\s*)([^:]+):\s*(.*)$/);
|
|
95
|
+
if (!match) continue;
|
|
96
|
+
|
|
97
|
+
const indent = match[1].length;
|
|
98
|
+
const key = match[2].trim();
|
|
99
|
+
let value = match[3].trim();
|
|
100
|
+
|
|
101
|
+
// Remove inline comments (not in quoted strings)
|
|
102
|
+
if (!value.startsWith('"') && !value.startsWith("'")) {
|
|
103
|
+
const commentIndex = value.indexOf('#');
|
|
104
|
+
if (commentIndex > 0) {
|
|
105
|
+
value = value.substring(0, commentIndex).trim();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Remove quotes
|
|
110
|
+
if (
|
|
111
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
112
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
113
|
+
) {
|
|
114
|
+
value = value.slice(1, -1);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Pop stack until we find parent
|
|
118
|
+
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
|
|
119
|
+
stack.pop();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const parent = stack[stack.length - 1].obj;
|
|
123
|
+
|
|
124
|
+
if (value) {
|
|
125
|
+
parent[key] = value;
|
|
126
|
+
} else {
|
|
127
|
+
parent[key] = {};
|
|
128
|
+
stack.push({ obj: parent[key], indent });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Simple YAML serializer
|
|
137
|
+
* @param {Object} obj - Object to serialize
|
|
138
|
+
* @param {number} indent - Current indentation level
|
|
139
|
+
* @returns {string} YAML string
|
|
140
|
+
*/
|
|
141
|
+
function serializeYamlSimple(obj, indent = 0) {
|
|
142
|
+
let result = '';
|
|
143
|
+
const spaces = ' '.repeat(indent);
|
|
144
|
+
|
|
145
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
146
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
147
|
+
result += `${spaces}${key}:\n`;
|
|
148
|
+
result += serializeYamlSimple(value, indent + 1);
|
|
149
|
+
} else {
|
|
150
|
+
const needsQuotes =
|
|
151
|
+
typeof value === 'string' &&
|
|
152
|
+
(value.includes(':') ||
|
|
153
|
+
value.includes('#') ||
|
|
154
|
+
value.includes(' ') ||
|
|
155
|
+
value.includes('\n'));
|
|
156
|
+
const formattedValue = needsQuotes ? `"${value}"` : value;
|
|
157
|
+
result += `${spaces}${key}: ${formattedValue}\n`;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
165
|
+
// PUBLIC API
|
|
166
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Parse YAML content to object
|
|
170
|
+
*
|
|
171
|
+
* Uses js-yaml if available, falls back to simple parser for basic YAML.
|
|
172
|
+
* Throws an error if content has complex features and js-yaml is not installed.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} content - YAML content string
|
|
175
|
+
* @returns {Object} Parsed object
|
|
176
|
+
* @throws {ContentError} If parsing fails or complex YAML without js-yaml
|
|
177
|
+
*/
|
|
178
|
+
function parseYaml(content) {
|
|
179
|
+
const yaml = getJsYaml();
|
|
180
|
+
|
|
181
|
+
if (yaml) {
|
|
182
|
+
try {
|
|
183
|
+
return yaml.load(content);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
throw new ContentError(
|
|
186
|
+
`Failed to parse YAML: ${error.message}`,
|
|
187
|
+
CONTENT_ERROR_CODES.PARSE_FAILED,
|
|
188
|
+
'yaml',
|
|
189
|
+
error
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// No js-yaml available - check for complex features
|
|
195
|
+
if (hasComplexYamlFeatures(content)) {
|
|
196
|
+
throw new ContentError(
|
|
197
|
+
'YAML content has complex features (anchors, multi-line strings, etc.) that require js-yaml. ' +
|
|
198
|
+
'Install js-yaml: npm install js-yaml',
|
|
199
|
+
CONTENT_ERROR_CODES.PARSE_FAILED,
|
|
200
|
+
'yaml'
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Use simple parser for basic YAML
|
|
205
|
+
try {
|
|
206
|
+
return parseYamlSimple(content);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
throw new ContentError(
|
|
209
|
+
`Failed to parse YAML: ${error.message}. For complex YAML, install js-yaml: npm install js-yaml`,
|
|
210
|
+
CONTENT_ERROR_CODES.PARSE_FAILED,
|
|
211
|
+
'yaml',
|
|
212
|
+
error
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Serialize object to YAML string
|
|
219
|
+
*
|
|
220
|
+
* Uses js-yaml if available, falls back to simple serializer.
|
|
221
|
+
*
|
|
222
|
+
* @param {Object} obj - Object to serialize
|
|
223
|
+
* @returns {string} YAML string
|
|
224
|
+
*/
|
|
225
|
+
function serializeYaml(obj) {
|
|
226
|
+
const yaml = getJsYaml();
|
|
227
|
+
|
|
228
|
+
if (yaml) {
|
|
229
|
+
try {
|
|
230
|
+
return yaml.dump(obj, { lineWidth: -1 });
|
|
231
|
+
} catch (error) {
|
|
232
|
+
throw new ContentError(
|
|
233
|
+
`Failed to serialize YAML: ${error.message}`,
|
|
234
|
+
CONTENT_ERROR_CODES.SERIALIZATION_FAILED,
|
|
235
|
+
'yaml',
|
|
236
|
+
error
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
return serializeYamlSimple(obj);
|
|
243
|
+
} catch (error) {
|
|
244
|
+
throw new ContentError(
|
|
245
|
+
`Failed to serialize YAML: ${error.message}`,
|
|
246
|
+
CONTENT_ERROR_CODES.SERIALIZATION_FAILED,
|
|
247
|
+
'yaml',
|
|
248
|
+
error
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
254
|
+
// EXPORTS
|
|
255
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
256
|
+
|
|
257
|
+
module.exports = {
|
|
258
|
+
isJsYamlAvailable,
|
|
259
|
+
getJsYaml,
|
|
260
|
+
parseYaml,
|
|
261
|
+
serializeYaml,
|
|
262
|
+
parseYamlSimple,
|
|
263
|
+
serializeYamlSimple,
|
|
264
|
+
hasComplexYamlFeatures
|
|
265
|
+
};
|