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.
- package/CHANGELOG.md +205 -0
- package/README.md +274 -41
- package/bin/yamlock +8 -0
- package/dist/cli/cli.js +489 -79
- 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 +60 -0
- package/dist/index.d.ts +125 -0
- package/dist/index.js +10 -0
- package/dist/utils/config.js +331 -54
- package/dist/utils/file.js +58 -0
- package/dist/utils/migrate.js +176 -0
- package/dist/utils/path-pattern.js +260 -0
- package/dist/utils/path.js +58 -9
- package/docs/api.md +63 -0
- package/docs/design/path-patterns.md +213 -0
- package/docs/design/payload-v2.md +344 -0
- package/docs/errors.md +72 -0
- package/docs/yaml-behavior.md +52 -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/cli/cli.js
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
lstatSync,
|
|
4
|
+
readFileSync
|
|
5
|
+
} from 'node:fs';
|
|
6
|
+
import { extname, resolve } from 'node:path';
|
|
4
7
|
import { exit } from 'node:process';
|
|
5
8
|
import { createRequire } from 'node:module';
|
|
6
9
|
import { randomBytes } from 'node:crypto';
|
|
10
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
7
11
|
|
|
8
12
|
import yaml from 'js-yaml';
|
|
9
13
|
|
|
10
14
|
import { processConfig } from '../utils/config.js';
|
|
15
|
+
import { writeFileAtomically } from '../utils/file.js';
|
|
16
|
+
import { migrateConfig } from '../utils/migrate.js';
|
|
17
|
+
import { compilePathPatterns } from '../utils/path-pattern.js';
|
|
11
18
|
import { listSupportedAlgorithms, TESTED_ALGORITHMS } from '../crypto/utils.js';
|
|
12
19
|
|
|
13
20
|
const require = createRequire(import.meta.url);
|
|
@@ -18,6 +25,58 @@ const BANNER = `
|
|
|
18
25
|
░░█░░█▀█░█░▀░█░█░░░█░█░█░░░█▀▄░
|
|
19
26
|
░░▀░░▀░▀░▀░░░▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░`;
|
|
20
27
|
|
|
28
|
+
const KEYGEN_MIN_LENGTH = 1;
|
|
29
|
+
const KEYGEN_MAX_LENGTH = 4096;
|
|
30
|
+
const FILE_COMMANDS = new Set(['encrypt', 'decrypt', 'migrate']);
|
|
31
|
+
const OPTION_SPECS = [
|
|
32
|
+
{ key: 'key', names: ['-k', '--key'], takesValue: true },
|
|
33
|
+
{ key: 'algorithm', names: ['-a', '--algorithm'], takesValue: true },
|
|
34
|
+
{ key: 'output', names: ['-o', '--output'], takesValue: true },
|
|
35
|
+
{ key: 'paths', names: ['-p', '--paths'], takesValue: true },
|
|
36
|
+
{ key: 'pathPatterns', names: ['--path-patterns'], takesValue: true },
|
|
37
|
+
{ key: 'dryRun', names: ['-d', '--dry-run'], takesValue: false },
|
|
38
|
+
{ key: 'allowMixed', names: ['--allow-mixed'], takesValue: false },
|
|
39
|
+
{ key: 'noBackup', names: ['--no-backup'], takesValue: false },
|
|
40
|
+
{ key: 'legacy', names: ['--legacy'], takesValue: false },
|
|
41
|
+
{ key: 'errorOnEncrypted', names: ['--error-on-encrypted'], takesValue: false },
|
|
42
|
+
{ key: 'forceEncrypt', names: ['--force-encrypt'], takesValue: false },
|
|
43
|
+
{ key: 'length', names: ['--length'], takesValue: true },
|
|
44
|
+
{ key: 'format', names: ['--format'], takesValue: true }
|
|
45
|
+
];
|
|
46
|
+
const OPTION_BY_NAME = new Map(
|
|
47
|
+
OPTION_SPECS.flatMap((spec) => spec.names.map((name) => [name, spec]))
|
|
48
|
+
);
|
|
49
|
+
const OPTION_LABELS = new Map(
|
|
50
|
+
OPTION_SPECS.map((spec) => [spec.key, spec.names.at(-1)])
|
|
51
|
+
);
|
|
52
|
+
const COMMAND_OPTIONS = new Map([
|
|
53
|
+
['encrypt', new Set([
|
|
54
|
+
'key',
|
|
55
|
+
'algorithm',
|
|
56
|
+
'output',
|
|
57
|
+
'paths',
|
|
58
|
+
'pathPatterns',
|
|
59
|
+
'dryRun',
|
|
60
|
+
'legacy',
|
|
61
|
+
'errorOnEncrypted',
|
|
62
|
+
'forceEncrypt'
|
|
63
|
+
])],
|
|
64
|
+
['decrypt', new Set(['key', 'output', 'paths', 'pathPatterns', 'dryRun'])],
|
|
65
|
+
['migrate', new Set([
|
|
66
|
+
'key',
|
|
67
|
+
'output',
|
|
68
|
+
'paths',
|
|
69
|
+
'pathPatterns',
|
|
70
|
+
'dryRun',
|
|
71
|
+
'allowMixed',
|
|
72
|
+
'noBackup'
|
|
73
|
+
])],
|
|
74
|
+
['keygen', new Set(['length', 'format'])],
|
|
75
|
+
['help', new Set()],
|
|
76
|
+
['version', new Set()],
|
|
77
|
+
['algorithms', new Set()]
|
|
78
|
+
]);
|
|
79
|
+
|
|
21
80
|
function getHelpText() {
|
|
22
81
|
return `${BANNER}
|
|
23
82
|
Version: ${packageJson.version}
|
|
@@ -28,18 +87,36 @@ yamlock <command> [options]
|
|
|
28
87
|
Commands:
|
|
29
88
|
encrypt <file> Encrypt string values in the given YAML/JSON file.
|
|
30
89
|
decrypt <file> Decrypt string values in the given YAML/JSON file.
|
|
90
|
+
migrate <file> Migrate selected legacy payloads to authenticated v2.
|
|
91
|
+
help Show this help text.
|
|
31
92
|
version Print the yamlock CLI version.
|
|
32
93
|
algorithms Print the list of supported cipher algorithms.
|
|
33
94
|
keygen Generate a random YAMLOCK_KEY.
|
|
34
95
|
|
|
35
96
|
Options:
|
|
36
97
|
-k, --key <value> Encryption key (or use YAMLOCK_KEY env).
|
|
37
|
-
-a, --algorithm <value>
|
|
98
|
+
-a, --algorithm <value> Legacy cipher algorithm (encrypt --legacy only).
|
|
38
99
|
-o, --output <file> Write the result to a different file (otherwise overwrites the input file).
|
|
39
|
-
-p, --paths <p1,p2> Comma-separated
|
|
40
|
-
-
|
|
41
|
-
--
|
|
100
|
+
-p, --paths <p1,p2> Comma-separated escaped field paths to process (dot/bracket notation).
|
|
101
|
+
--path-patterns <p1,p2> Structural selectors using *, [*], and ** whole-segment wildcards.
|
|
102
|
+
-d, --dry-run Preview the operation without modifying files.
|
|
103
|
+
--allow-mixed (migrate) Authenticate and preserve selected v2 values.
|
|
104
|
+
--no-backup (migrate) Replace the input without creating <file>.yamlock.bak.
|
|
105
|
+
--legacy (encrypt) Write the legacy v1 format for compatibility.
|
|
106
|
+
--error-on-encrypted (encrypt) Fail if a selected value is already encrypted.
|
|
107
|
+
--force-encrypt (encrypt) Encrypt selected yl|... strings as plaintext.
|
|
108
|
+
--length <bytes> (keygen) Random bytes to generate, 1-${KEYGEN_MAX_LENGTH} (default: 32).
|
|
42
109
|
--format <hex|base64> (keygen) Output format (default: base64).
|
|
110
|
+
-h, --help Show this help text.
|
|
111
|
+
|
|
112
|
+
YAML rewrite note:
|
|
113
|
+
Comments, anchors/aliases, explicit tags, quoting, and formatting are not
|
|
114
|
+
preserved byte-for-byte. Use --dry-run or --output before replacing a file.
|
|
115
|
+
|
|
116
|
+
Path syntax:
|
|
117
|
+
Object-key backslashes, dots, brackets, and commas must be backslash-escaped.
|
|
118
|
+
Example: db\\.primary.token selects { "db.primary": { "token": ... } }.
|
|
119
|
+
Patterns are separate selectors; e.g. services.*.token or users[*].token.
|
|
43
120
|
`;
|
|
44
121
|
}
|
|
45
122
|
|
|
@@ -61,14 +138,35 @@ function detectFormat(filePath) {
|
|
|
61
138
|
}
|
|
62
139
|
|
|
63
140
|
function readConfigFile(filePath) {
|
|
141
|
+
const stat = lstatSync(filePath);
|
|
64
142
|
const content = readFileSync(filePath, 'utf8');
|
|
65
143
|
const format = detectFormat(filePath);
|
|
66
144
|
|
|
67
145
|
if (format === 'yaml') {
|
|
68
|
-
return { format, data: yaml.load(content) ?? {}, raw: content };
|
|
146
|
+
return { format, data: yaml.load(content) ?? {}, raw: content, stat };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { format, data: JSON.parse(content), raw: content, stat };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function describeReadFailure(error) {
|
|
153
|
+
if (typeof error?.reason === 'string') {
|
|
154
|
+
const line = Number.isInteger(error.mark?.line) ? error.mark.line + 1 : null;
|
|
155
|
+
const column = Number.isInteger(error.mark?.column) ? error.mark.column + 1 : null;
|
|
156
|
+
const location = line && column ? ` at line ${line}, column ${column}` : '';
|
|
157
|
+
return `Invalid YAML: ${error.reason}${location}.`;
|
|
69
158
|
}
|
|
70
159
|
|
|
71
|
-
|
|
160
|
+
if (error instanceof SyntaxError) {
|
|
161
|
+
return 'Invalid JSON syntax.';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const fileErrors = {
|
|
165
|
+
EACCES: 'Input file is not readable.',
|
|
166
|
+
EISDIR: 'Input path is a directory, not a file.',
|
|
167
|
+
ENOENT: 'Input file does not exist.'
|
|
168
|
+
};
|
|
169
|
+
return fileErrors[error?.code] ?? 'Unable to read or parse the input file.';
|
|
72
170
|
}
|
|
73
171
|
|
|
74
172
|
function serializeConfig(format, data) {
|
|
@@ -79,75 +177,168 @@ function serializeConfig(format, data) {
|
|
|
79
177
|
return `${JSON.stringify(data, null, 2)}\n`;
|
|
80
178
|
}
|
|
81
179
|
|
|
82
|
-
function
|
|
83
|
-
writeFileSync(filePath, serialized, 'utf8');
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function parsePaths(value) {
|
|
180
|
+
function splitPathList(value) {
|
|
87
181
|
if (!value) {
|
|
88
182
|
return [];
|
|
89
183
|
}
|
|
90
184
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
185
|
+
const paths = [];
|
|
186
|
+
let current = '';
|
|
187
|
+
let escaped = false;
|
|
188
|
+
|
|
189
|
+
for (const character of String(value)) {
|
|
190
|
+
if (character === ',' && !escaped) {
|
|
191
|
+
paths.push(current);
|
|
192
|
+
current = '';
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
current += character;
|
|
197
|
+
if (escaped) {
|
|
198
|
+
escaped = false;
|
|
199
|
+
} else if (character === '\\') {
|
|
200
|
+
escaped = true;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
paths.push(current);
|
|
204
|
+
|
|
205
|
+
return paths.map((segment) => segment.trim());
|
|
95
206
|
}
|
|
96
207
|
|
|
97
208
|
function parseArgs(argv) {
|
|
98
209
|
const args = argv.slice(2);
|
|
210
|
+
const commandAliases = new Map([
|
|
211
|
+
['-h', 'help'],
|
|
212
|
+
['--help', 'help']
|
|
213
|
+
]);
|
|
99
214
|
const result = {
|
|
100
|
-
command: args[0],
|
|
215
|
+
command: commandAliases.get(args[0]) ?? args[0],
|
|
101
216
|
file: undefined,
|
|
102
|
-
options: {
|
|
217
|
+
options: {
|
|
218
|
+
dryRun: false,
|
|
219
|
+
allowMixed: false,
|
|
220
|
+
noBackup: false,
|
|
221
|
+
legacy: false,
|
|
222
|
+
errorOnEncrypted: false,
|
|
223
|
+
forceEncrypt: false
|
|
224
|
+
},
|
|
225
|
+
specifiedOptions: new Set()
|
|
103
226
|
};
|
|
104
227
|
|
|
105
|
-
let
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
228
|
+
for (let i = 1; i < args.length; i += 1) {
|
|
229
|
+
const arg = args[i];
|
|
230
|
+
const spec = OPTION_BY_NAME.get(arg);
|
|
231
|
+
|
|
232
|
+
if (spec) {
|
|
233
|
+
if (result.specifiedOptions.has(spec.key)) {
|
|
234
|
+
throw cliError('ERR_DUPLICATE_OPTION', `Option ${OPTION_LABELS.get(spec.key)} was provided more than once.`);
|
|
235
|
+
}
|
|
236
|
+
result.specifiedOptions.add(spec.key);
|
|
237
|
+
|
|
238
|
+
if (!spec.takesValue) {
|
|
239
|
+
result.options[spec.key] = true;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const next = args[i + 1];
|
|
244
|
+
const negativeLength = spec.key === 'length' && /^-\d/.test(next ?? '');
|
|
245
|
+
if (
|
|
246
|
+
next === undefined ||
|
|
247
|
+
next === '' ||
|
|
248
|
+
(next.startsWith('-') && !negativeLength)
|
|
249
|
+
) {
|
|
250
|
+
throw cliError(
|
|
251
|
+
'ERR_MISSING_OPTION_VALUE',
|
|
252
|
+
`Option ${OPTION_LABELS.get(spec.key)} requires a value.`
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (spec.key === 'paths' || spec.key === 'pathPatterns') {
|
|
257
|
+
const splitSelectors = splitPathList(next);
|
|
258
|
+
const selectors = spec.key === 'pathPatterns'
|
|
259
|
+
? splitSelectors
|
|
260
|
+
: splitSelectors.filter((selector) => selector.length > 0);
|
|
261
|
+
if (
|
|
262
|
+
selectors.length === 0 ||
|
|
263
|
+
(spec.key === 'pathPatterns' && selectors.some((selector) => selector.length === 0))
|
|
264
|
+
) {
|
|
265
|
+
throw cliError(
|
|
266
|
+
spec.key === 'pathPatterns'
|
|
267
|
+
? 'ERR_INVALID_PATH_PATTERNS'
|
|
268
|
+
: 'ERR_INVALID_OPTION_VALUE',
|
|
269
|
+
`Option ${OPTION_LABELS.get(spec.key)} requires at least one non-empty selector.`
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
if (spec.key === 'pathPatterns') {
|
|
273
|
+
compilePathPatterns(selectors);
|
|
274
|
+
}
|
|
275
|
+
result.options[spec.key] = selectors;
|
|
276
|
+
} else {
|
|
277
|
+
result.options[spec.key] = next;
|
|
278
|
+
}
|
|
279
|
+
i += 1;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (arg.startsWith('-')) {
|
|
284
|
+
throw cliError('ERR_UNKNOWN_OPTION', `Unknown option: ${arg}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (result.file !== undefined) {
|
|
288
|
+
throw cliError('ERR_UNEXPECTED_ARGUMENT', `Unexpected argument: ${arg}`);
|
|
289
|
+
}
|
|
290
|
+
result.file = arg;
|
|
112
291
|
}
|
|
113
292
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const next = args[i + 1];
|
|
293
|
+
return result;
|
|
294
|
+
}
|
|
117
295
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
} else if (arg === '--length') {
|
|
131
|
-
result.options.length = next;
|
|
132
|
-
i += 1;
|
|
133
|
-
} else if (arg === '--format') {
|
|
134
|
-
result.options.format = next;
|
|
135
|
-
i += 1;
|
|
136
|
-
} else if (arg === '-d' || arg === '--dry-run') {
|
|
137
|
-
result.options.dryRun = true;
|
|
296
|
+
function validateCommandLine({ command, file, specifiedOptions }) {
|
|
297
|
+
const allowedOptions = COMMAND_OPTIONS.get(command);
|
|
298
|
+
if (!allowedOptions) {
|
|
299
|
+
throw cliError('ERR_UNKNOWN_COMMAND', `Unknown command: ${command}`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
for (const option of specifiedOptions) {
|
|
303
|
+
if (!allowedOptions.has(option)) {
|
|
304
|
+
throw cliError(
|
|
305
|
+
'ERR_INVALID_OPTION',
|
|
306
|
+
`${command} does not accept ${OPTION_LABELS.get(option)}.`
|
|
307
|
+
);
|
|
138
308
|
}
|
|
139
309
|
}
|
|
140
310
|
|
|
141
|
-
if (
|
|
142
|
-
|
|
311
|
+
if (!FILE_COMMANDS.has(command) && file !== undefined) {
|
|
312
|
+
throw cliError('ERR_UNEXPECTED_ARGUMENT', `${command} does not accept a file argument.`);
|
|
143
313
|
}
|
|
314
|
+
}
|
|
144
315
|
|
|
145
|
-
|
|
316
|
+
function parseKeyLength(value) {
|
|
317
|
+
const rawValue = String(value);
|
|
318
|
+
if (!/^\d+$/.test(rawValue)) {
|
|
319
|
+
throw cliError(
|
|
320
|
+
'ERR_INVALID_LENGTH',
|
|
321
|
+
`Key length must be an integer between ${KEYGEN_MIN_LENGTH} and ${KEYGEN_MAX_LENGTH} bytes.`
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const length = Number(rawValue);
|
|
326
|
+
if (
|
|
327
|
+
!Number.isSafeInteger(length) ||
|
|
328
|
+
length < KEYGEN_MIN_LENGTH ||
|
|
329
|
+
length > KEYGEN_MAX_LENGTH
|
|
330
|
+
) {
|
|
331
|
+
throw cliError(
|
|
332
|
+
'ERR_INVALID_LENGTH',
|
|
333
|
+
`Key length must be an integer between ${KEYGEN_MIN_LENGTH} and ${KEYGEN_MAX_LENGTH} bytes.`
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return length;
|
|
146
338
|
}
|
|
147
339
|
|
|
148
340
|
function generateRandomKey(length, format) {
|
|
149
|
-
const
|
|
150
|
-
const buffer = randomBytes(size);
|
|
341
|
+
const buffer = randomBytes(length);
|
|
151
342
|
if (format === 'hex') {
|
|
152
343
|
return buffer.toString('hex');
|
|
153
344
|
}
|
|
@@ -159,31 +350,202 @@ function fail(code, message) {
|
|
|
159
350
|
return exit(1);
|
|
160
351
|
}
|
|
161
352
|
|
|
162
|
-
function
|
|
163
|
-
|
|
353
|
+
function cliError(code, message) {
|
|
354
|
+
return Object.assign(new Error(message), { code });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function validateOperationSource(filePath, config) {
|
|
358
|
+
if (config.stat.isSymbolicLink() || !config.stat.isFile()) {
|
|
359
|
+
throw cliError('ERR_UNSAFE_INPUT', 'Input must be a regular file, not a symbolic link.');
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
let currentStat;
|
|
363
|
+
let currentRaw;
|
|
364
|
+
try {
|
|
365
|
+
currentStat = lstatSync(filePath);
|
|
366
|
+
currentRaw = readFileSync(filePath, 'utf8');
|
|
367
|
+
} catch {
|
|
368
|
+
throw cliError('ERR_INPUT_CHANGED', 'Input changed after it was read; no file was replaced.');
|
|
369
|
+
}
|
|
370
|
+
if (
|
|
371
|
+
!currentStat.isFile() ||
|
|
372
|
+
currentStat.isSymbolicLink() ||
|
|
373
|
+
currentStat.dev !== config.stat.dev ||
|
|
374
|
+
currentStat.ino !== config.stat.ino ||
|
|
375
|
+
currentStat.mode !== config.stat.mode ||
|
|
376
|
+
currentRaw !== config.raw
|
|
377
|
+
) {
|
|
378
|
+
throw cliError('ERR_INPUT_CHANGED', 'Input changed after it was read; no file was replaced.');
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function resolveOutputMode(outputPath, sourceMode) {
|
|
383
|
+
try {
|
|
384
|
+
const outputStat = lstatSync(outputPath);
|
|
385
|
+
if (outputStat.isSymbolicLink() || !outputStat.isFile()) {
|
|
386
|
+
throw cliError('ERR_UNSAFE_OUTPUT', 'Output must be a regular file, not a symbolic link.');
|
|
387
|
+
}
|
|
388
|
+
return outputStat.mode & 0o7777;
|
|
389
|
+
} catch (error) {
|
|
390
|
+
if (error.code === 'ENOENT') {
|
|
391
|
+
return sourceMode;
|
|
392
|
+
}
|
|
393
|
+
throw error;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function handleWrite({ dryRun, file, absolutePath, outputPath, config, data, operation }) {
|
|
398
|
+
const serialized = serializeConfig(config.format, data);
|
|
164
399
|
if (dryRun) {
|
|
165
400
|
print(`DRY-RUN (${operation}) ${file}`);
|
|
166
401
|
print('--- original');
|
|
167
|
-
print((
|
|
402
|
+
print((config.raw ?? '').trimEnd());
|
|
168
403
|
print('+++ result');
|
|
169
404
|
print(serialized.trimEnd());
|
|
170
|
-
if (outputPath !==
|
|
405
|
+
if (outputPath !== absolutePath) {
|
|
171
406
|
print(`(would write to ${outputPath})`);
|
|
172
407
|
}
|
|
173
408
|
print('No files were modified.');
|
|
174
409
|
return;
|
|
175
410
|
}
|
|
176
411
|
|
|
177
|
-
|
|
412
|
+
const sourceMode = config.stat.mode & 0o7777;
|
|
413
|
+
const inPlace = outputPath === absolutePath;
|
|
414
|
+
validateOperationSource(absolutePath, config);
|
|
415
|
+
const outputMode = inPlace
|
|
416
|
+
? sourceMode
|
|
417
|
+
: resolveOutputMode(outputPath, sourceMode);
|
|
418
|
+
|
|
419
|
+
writeFileAtomically(outputPath, serialized, { mode: outputMode });
|
|
178
420
|
print(`${operation === 'encrypt' ? 'Encrypted' : 'Decrypted'} values in ${outputPath}`);
|
|
179
421
|
}
|
|
180
422
|
|
|
423
|
+
function validateMigrationSource(filePath, config) {
|
|
424
|
+
if (config.stat.isSymbolicLink()) {
|
|
425
|
+
throw cliError(
|
|
426
|
+
'ERR_MIGRATION_UNSAFE_INPUT',
|
|
427
|
+
'Migration input must not be a symbolic link.'
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (!config.stat.isFile()) {
|
|
432
|
+
throw cliError(
|
|
433
|
+
'ERR_MIGRATION_UNSAFE_INPUT',
|
|
434
|
+
'Migration input must be a regular file.'
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const currentStat = lstatSync(filePath);
|
|
439
|
+
const currentRaw = readFileSync(filePath, 'utf8');
|
|
440
|
+
if (
|
|
441
|
+
!currentStat.isFile() ||
|
|
442
|
+
currentStat.isSymbolicLink() ||
|
|
443
|
+
currentStat.dev !== config.stat.dev ||
|
|
444
|
+
currentStat.ino !== config.stat.ino ||
|
|
445
|
+
currentStat.mode !== config.stat.mode ||
|
|
446
|
+
currentRaw !== config.raw
|
|
447
|
+
) {
|
|
448
|
+
throw cliError(
|
|
449
|
+
'ERR_MIGRATION_INPUT_CHANGED',
|
|
450
|
+
'Migration input changed after it was read.'
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function handleMigration({ file, absolutePath, outputPath, config, key, options }) {
|
|
456
|
+
validateMigrationSource(absolutePath, config);
|
|
457
|
+
const result = migrateConfig(config.data, {
|
|
458
|
+
key,
|
|
459
|
+
paths: options.paths,
|
|
460
|
+
pathPatterns: options.pathPatterns,
|
|
461
|
+
allowMixed: options.allowMixed
|
|
462
|
+
});
|
|
463
|
+
const serialized = serializeConfig(config.format, result.data);
|
|
464
|
+
|
|
465
|
+
if (!result.changed) {
|
|
466
|
+
print(`No legacy values required migration; authenticated ${result.stats.preservedV2} v2 value(s).`);
|
|
467
|
+
print('No files were modified.');
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (options.dryRun) {
|
|
472
|
+
print(`DRY-RUN (migrate) ${file}`);
|
|
473
|
+
print(`Would migrate ${result.stats.migrated} legacy value(s) and preserve ${result.stats.preservedV2} v2 value(s).`);
|
|
474
|
+
if (outputPath === absolutePath && !options.noBackup) {
|
|
475
|
+
print(`Would create backup ${absolutePath}.yamlock.bak.`);
|
|
476
|
+
}
|
|
477
|
+
print(`Would write to ${outputPath}.`);
|
|
478
|
+
print('No files were modified.');
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
validateMigrationSource(absolutePath, config);
|
|
483
|
+
const inPlace = outputPath === absolutePath;
|
|
484
|
+
let backupPath;
|
|
485
|
+
if (inPlace && !options.noBackup) {
|
|
486
|
+
backupPath = `${absolutePath}.yamlock.bak`;
|
|
487
|
+
try {
|
|
488
|
+
writeFileAtomically(backupPath, config.raw, {
|
|
489
|
+
mode: config.stat.mode & 0o7777,
|
|
490
|
+
refuseExisting: true
|
|
491
|
+
});
|
|
492
|
+
} catch (error) {
|
|
493
|
+
if (error.code === 'EEXIST') {
|
|
494
|
+
throw cliError(
|
|
495
|
+
'ERR_MIGRATION_BACKUP_EXISTS',
|
|
496
|
+
`Backup already exists: ${backupPath}`
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
throw error;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
try {
|
|
504
|
+
writeFileAtomically(outputPath, serialized, {
|
|
505
|
+
mode: config.stat.mode & 0o7777,
|
|
506
|
+
refuseExisting: !inPlace
|
|
507
|
+
});
|
|
508
|
+
} catch (error) {
|
|
509
|
+
if (!inPlace && error.code === 'EEXIST') {
|
|
510
|
+
throw cliError(
|
|
511
|
+
'ERR_MIGRATION_OUTPUT_EXISTS',
|
|
512
|
+
`Migration output already exists: ${outputPath}`
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
throw error;
|
|
516
|
+
}
|
|
517
|
+
print(`Migrated ${result.stats.migrated} legacy value(s) to v2 in ${outputPath}.`);
|
|
518
|
+
if (result.stats.preservedV2 > 0) {
|
|
519
|
+
print(`Authenticated and preserved ${result.stats.preservedV2} existing v2 value(s).`);
|
|
520
|
+
}
|
|
521
|
+
if (backupPath) {
|
|
522
|
+
print(`Backup: ${backupPath}`);
|
|
523
|
+
} else if (inPlace) {
|
|
524
|
+
print('Backup disabled by --no-backup.');
|
|
525
|
+
} else {
|
|
526
|
+
print('Source file was preserved; no backup was created.');
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
181
530
|
export async function runCli(argv = process.argv) {
|
|
182
|
-
|
|
531
|
+
let parsed;
|
|
532
|
+
try {
|
|
533
|
+
parsed = parseArgs(argv);
|
|
534
|
+
if (parsed.command) {
|
|
535
|
+
validateCommandLine(parsed);
|
|
536
|
+
}
|
|
537
|
+
} catch (error) {
|
|
538
|
+
const code = typeof error.code === 'string' && error.code.startsWith('ERR_')
|
|
539
|
+
? error.code
|
|
540
|
+
: 'ERR_INVALID_ARGUMENTS';
|
|
541
|
+
return fail(code, error.message);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const { command, file, options } = parsed;
|
|
183
545
|
|
|
184
|
-
if (!command) {
|
|
546
|
+
if (!command || command === 'help') {
|
|
185
547
|
print(getHelpText().trim());
|
|
186
|
-
return exit(
|
|
548
|
+
return exit(0);
|
|
187
549
|
}
|
|
188
550
|
|
|
189
551
|
if (command === 'version') {
|
|
@@ -197,31 +559,33 @@ export async function runCli(argv = process.argv) {
|
|
|
197
559
|
const testedSet = new Set(tested);
|
|
198
560
|
const additional = algorithms.filter((name) => !testedSet.has(name));
|
|
199
561
|
|
|
200
|
-
print('
|
|
562
|
+
print('Default v2 profile: aes-256-gcm with scrypt.');
|
|
563
|
+
print('\nTested legacy algorithms (covered by yamlock fixtures):');
|
|
201
564
|
tested.forEach((name) => print(`- ${name}`));
|
|
202
565
|
|
|
203
566
|
if (additional.length > 0) {
|
|
204
567
|
print('\nAdditional algorithms available in this runtime:');
|
|
205
568
|
additional.forEach((name) => print(`- ${name}`));
|
|
206
|
-
print('\
|
|
569
|
+
print('\nAdditional ciphers require explicit legacy mode and are not part of the official test matrix.');
|
|
207
570
|
}
|
|
208
571
|
return exit(0);
|
|
209
572
|
}
|
|
210
573
|
|
|
211
574
|
if (command === 'keygen') {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
return fail('ERR_INVALID_LENGTH',
|
|
575
|
+
let desiredLength;
|
|
576
|
+
try {
|
|
577
|
+
desiredLength = options.length === undefined ? 32 : parseKeyLength(options.length);
|
|
578
|
+
} catch (error) {
|
|
579
|
+
return fail(error.code ?? 'ERR_INVALID_LENGTH', error.message);
|
|
217
580
|
}
|
|
581
|
+
const normalizedFormat = (options.format ?? 'base64').toLowerCase();
|
|
218
582
|
|
|
219
583
|
if (!['base64', 'hex'].includes(normalizedFormat)) {
|
|
220
584
|
return fail('ERR_INVALID_FORMAT', 'Key format must be either "base64" or "hex".');
|
|
221
585
|
}
|
|
222
586
|
|
|
223
587
|
const keyValue = generateRandomKey(desiredLength, normalizedFormat);
|
|
224
|
-
print(`Generated key (${normalizedFormat}, ${
|
|
588
|
+
print(`Generated key (${normalizedFormat}, ${desiredLength} bytes of entropy):`);
|
|
225
589
|
print(keyValue);
|
|
226
590
|
print('\nStore it securely, e.g.');
|
|
227
591
|
print(` export YAMLOCK_KEY="${keyValue}"`);
|
|
@@ -244,7 +608,7 @@ export async function runCli(argv = process.argv) {
|
|
|
244
608
|
try {
|
|
245
609
|
config = readConfigFile(absolutePath);
|
|
246
610
|
} catch (error) {
|
|
247
|
-
return fail('ERR_READ_FAILED',
|
|
611
|
+
return fail('ERR_READ_FAILED', describeReadFailure(error));
|
|
248
612
|
}
|
|
249
613
|
|
|
250
614
|
const outputPath = options.output
|
|
@@ -252,19 +616,55 @@ export async function runCli(argv = process.argv) {
|
|
|
252
616
|
: absolutePath;
|
|
253
617
|
|
|
254
618
|
try {
|
|
619
|
+
if (command === 'migrate') {
|
|
620
|
+
handleMigration({
|
|
621
|
+
file,
|
|
622
|
+
absolutePath,
|
|
623
|
+
outputPath,
|
|
624
|
+
config,
|
|
625
|
+
key,
|
|
626
|
+
options
|
|
627
|
+
});
|
|
628
|
+
return exit(0);
|
|
629
|
+
}
|
|
630
|
+
|
|
255
631
|
if (command === 'encrypt') {
|
|
632
|
+
if (options.algorithm !== undefined && !options.legacy) {
|
|
633
|
+
throw cliError(
|
|
634
|
+
'ERR_INVALID_OPTION',
|
|
635
|
+
'encrypt requires --legacy when --algorithm is provided.'
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
if (options.errorOnEncrypted && options.forceEncrypt) {
|
|
639
|
+
throw cliError(
|
|
640
|
+
'ERR_INVALID_OPTION',
|
|
641
|
+
'encrypt cannot combine --error-on-encrypted with --force-encrypt.'
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
|
|
256
645
|
const result = processConfig(config.data, {
|
|
257
646
|
mode: 'encrypt',
|
|
258
647
|
key,
|
|
259
648
|
algorithm: options.algorithm,
|
|
260
|
-
|
|
649
|
+
formatVersion: options.legacy ? 1 : 2,
|
|
650
|
+
existingPayloadPolicy: options.forceEncrypt
|
|
651
|
+
? 'encrypt'
|
|
652
|
+
: options.errorOnEncrypted
|
|
653
|
+
? 'error'
|
|
654
|
+
: 'preserve',
|
|
655
|
+
paths: options.paths,
|
|
656
|
+
pathPatterns: options.pathPatterns
|
|
261
657
|
});
|
|
658
|
+
if (outputPath === absolutePath && isDeepStrictEqual(result, config.data)) {
|
|
659
|
+
print('No plaintext values required encryption. No files were modified.');
|
|
660
|
+
return exit(0);
|
|
661
|
+
}
|
|
262
662
|
handleWrite({
|
|
263
663
|
dryRun: options.dryRun,
|
|
264
664
|
file,
|
|
665
|
+
absolutePath,
|
|
265
666
|
outputPath,
|
|
266
|
-
|
|
267
|
-
originalRaw: config.raw,
|
|
667
|
+
config,
|
|
268
668
|
data: result,
|
|
269
669
|
operation: 'encrypt'
|
|
270
670
|
});
|
|
@@ -275,15 +675,15 @@ export async function runCli(argv = process.argv) {
|
|
|
275
675
|
const result = processConfig(config.data, {
|
|
276
676
|
mode: 'decrypt',
|
|
277
677
|
key,
|
|
278
|
-
|
|
279
|
-
|
|
678
|
+
paths: options.paths,
|
|
679
|
+
pathPatterns: options.pathPatterns
|
|
280
680
|
});
|
|
281
681
|
handleWrite({
|
|
282
682
|
dryRun: options.dryRun,
|
|
283
683
|
file,
|
|
684
|
+
absolutePath,
|
|
284
685
|
outputPath,
|
|
285
|
-
|
|
286
|
-
originalRaw: config.raw,
|
|
686
|
+
config,
|
|
287
687
|
data: result,
|
|
288
688
|
operation: 'decrypt'
|
|
289
689
|
});
|
|
@@ -293,7 +693,17 @@ export async function runCli(argv = process.argv) {
|
|
|
293
693
|
print(getHelpText().trim());
|
|
294
694
|
return fail('ERR_UNKNOWN_COMMAND', `Unknown command: ${command}`);
|
|
295
695
|
} catch (error) {
|
|
296
|
-
|
|
696
|
+
const structuredCode = typeof error.code === 'string' && error.code.startsWith('ERR_')
|
|
697
|
+
? error.code
|
|
698
|
+
: null;
|
|
699
|
+
const isMigrationCode = structuredCode === 'ERR_INVALID_PATH_PATTERNS' ||
|
|
700
|
+
structuredCode?.startsWith('ERR_MIGRATION_');
|
|
701
|
+
const code = command === 'migrate'
|
|
702
|
+
? isMigrationCode
|
|
703
|
+
? structuredCode
|
|
704
|
+
: 'ERR_MIGRATION_FAILED'
|
|
705
|
+
: structuredCode ?? 'ERR_PROCESS_FAILED';
|
|
706
|
+
return fail(code, `Operation failed: ${error.message}`);
|
|
297
707
|
}
|
|
298
708
|
}
|
|
299
709
|
|