| | | 1 | | import type { MapFileConfig } from './MapFileConfig.js'; |
| | | 2 | | |
| | | 3 | | export const MAP_FILE_SCHEMA_URL = |
| | 15 | 4 | | 'https://envilder.com/schema/map-file.v1.json'; |
| | | 5 | | |
| | | 6 | | /** |
| | | 7 | | * Where a secret is pushed to. A profile is deliberately absent: it is a |
| | | 8 | | * local credential choice and never belongs in a committed map file. |
| | | 9 | | */ |
| | | 10 | | export type MapFileTarget = { |
| | | 11 | | provider: string; |
| | | 12 | | vaultUrl?: string; |
| | | 13 | | }; |
| | | 14 | | |
| | | 15 | | export type MapFileUpdateDecision = |
| | | 16 | | | { kind: 'create'; content: string } |
| | | 17 | | | { kind: 'append'; content: string } |
| | | 18 | | | { kind: 'unchanged' } |
| | | 19 | | | { kind: 'conflict' } |
| | | 20 | | | { kind: 'invalidJson' } |
| | | 21 | | | { kind: 'targetMismatch'; fileTarget: MapFileTarget }; |
| | | 22 | | |
| | | 23 | | type Layout = { |
| | | 24 | | indent: string | number; |
| | | 25 | | newline: string; |
| | | 26 | | trailingNewline: boolean; |
| | | 27 | | }; |
| | | 28 | | |
| | 15 | 29 | | const DEFAULT_PROVIDER = 'aws'; |
| | 15 | 30 | | const DEFAULT_LAYOUT: Layout = { |
| | | 31 | | indent: 2, |
| | | 32 | | newline: '\n', |
| | | 33 | | trailingNewline: true, |
| | | 34 | | }; |
| | | 35 | | |
| | | 36 | | export function resolveMapFileTarget(config: MapFileConfig): MapFileTarget { |
| | 43 | 37 | | const provider = config.provider?.toLowerCase() || DEFAULT_PROVIDER; |
| | 43 | 38 | | if (provider === 'azure' && config.vaultUrl) { |
| | 12 | 39 | | return { provider, vaultUrl: config.vaultUrl }; |
| | | 40 | | } |
| | 31 | 41 | | return { provider }; |
| | | 42 | | } |
| | | 43 | | |
| | | 44 | | /** |
| | | 45 | | * Decides how recording `key` -> `secretPath` changes a map file. Pure: it |
| | | 46 | | * never touches the filesystem, so every outcome can be checked on strings. |
| | | 47 | | * |
| | | 48 | | * A file that targets somewhere else than the push is left alone, and that |
| | | 49 | | * outranks a conflicting mapping, because the mapping then describes another |
| | | 50 | | * secret store. |
| | | 51 | | */ |
| | | 52 | | export function decideMapFileUpdate( |
| | | 53 | | existingContent: string | null, |
| | | 54 | | key: string, |
| | | 55 | | secretPath: string, |
| | | 56 | | target: MapFileTarget, |
| | | 57 | | mapFile: string, |
| | | 58 | | ): MapFileUpdateDecision { |
| | 52 | 59 | | if (existingContent === null) { |
| | 20 | 60 | | const file = buildNewFile(key, secretPath, target, mapFile); |
| | 20 | 61 | | return { kind: 'create', content: serialize(file, DEFAULT_LAYOUT) }; |
| | | 62 | | } |
| | | 63 | | |
| | 32 | 64 | | const parsed = parseObject(existingContent); |
| | 32 | 65 | | if (parsed === undefined) { |
| | 3 | 66 | | return { kind: 'invalidJson' }; |
| | | 67 | | } |
| | | 68 | | |
| | 29 | 69 | | const fileTarget = resolveMapFileTarget(readConfig(parsed)); |
| | 29 | 70 | | if (!sameTarget(fileTarget, target)) { |
| | 6 | 71 | | return { kind: 'targetMismatch', fileTarget }; |
| | | 72 | | } |
| | | 73 | | |
| | 23 | 74 | | if (Object.hasOwn(parsed, key)) { |
| | 6 | 75 | | return parsed[key] === secretPath |
| | | 76 | | ? { kind: 'unchanged' } |
| | | 77 | | : { kind: 'conflict' }; |
| | | 78 | | } |
| | | 79 | | |
| | | 80 | | // fromEntries defines own properties, so a `__proto__` key stays data. |
| | 17 | 81 | | const updated = Object.fromEntries([ |
| | | 82 | | ...Object.entries(parsed), |
| | | 83 | | [key, secretPath], |
| | | 84 | | ]); |
| | 17 | 85 | | return { |
| | | 86 | | kind: 'append', |
| | | 87 | | content: serialize(updated, detectLayout(existingContent)), |
| | | 88 | | }; |
| | | 89 | | } |
| | | 90 | | |
| | | 91 | | /** |
| | | 92 | | * File names SchemaStore already associates with the map-file schema |
| | | 93 | | * (`envilder.json`, `envilder.*.json`), so editors validate them without a |
| | | 94 | | * `$schema` key. |
| | | 95 | | */ |
| | 15 | 96 | | const SCHEMA_STORE_FILE_NAME = /^envilder(?:\.[^.]+)*\.json$/; |
| | | 97 | | |
| | | 98 | | function buildNewFile( |
| | | 99 | | key: string, |
| | | 100 | | secretPath: string, |
| | | 101 | | target: MapFileTarget, |
| | | 102 | | mapFile: string, |
| | | 103 | | ): Record<string, unknown> { |
| | 20 | 104 | | const fileName = mapFile.split(/[\\/]/).pop() ?? mapFile; |
| | 20 | 105 | | const file: Record<string, unknown> = SCHEMA_STORE_FILE_NAME.test(fileName) |
| | | 106 | | ? {} |
| | | 107 | | : { $schema: MAP_FILE_SCHEMA_URL }; |
| | 20 | 108 | | if (target.provider !== DEFAULT_PROVIDER) { |
| | 3 | 109 | | file.$config = { |
| | | 110 | | provider: target.provider, |
| | | 111 | | ...(target.vaultUrl && { vaultUrl: target.vaultUrl }), |
| | | 112 | | }; |
| | | 113 | | } |
| | 20 | 114 | | file[key] = secretPath; |
| | 20 | 115 | | return file; |
| | | 116 | | } |
| | | 117 | | |
| | | 118 | | function parseObject(content: string): Record<string, unknown> | undefined { |
| | 32 | 119 | | try { |
| | 32 | 120 | | const value: unknown = JSON.parse(content); |
| | 32 | 121 | | return typeof value === 'object' && value !== null && !Array.isArray(value) |
| | | 122 | | ? (value as Record<string, unknown>) |
| | | 123 | | : undefined; |
| | | 124 | | } catch { |
| | 2 | 125 | | return undefined; |
| | | 126 | | } |
| | | 127 | | } |
| | | 128 | | |
| | | 129 | | function readConfig(parsed: Record<string, unknown>): MapFileConfig { |
| | 29 | 130 | | const config = parsed.$config; |
| | 29 | 131 | | return config && typeof config === 'object' ? (config as MapFileConfig) : {}; |
| | | 132 | | } |
| | | 133 | | |
| | | 134 | | function sameTarget(a: MapFileTarget, b: MapFileTarget): boolean { |
| | 29 | 135 | | return ( |
| | | 136 | | a.provider === b.provider && |
| | | 137 | | normalizeVaultUrl(a.vaultUrl) === normalizeVaultUrl(b.vaultUrl) |
| | | 138 | | ); |
| | | 139 | | } |
| | | 140 | | |
| | | 141 | | function normalizeVaultUrl(vaultUrl: string | undefined): string | undefined { |
| | 48 | 142 | | return vaultUrl?.trim().replace(/\/+$/, '').toLowerCase(); |
| | | 143 | | } |
| | | 144 | | |
| | | 145 | | function detectLayout(content: string): Layout { |
| | 17 | 146 | | const indent = /\n([ \t]+)\S/.exec(content)?.[1]; |
| | 17 | 147 | | return { |
| | | 148 | | indent: indent ?? DEFAULT_LAYOUT.indent, |
| | | 149 | | newline: content.includes('\r\n') ? '\r\n' : '\n', |
| | | 150 | | trailingNewline: /\n$/.test(content), |
| | | 151 | | }; |
| | | 152 | | } |
| | | 153 | | |
| | | 154 | | function serialize(value: Record<string, unknown>, layout: Layout): string { |
| | 37 | 155 | | const body = JSON.stringify(value, null, layout.indent).replace( |
| | | 156 | | /\n/g, |
| | | 157 | | layout.newline, |
| | | 158 | | ); |
| | 37 | 159 | | return layout.trailingNewline ? `${body}${layout.newline}` : body; |
| | | 160 | | } |