onesecret 0.0.0-stage → 0.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/src/sops.js ADDED
@@ -0,0 +1,434 @@
1
+ 'use strict'
2
+
3
+ const crypto = require('node:crypto')
4
+ const age = require('./age')
5
+ const { keysOf } = require('./yaml')
6
+ const { goRegExp } = require('./regexp')
7
+ const { OnesecretError } = require('./error')
8
+
9
+ const SOPS_VERSION = '3.13.3'
10
+ const METADATA_KEY = 'sops'
11
+ const DEFAULT_RULES = { unencrypted_suffix: '_unencrypted' }
12
+ const RULE_KEYS = [
13
+ 'unencrypted_suffix',
14
+ 'encrypted_suffix',
15
+ 'unencrypted_regex',
16
+ 'encrypted_regex',
17
+ 'mac_only_encrypted'
18
+ ]
19
+ const DATA_KEY_SIZE = 32
20
+ const IV_SIZE = 32
21
+ const TAG_SIZE = 16
22
+ const ENCRYPTED_VALUE =
23
+ /^ENC\[AES256_GCM,data:(.+),iv:(.+),tag:(.+),type:(.+)\]$/
24
+ const CANONICAL_BASE64 =
25
+ /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
26
+ const MAC_ONLY_ENCRYPTED_PREFIX = crypto
27
+ .createHash('sha256')
28
+ .update('sops')
29
+ .digest()
30
+ const COMMENT_RULE_KEYS = [
31
+ 'unencrypted_comment_regex',
32
+ 'encrypted_comment_regex'
33
+ ]
34
+ const OTHER_KEY_SOURCES = ['kms', 'gcp_kms', 'azure_kv', 'hc_vault', 'pgp']
35
+
36
+ const isMapping = value =>
37
+ value !== null &&
38
+ typeof value === 'object' &&
39
+ [Object.prototype, null].includes(Object.getPrototypeOf(value))
40
+
41
+ const mapLeaves = (node, path, transform, orderOf) => {
42
+ if (Array.isArray(node)) {
43
+ return node.map(item => mapLeaves(item, path, transform, orderOf))
44
+ }
45
+ if (isMapping(node)) {
46
+ return Object.fromEntries(
47
+ orderOf(node).map(key => [
48
+ key,
49
+ mapLeaves(node[key], [...path, key], transform, orderOf)
50
+ ])
51
+ )
52
+ }
53
+ return transform(node, path)
54
+ }
55
+
56
+ const rulesOf = metadata =>
57
+ Object.fromEntries(
58
+ RULE_KEYS.filter(key => metadata[key] !== undefined).map(key => [
59
+ key,
60
+ metadata[key]
61
+ ])
62
+ )
63
+
64
+ const isEncryptedPath = (path, rules) => {
65
+ let encrypted = true
66
+ if (
67
+ rules.unencrypted_suffix &&
68
+ path.some(key => key.endsWith(rules.unencrypted_suffix))
69
+ ) {
70
+ encrypted = false
71
+ }
72
+ if (rules.encrypted_suffix) {
73
+ encrypted = path.some(key => key.endsWith(rules.encrypted_suffix))
74
+ }
75
+ if (
76
+ rules.unencrypted_regex &&
77
+ path.some(key => goRegExp(rules.unencrypted_regex).test(key))
78
+ ) {
79
+ encrypted = false
80
+ }
81
+ if (rules.encrypted_regex) {
82
+ encrypted = path.some(key => goRegExp(rules.encrypted_regex).test(key))
83
+ }
84
+ return encrypted
85
+ }
86
+
87
+ const assertSupportedRules = metadata => {
88
+ const commentRule = COMMENT_RULE_KEYS.find(key => metadata[key])
89
+ if (commentRule) {
90
+ throw new OnesecretError(
91
+ `${commentRule} depends on YAML comments and is not supported, use sops`,
92
+ 'EFORMAT'
93
+ )
94
+ }
95
+ }
96
+
97
+ const otherKeySources = metadata =>
98
+ OTHER_KEY_SOURCES.filter(
99
+ key => Array.isArray(metadata[key]) && metadata[key].length > 0
100
+ )
101
+
102
+ const macHash = rules => {
103
+ const hash = crypto.createHash('sha512')
104
+ if (rules.mac_only_encrypted) hash.update(MAC_ONLY_ENCRYPTED_PREFIX)
105
+ return hash
106
+ }
107
+
108
+ const isBlank = value => value === null || value === ''
109
+
110
+ const formatFloat = number => {
111
+ if (Number.isNaN(number)) return 'NaN'
112
+ if (!Number.isFinite(number)) return number > 0 ? '+Inf' : '-Inf'
113
+ if (Object.is(number, -0)) return '-0'
114
+ const [mantissa, exponentText] = String(number).split('e')
115
+ if (exponentText === undefined) return mantissa
116
+ const sign = mantissa.startsWith('-') ? '-' : ''
117
+ const [whole, fraction = ''] = mantissa.replace('-', '').split('.')
118
+ const digits = whole + fraction
119
+ const point = whole.length + Number(exponentText)
120
+ if (point <= 0) return `${sign}0.${'0'.repeat(-point)}${digits}`
121
+ if (point >= digits.length) {
122
+ return sign + digits + '0'.repeat(point - digits.length)
123
+ }
124
+ return `${sign}${digits.slice(0, point)}.${digits.slice(point)}`
125
+ }
126
+
127
+ const typeOf = value => {
128
+ if (typeof value === 'string') return 'str'
129
+ if (typeof value === 'boolean') return 'bool'
130
+ if (typeof value === 'bigint') return 'int'
131
+ if (typeof value === 'number') {
132
+ return Number.isSafeInteger(value) && !Object.is(value, -0)
133
+ ? 'int'
134
+ : 'float'
135
+ }
136
+ if (Buffer.isBuffer(value)) return 'bytes'
137
+ throw new OnesecretError(
138
+ `cannot encrypt a value of type ${typeof value}`,
139
+ 'EVALUE'
140
+ )
141
+ }
142
+
143
+ const SERIALIZERS = {
144
+ str: value => Buffer.from(value),
145
+ bool: value => Buffer.from(value ? 'True' : 'False'),
146
+ int: value => Buffer.from(String(value)),
147
+ float: value => Buffer.from(formatFloat(value)),
148
+ bytes: value => value
149
+ }
150
+
151
+ const parseInteger = text => {
152
+ const value = BigInt(text)
153
+ return value >= Number.MIN_SAFE_INTEGER && value <= Number.MAX_SAFE_INTEGER
154
+ ? Number(value)
155
+ : value
156
+ }
157
+
158
+ const COMMENT = Symbol('onesecret.comment')
159
+ const INT64_MIN = -(2n ** 63n)
160
+ const INT64_MAX = 2n ** 63n - 1n
161
+
162
+ const PARSERS = {
163
+ comment: () => COMMENT,
164
+ str: bytes => bytes.toString(),
165
+ time: bytes => bytes.toString(),
166
+ bool: bytes => ['true', '1', 't'].includes(bytes.toString().toLowerCase()),
167
+ int: bytes => parseInteger(bytes.toString()),
168
+ float: bytes => {
169
+ const text = bytes.toString()
170
+ if (/^[-+]?inf(inity)?$/i.test(text)) {
171
+ return text.startsWith('-') ? -Infinity : Infinity
172
+ }
173
+ return /^nan$/i.test(text) ? NaN : Number.parseFloat(text)
174
+ },
175
+ bytes: bytes => bytes
176
+ }
177
+
178
+ const serialize = value =>
179
+ isBlank(value) ? Buffer.alloc(0) : SERIALIZERS[typeOf(value)](value)
180
+
181
+ const additionalData = path => `${path.join(':')}:`
182
+
183
+ const isOutOfInt64 = value =>
184
+ typeof value === 'bigint' && (value < INT64_MIN || value > INT64_MAX)
185
+
186
+ const encryptValue = (value, dataKey, aad) => {
187
+ const type = typeOf(value)
188
+ const iv = crypto.randomBytes(IV_SIZE)
189
+ const cipher = crypto.createCipheriv('aes-256-gcm', dataKey, iv)
190
+ cipher.setAAD(Buffer.from(aad))
191
+ const data = Buffer.concat([
192
+ cipher.update(SERIALIZERS[type](value)),
193
+ cipher.final()
194
+ ])
195
+ const tag = cipher.getAuthTag()
196
+ return `ENC[AES256_GCM,data:${data.toString('base64')},iv:${iv.toString(
197
+ 'base64'
198
+ )},tag:${tag.toString('base64')},type:${type}]`
199
+ }
200
+
201
+ const openValue = (value, dataKey, aad) => {
202
+ if (isBlank(value)) return { value, plaintext: Buffer.alloc(0) }
203
+ const match = typeof value === 'string' && ENCRYPTED_VALUE.exec(value)
204
+ if (!match) {
205
+ throw new OnesecretError(`value at "${aad}" is not encrypted`, 'EFORMAT')
206
+ }
207
+ const [, data, ivText, tagText, type] = match
208
+ if (!PARSERS[type]) {
209
+ throw new OnesecretError(
210
+ `value at "${aad}" has unknown type "${type}"`,
211
+ 'EFORMAT'
212
+ )
213
+ }
214
+ if (![data, ivText, tagText].every(text => CANONICAL_BASE64.test(text))) {
215
+ throw new OnesecretError(
216
+ `value at "${aad}" has malformed base64`,
217
+ 'EFORMAT'
218
+ )
219
+ }
220
+ const iv = Buffer.from(ivText, 'base64')
221
+ const tag = Buffer.from(tagText, 'base64')
222
+ if (iv.length !== IV_SIZE || tag.length !== TAG_SIZE) {
223
+ throw new OnesecretError(
224
+ `value at "${aad}" has a malformed iv or tag`,
225
+ 'EFORMAT'
226
+ )
227
+ }
228
+ let plaintext
229
+ try {
230
+ const decipher = crypto.createDecipheriv('aes-256-gcm', dataKey, iv, {
231
+ authTagLength: TAG_SIZE
232
+ })
233
+ decipher.setAAD(Buffer.from(aad))
234
+ decipher.setAuthTag(tag)
235
+ plaintext = Buffer.concat([
236
+ decipher.update(Buffer.from(data, 'base64')),
237
+ decipher.final()
238
+ ])
239
+ } catch {
240
+ throw new OnesecretError(`value at "${aad}" failed to authenticate`, 'EMAC')
241
+ }
242
+ return { value: PARSERS[type](plaintext), plaintext }
243
+ }
244
+
245
+ const openDataKey = (enc, identities) => {
246
+ try {
247
+ return age.decrypt(enc, identities)
248
+ } catch (error) {
249
+ throw new OnesecretError(
250
+ `age data key is malformed: ${error.message}`,
251
+ 'EFORMAT'
252
+ )
253
+ }
254
+ }
255
+
256
+ const sealDataKey = (dataKey, recipient) => {
257
+ try {
258
+ return age.encrypt(dataKey, [recipient])
259
+ } catch (error) {
260
+ throw new OnesecretError(error.message, 'EKEY')
261
+ }
262
+ }
263
+
264
+ const unwrapDataKey = (metadata, identities) => {
265
+ if (metadata.key_groups || metadata.shamir_threshold) {
266
+ throw new OnesecretError(
267
+ 'key_groups are not supported, use a flat list of age recipients',
268
+ 'EFORMAT'
269
+ )
270
+ }
271
+ const entries = metadata.age || []
272
+ if (entries.length === 0) {
273
+ throw new OnesecretError('file has no age recipients', 'EFORMAT')
274
+ }
275
+ for (const { enc } of entries) {
276
+ const dataKey = openDataKey(enc, identities)
277
+ if (dataKey) {
278
+ if (dataKey.length !== DATA_KEY_SIZE) {
279
+ throw new OnesecretError('data key has the wrong size', 'EFORMAT')
280
+ }
281
+ return dataKey
282
+ }
283
+ }
284
+ const recipients = entries.map(({ recipient }) => recipient).join(', ')
285
+ throw new OnesecretError(
286
+ `no age identity matches the file recipients (${recipients})`,
287
+ 'EKEY'
288
+ )
289
+ }
290
+
291
+ const metadataOf = document => {
292
+ const metadata = isMapping(document) ? document[METADATA_KEY] : undefined
293
+ if (!isMapping(metadata)) {
294
+ throw new OnesecretError('file is not encrypted with sops', 'EFORMAT')
295
+ }
296
+ return metadata
297
+ }
298
+
299
+ const names = document => {
300
+ metadataOf(document)
301
+ return keysOf(document).filter(key => key !== METADATA_KEY)
302
+ }
303
+
304
+ const valueTypeOf = value => {
305
+ const encrypted =
306
+ typeof value === 'string' ? ENCRYPTED_VALUE.exec(value) : null
307
+ if (encrypted) return encrypted[4]
308
+ if (value === null) return 'null'
309
+ if (isMapping(value)) return 'mapping'
310
+ if (Array.isArray(value)) return 'list'
311
+ return typeOf(value)
312
+ }
313
+
314
+ const valueTypes = document =>
315
+ Object.fromEntries(
316
+ names(document).map(name => [name, valueTypeOf(document[name])])
317
+ )
318
+
319
+ const recipientsOf = document =>
320
+ (metadataOf(document).age || []).map(({ recipient }) => recipient)
321
+
322
+ const withoutComments = node => {
323
+ if (Array.isArray(node)) {
324
+ return node.filter(item => item !== COMMENT).map(withoutComments)
325
+ }
326
+ if (isMapping(node)) {
327
+ return Object.fromEntries(
328
+ Object.entries(node).map(([key, value]) => [key, withoutComments(value)])
329
+ )
330
+ }
331
+ return node
332
+ }
333
+
334
+ const decrypt = (document, identities) => {
335
+ const metadata = metadataOf(document)
336
+ assertSupportedRules(metadata)
337
+ const dataKey = unwrapDataKey(metadata, identities)
338
+ const rules = rulesOf(metadata)
339
+ const hash = macHash(rules)
340
+ const tree = mapLeaves(
341
+ document,
342
+ [],
343
+ (value, path) => {
344
+ if (!isEncryptedPath(path, rules)) {
345
+ if (!rules.mac_only_encrypted) hash.update(serialize(value))
346
+ return value
347
+ }
348
+ const opened = openValue(value, dataKey, additionalData(path))
349
+ if (opened.value === COMMENT) return COMMENT
350
+ hash.update(opened.plaintext)
351
+ return opened.value
352
+ },
353
+ node =>
354
+ keysOf(node).filter(key => node !== document || key !== METADATA_KEY)
355
+ )
356
+ const { value: expectedMac } = openValue(
357
+ metadata.mac,
358
+ dataKey,
359
+ String(metadata.lastmodified)
360
+ )
361
+ const actualMac = hash.digest('hex').toUpperCase()
362
+ if (typeof expectedMac !== 'string' || expectedMac !== actualMac) {
363
+ throw new OnesecretError(
364
+ 'file MAC does not match its content, it was modified without the key',
365
+ 'EMAC'
366
+ )
367
+ }
368
+ return withoutComments(tree)
369
+ }
370
+
371
+ const encrypt = (tree, { recipients, rules = DEFAULT_RULES }) => {
372
+ if (METADATA_KEY in tree) {
373
+ throw new OnesecretError(
374
+ `"${METADATA_KEY}" is reserved for the file metadata`,
375
+ 'EVALUE'
376
+ )
377
+ }
378
+ if (recipients.length === 0) {
379
+ throw new OnesecretError('at least one age recipient is required', 'EKEY')
380
+ }
381
+ const dataKey = crypto.randomBytes(DATA_KEY_SIZE)
382
+ const hash = macHash(rules)
383
+ const encrypted = mapLeaves(
384
+ tree,
385
+ [],
386
+ (value, path) => {
387
+ const shouldEncrypt = isEncryptedPath(path, rules)
388
+ if (isOutOfInt64(value)) {
389
+ throw new OnesecretError(
390
+ `value at "${additionalData(
391
+ path
392
+ )}" is outside the 64-bit integer range sops can read, quote it`,
393
+ 'EVALUE'
394
+ )
395
+ }
396
+ if (shouldEncrypt || !rules.mac_only_encrypted) {
397
+ hash.update(serialize(value))
398
+ }
399
+ if (!shouldEncrypt || isBlank(value)) return value
400
+ return encryptValue(value, dataKey, additionalData(path))
401
+ },
402
+ Object.keys
403
+ )
404
+ const lastmodified = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')
405
+ return {
406
+ ...encrypted,
407
+ [METADATA_KEY]: {
408
+ age: recipients.map(recipient => ({
409
+ enc: sealDataKey(dataKey, recipient),
410
+ recipient
411
+ })),
412
+ lastmodified,
413
+ mac: encryptValue(
414
+ hash.digest('hex').toUpperCase(),
415
+ dataKey,
416
+ lastmodified
417
+ ),
418
+ ...rules,
419
+ version: SOPS_VERSION
420
+ }
421
+ }
422
+ }
423
+
424
+ module.exports = {
425
+ DEFAULT_RULES,
426
+ decrypt,
427
+ encrypt,
428
+ isEncryptedPath,
429
+ names,
430
+ otherKeySources,
431
+ recipientsOf,
432
+ rulesOf,
433
+ valueTypes
434
+ }
package/src/store.js ADDED
@@ -0,0 +1,257 @@
1
+ 'use strict'
2
+
3
+ const fs = require('node:fs')
4
+ const path = require('node:path')
5
+ const yaml = require('./yaml')
6
+ const jsYaml = require('js-yaml')
7
+ const { rulesOf } = require('./sops')
8
+ const { goRegExp } = require('./regexp')
9
+ const { OnesecretError } = require('./error')
10
+
11
+ const DEFAULT_LAYER = 'default'
12
+ const EXTENSIONS = ['.enc.yml', '.enc.yaml', '.enc.json']
13
+ const PLAIN_EXTENSIONS = ['.yml', '.yaml', '.json']
14
+ const ENCRYPTED_FILE = /^(.+)\.enc\.(ya?ml|json)$/
15
+ const PLAIN_FILE = /^(.+)\.(ya?ml|json)$/
16
+ const SOPS_CONFIG = '.sops.yaml'
17
+
18
+ const isJson = file => file.endsWith('.json')
19
+
20
+ const stringify = (file, document) =>
21
+ isJson(file) ? yaml.dumpJson(document) : yaml.dump(document)
22
+
23
+ const layersFor = env =>
24
+ env && env !== DEFAULT_LAYER ? [DEFAULT_LAYER, env] : [DEFAULT_LAYER]
25
+
26
+ const namesIn = dir => {
27
+ try {
28
+ return new Set(fs.readdirSync(dir))
29
+ } catch {
30
+ return new Set()
31
+ }
32
+ }
33
+
34
+ const assertExactCase = (names, wanted, dir) => {
35
+ const byLowercase = new Map(wanted.map(name => [name.toLowerCase(), name]))
36
+ for (const actual of names) {
37
+ const name = byLowercase.get(actual.toLowerCase())
38
+ if (name && actual !== name) {
39
+ throw new OnesecretError(
40
+ `${actual} in ${dir} differs from ${name} only in letter case, rename it to ${name}`,
41
+ 'EFORMAT'
42
+ )
43
+ }
44
+ }
45
+ }
46
+
47
+ const findFile = (dir, layer, extensions) => {
48
+ const names = namesIn(dir)
49
+ assertExactCase(
50
+ names,
51
+ extensions.map(extension => layer + extension),
52
+ dir
53
+ )
54
+ const found = extensions
55
+ .map(extension => layer + extension)
56
+ .filter(name => names.has(name))
57
+ .map(name => path.join(dir, name))
58
+ if (found.length > 1) {
59
+ throw new OnesecretError(
60
+ `${found
61
+ .map(file => path.basename(file))
62
+ .join(' and ')} both hold the ${layer} layer in ${dir}, keep one`,
63
+ 'EFORMAT'
64
+ )
65
+ }
66
+ return found[0]
67
+ }
68
+
69
+ const findLayerFile = (dir, layer) => findFile(dir, layer, EXTENSIONS)
70
+
71
+ const findPlainFile = (dir, layer) => findFile(dir, layer, PLAIN_EXTENSIONS)
72
+
73
+ const layerOfFile = file => {
74
+ const name = path.basename(file)
75
+ return (ENCRYPTED_FILE.exec(name) || PLAIN_FILE.exec(name))[1]
76
+ }
77
+
78
+ const encryptedFilesIn = dir =>
79
+ [...namesIn(dir)]
80
+ .filter(name => ENCRYPTED_FILE.test(name))
81
+ .sort()
82
+ .map(name => path.join(dir, name))
83
+
84
+ const fileKindOf = file => {
85
+ const name = path.basename(file)
86
+ if (ENCRYPTED_FILE.test(name)) return 'encrypted'
87
+ if (PLAIN_FILE.test(name)) return 'plain'
88
+ return null
89
+ }
90
+
91
+ const layerFileOrNew = (dir, layer) =>
92
+ findLayerFile(dir, layer) || path.join(dir, layer + EXTENSIONS[0])
93
+
94
+ const parseErrorOf = error => {
95
+ if (error.mark) {
96
+ return `invalid YAML at line ${error.mark.line + 1}, column ${
97
+ error.mark.column + 1
98
+ }`
99
+ }
100
+ if (error instanceof SyntaxError) return 'not valid JSON'
101
+ return error.code || error.message
102
+ }
103
+
104
+ const readDocument = file => {
105
+ try {
106
+ return yaml.parse(fs.readFileSync(file, 'utf8'))
107
+ } catch (error) {
108
+ throw new OnesecretError(
109
+ `cannot read ${file}: ${parseErrorOf(error)}`,
110
+ 'EFORMAT'
111
+ )
112
+ }
113
+ }
114
+
115
+ const isMapping = value =>
116
+ value !== null && typeof value === 'object' && !Array.isArray(value)
117
+
118
+ const onlyDocument = (file, text) => {
119
+ const documents = jsYaml.loadAll(text)
120
+ if (documents.length > 1) {
121
+ throw new OnesecretError(
122
+ `${file} must hold a single YAML document`,
123
+ 'EFORMAT'
124
+ )
125
+ }
126
+ return documents[0]
127
+ }
128
+
129
+ const parsePlain = (file, text) => {
130
+ if (!isJson(file)) return onlyDocument(file, text)
131
+ return text.trim() === '' ? null : JSON.parse(text)
132
+ }
133
+
134
+ const readPlain = file => {
135
+ let values
136
+ try {
137
+ values = parsePlain(file, fs.readFileSync(file, 'utf8'))
138
+ } catch (error) {
139
+ if (error instanceof OnesecretError) throw error
140
+ throw new OnesecretError(
141
+ `cannot read ${file}: ${parseErrorOf(error)}`,
142
+ 'EFORMAT'
143
+ )
144
+ }
145
+ if (values === undefined || values === null) return {}
146
+ if (!isMapping(values)) {
147
+ throw new OnesecretError(
148
+ `${file} must be a mapping of NAME: value`,
149
+ 'EFORMAT'
150
+ )
151
+ }
152
+ if (isMapping(values.sops) && values.sops.mac) {
153
+ throw new OnesecretError(
154
+ `${file} is encrypted with sops, rename it to ${path
155
+ .basename(file)
156
+ .replace(PLAIN_FILE, '$1.enc.$2')}`,
157
+ 'EFORMAT'
158
+ )
159
+ }
160
+ return values
161
+ }
162
+
163
+ const modeOf = file => {
164
+ try {
165
+ return fs.statSync(file).mode & 0o777
166
+ } catch {
167
+ return null
168
+ }
169
+ }
170
+
171
+ const realPathOf = file => {
172
+ try {
173
+ return fs.realpathSync(file)
174
+ } catch {
175
+ return file
176
+ }
177
+ }
178
+
179
+ const writeText = (link, text) => {
180
+ const file = realPathOf(link)
181
+ fs.mkdirSync(path.dirname(file), { recursive: true })
182
+ const mode = modeOf(file)
183
+ const temporary = `${file}.${process.pid}.tmp`
184
+ fs.writeFileSync(temporary, text)
185
+ if (mode !== null) fs.chmodSync(temporary, mode)
186
+ fs.renameSync(temporary, file)
187
+ }
188
+
189
+ const writeDocument = (file, document) =>
190
+ writeText(file, stringify(file, document))
191
+
192
+ const readSopsConfig = file => {
193
+ try {
194
+ return (
195
+ jsYaml.load(fs.readFileSync(file, 'utf8'), {
196
+ schema: jsYaml.CORE_SCHEMA
197
+ }) || {}
198
+ )
199
+ } catch (error) {
200
+ throw new OnesecretError(
201
+ `cannot read ${file}: ${error.reason || error.message}`,
202
+ 'EFORMAT'
203
+ )
204
+ }
205
+ }
206
+
207
+ const findSopsConfig = from => {
208
+ for (let dir = path.resolve(from); ; dir = path.dirname(dir)) {
209
+ const candidate = path.join(dir, SOPS_CONFIG)
210
+ if (fs.existsSync(candidate)) return candidate
211
+ if (dir === path.dirname(dir)) return null
212
+ }
213
+ }
214
+
215
+ const splitRecipients = value =>
216
+ (Array.isArray(value) ? value : String(value).split(','))
217
+ .map(recipient => recipient.trim())
218
+ .filter(Boolean)
219
+
220
+ const creationRule = file => {
221
+ const configFile = findSopsConfig(path.dirname(file))
222
+ if (!configFile) return null
223
+ const config = readSopsConfig(configFile)
224
+ const relative = path
225
+ .relative(path.dirname(configFile), file)
226
+ .split(path.sep)
227
+ .join('/')
228
+ const rule = (config.creation_rules || []).find(
229
+ ({ path_regex: pattern }) => !pattern || goRegExp(pattern).test(relative)
230
+ )
231
+ if (!rule || !rule.age) return null
232
+ const rules = rulesOf(rule)
233
+ return {
234
+ recipients: splitRecipients(rule.age),
235
+ rules: Object.keys(rules).length > 0 ? rules : undefined
236
+ }
237
+ }
238
+
239
+ module.exports = {
240
+ DEFAULT_LAYER,
241
+ SOPS_CONFIG,
242
+ creationRule,
243
+ encryptedFilesIn,
244
+ fileKindOf,
245
+ findLayerFile,
246
+ findPlainFile,
247
+ isMapping,
248
+ layerOfFile,
249
+ parseErrorOf,
250
+ layerFileOrNew,
251
+ layersFor,
252
+ readDocument,
253
+ readPlain,
254
+ stringify,
255
+ writeDocument,
256
+ writeText
257
+ }