dotcrypt 0.0.0-stage → 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 +40 -0
- package/LICENSE +21 -0
- package/README.md +375 -2
- package/bin/cli.js +556 -0
- package/package.json +59 -4
- package/src/config.js +167 -0
- package/src/crypto.js +259 -0
- package/src/server.js +321 -0
- package/src/sync.js +100 -0
- package/src/utils.js +283 -0
- package/src/vault.js +419 -0
- package/ui/README.md +4 -0
- package/ui/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/ui/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/ui/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/ui/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/ui/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/ui/dist/assets/index-BlKnGv6b.js +9 -0
- package/ui/dist/assets/index-DL_3iL0a.css +2 -0
- package/ui/dist/index.html +14 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
import { CONFIG_FILENAME, DEFAULT_ENVIRONMENTS, findProjectRoot, loadConfig } from './config.js';
|
|
9
|
+
import { ConfirmationRequiredError, VaultError, isWithin } from './utils.js';
|
|
10
|
+
import * as vault from './vault.js';
|
|
11
|
+
|
|
12
|
+
export const UI_DIST_DIR = fileURLToPath(new URL('../ui/dist/', import.meta.url));
|
|
13
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
14
|
+
|
|
15
|
+
export function isUiBuilt() {
|
|
16
|
+
return fs.existsSync(path.join(UI_DIST_DIR, 'index.html'));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const MIME_TYPES = {
|
|
20
|
+
'.html': 'text/html; charset=utf-8',
|
|
21
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
22
|
+
'.css': 'text/css; charset=utf-8',
|
|
23
|
+
'.json': 'application/json; charset=utf-8',
|
|
24
|
+
'.svg': 'image/svg+xml',
|
|
25
|
+
'.png': 'image/png',
|
|
26
|
+
'.ico': 'image/x-icon',
|
|
27
|
+
'.woff2': 'font/woff2',
|
|
28
|
+
'.woff': 'font/woff',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const SECURITY_HEADERS = {
|
|
32
|
+
'Content-Security-Policy': [
|
|
33
|
+
"default-src 'self'",
|
|
34
|
+
"script-src 'self'",
|
|
35
|
+
"style-src 'self' 'unsafe-inline'",
|
|
36
|
+
"img-src 'self' data:",
|
|
37
|
+
"font-src 'self' data:",
|
|
38
|
+
"connect-src 'self'",
|
|
39
|
+
"object-src 'none'",
|
|
40
|
+
"base-uri 'none'",
|
|
41
|
+
"form-action 'none'",
|
|
42
|
+
"frame-ancestors 'none'",
|
|
43
|
+
].join('; '),
|
|
44
|
+
'X-Content-Type-Options': 'nosniff',
|
|
45
|
+
'X-Frame-Options': 'DENY',
|
|
46
|
+
'Referrer-Policy': 'no-referrer',
|
|
47
|
+
'Cross-Origin-Opener-Policy': 'same-origin',
|
|
48
|
+
'Cross-Origin-Resource-Policy': 'same-origin',
|
|
49
|
+
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const ERROR_STATUS = {
|
|
53
|
+
NO_CONFIG: 404,
|
|
54
|
+
NOT_FOUND: 404,
|
|
55
|
+
DECRYPT_FAILED: 422,
|
|
56
|
+
INVALID_VAULT: 422,
|
|
57
|
+
WEAK_PASSWORD: 400,
|
|
58
|
+
INVALID_INPUT: 400,
|
|
59
|
+
CONFIRMATION_REQUIRED: 409,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
class HttpError extends Error {
|
|
63
|
+
constructor(status, message) {
|
|
64
|
+
super(message);
|
|
65
|
+
this.status = status;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function send(res, status, body, headers = {}) {
|
|
70
|
+
res.writeHead(status, { ...SECURITY_HEADERS, ...headers });
|
|
71
|
+
res.end(body);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function sendJson(res, status, payload) {
|
|
75
|
+
send(res, status, JSON.stringify(payload), {
|
|
76
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
77
|
+
'Cache-Control': 'no-store',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function readJsonBody(req) {
|
|
82
|
+
const type = (req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase();
|
|
83
|
+
if (type !== 'application/json') return Promise.reject(new HttpError(415, 'Content-Type must be application/json.'));
|
|
84
|
+
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
const chunks = [];
|
|
87
|
+
let size = 0;
|
|
88
|
+
req.on('data', (chunk) => {
|
|
89
|
+
size += chunk.length;
|
|
90
|
+
if (size > MAX_BODY_BYTES) {
|
|
91
|
+
reject(new HttpError(413, 'Request body too large.'));
|
|
92
|
+
req.destroy();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
chunks.push(chunk);
|
|
96
|
+
});
|
|
97
|
+
req.on('end', () => {
|
|
98
|
+
const raw = Buffer.concat(chunks);
|
|
99
|
+
try {
|
|
100
|
+
const parsed = raw.length ? JSON.parse(raw.toString('utf8')) : {};
|
|
101
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
102
|
+
throw new Error('not an object');
|
|
103
|
+
}
|
|
104
|
+
resolve(parsed);
|
|
105
|
+
} catch {
|
|
106
|
+
reject(new HttpError(400, 'Request body must be a JSON object.'));
|
|
107
|
+
} finally {
|
|
108
|
+
raw.fill(0); // body may contain passwords
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
req.on('error', reject);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function errorResponse(err) {
|
|
116
|
+
if (err instanceof ConfirmationRequiredError) {
|
|
117
|
+
return [409, { code: err.code, message: err.message, reason: err.reason, diff: err.diff, hint: err.hint }];
|
|
118
|
+
}
|
|
119
|
+
if (err instanceof VaultError) {
|
|
120
|
+
return [ERROR_STATUS[err.code] ?? 400, { code: err.code, message: err.message, hint: err.hint }];
|
|
121
|
+
}
|
|
122
|
+
if (err instanceof HttpError) return [err.status, { code: 'HTTP_ERROR', message: err.message }];
|
|
123
|
+
return [500, { code: 'INTERNAL_ERROR', message: 'Unexpected server error. See the terminal for details.' }];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const bool = (value) => value === true;
|
|
127
|
+
const str = (value) => (typeof value === 'string' ? value : undefined);
|
|
128
|
+
|
|
129
|
+
/** Opens a URL in the default browser without going through a shell. */
|
|
130
|
+
export function openBrowser(url) {
|
|
131
|
+
const [cmd, args] =
|
|
132
|
+
process.platform === 'win32'
|
|
133
|
+
? ['rundll32', ['url.dll,FileProtocolHandler', url]]
|
|
134
|
+
: process.platform === 'darwin'
|
|
135
|
+
? ['open', [url]]
|
|
136
|
+
: ['xdg-open', [url]];
|
|
137
|
+
const child = execFile(cmd, args, { windowsHide: true }, () => {});
|
|
138
|
+
child.on('error', () => {});
|
|
139
|
+
child.unref?.();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Starts the local dashboard server bound to 127.0.0.1.
|
|
144
|
+
*
|
|
145
|
+
* Security model:
|
|
146
|
+
* - Loopback-only listener; Host header allow-list blocks DNS-rebinding attacks.
|
|
147
|
+
* - Every API call needs a random per-session bearer token (constant-time compare).
|
|
148
|
+
* - Mutations require JSON + same-origin, so other websites cannot forge requests.
|
|
149
|
+
* - Responses never contain secret values; passwords are never logged.
|
|
150
|
+
*/
|
|
151
|
+
export function startUiServer({ cwd = process.cwd(), port = 0, onLog = () => {} } = {}) {
|
|
152
|
+
const projectDir = findProjectRoot(cwd) ?? path.resolve(cwd);
|
|
153
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
154
|
+
const tokenBytes = Buffer.from(token, 'utf8');
|
|
155
|
+
const allowedHosts = new Set();
|
|
156
|
+
|
|
157
|
+
const hasConfig = () => fs.existsSync(path.join(projectDir, CONFIG_FILENAME));
|
|
158
|
+
const context = () => {
|
|
159
|
+
const ctx = loadConfig(projectDir);
|
|
160
|
+
if (ctx.root !== projectDir) throw new VaultError(`No ${CONFIG_FILENAME} in ${projectDir}.`, { code: 'NO_CONFIG' });
|
|
161
|
+
return ctx;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const isAuthorized = (req) => {
|
|
165
|
+
const match = /^Bearer ([a-f0-9]{64})$/.exec(req.headers.authorization ?? '');
|
|
166
|
+
return Boolean(match) && crypto.timingSafeEqual(Buffer.from(match[1], 'utf8'), tokenBytes);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const isSameOrigin = (req) => {
|
|
170
|
+
const origin = req.headers.origin;
|
|
171
|
+
if (origin !== undefined && origin !== `http://${req.headers.host}`) return false;
|
|
172
|
+
const site = req.headers['sec-fetch-site'];
|
|
173
|
+
return site === undefined || site === 'same-origin' || site === 'none';
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const envActions = {
|
|
177
|
+
encrypt: (ctx, name, body) =>
|
|
178
|
+
vault.encryptEnvironment(ctx, name, str(body.password), {
|
|
179
|
+
overwrite: bool(body.overwrite),
|
|
180
|
+
replaceExisting: bool(body.replaceExisting),
|
|
181
|
+
}),
|
|
182
|
+
decrypt: (ctx, name, body) => vault.decryptEnvironment(ctx, name, str(body.password), { overwrite: bool(body.overwrite) }),
|
|
183
|
+
diff: (ctx, name, body) => vault.diffEnvironment(ctx, name, str(body.password)),
|
|
184
|
+
rotate: (ctx, name, body) => vault.rotateEnvironmentKey(ctx, name, str(body.oldPassword), str(body.newPassword)),
|
|
185
|
+
default: (ctx, name) => {
|
|
186
|
+
vault.setDefaultEnvironment(ctx, name);
|
|
187
|
+
return { status: 'ok', environment: name };
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
async function handleApi(req, res, pathname) {
|
|
192
|
+
if (!isAuthorized(req)) throw new HttpError(401, 'Invalid or missing session token. Reopen the URL printed in the terminal.');
|
|
193
|
+
|
|
194
|
+
if (req.method === 'GET' && pathname === '/api/status') {
|
|
195
|
+
if (!hasConfig()) {
|
|
196
|
+
return {
|
|
197
|
+
initialized: false,
|
|
198
|
+
root: projectDir,
|
|
199
|
+
configFile: CONFIG_FILENAME,
|
|
200
|
+
suggestedName: path.basename(projectDir),
|
|
201
|
+
defaultEnvironments: DEFAULT_ENVIRONMENTS,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
const ctx = context();
|
|
205
|
+
return {
|
|
206
|
+
initialized: true,
|
|
207
|
+
...vault.getVaultStatus(ctx),
|
|
208
|
+
gitignoreMissing: vault.repairGitignore(ctx, { dryRun: true }).gitignoreAdded,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (req.method !== 'POST') throw new HttpError(405, 'Method not allowed.');
|
|
213
|
+
if (!isSameOrigin(req)) throw new HttpError(403, 'Cross-origin requests are not allowed.');
|
|
214
|
+
const body = await readJsonBody(req);
|
|
215
|
+
|
|
216
|
+
if (pathname === '/api/init') {
|
|
217
|
+
const environments = Array.isArray(body.environments) ? body.environments.filter((e) => typeof e === 'string') : undefined;
|
|
218
|
+
const result = vault.initVault(projectDir, {
|
|
219
|
+
name: str(body.name),
|
|
220
|
+
environments,
|
|
221
|
+
defaultEnvironment: str(body.defaultEnvironment),
|
|
222
|
+
overwrite: bool(body.overwrite),
|
|
223
|
+
});
|
|
224
|
+
onLog(`init: created ${CONFIG_FILENAME}`);
|
|
225
|
+
return { status: 'created', gitignoreAdded: result.gitignoreAdded, warnings: result.warnings };
|
|
226
|
+
}
|
|
227
|
+
if (pathname === '/api/environments') {
|
|
228
|
+
const result = vault.addEnvironment(context(), {
|
|
229
|
+
name: str(body.name),
|
|
230
|
+
file: str(body.file),
|
|
231
|
+
encryptedFile: str(body.encryptedFile),
|
|
232
|
+
});
|
|
233
|
+
onLog(`add environment: ${body.name}`);
|
|
234
|
+
return { status: 'created', gitignoreAdded: result.gitignoreAdded };
|
|
235
|
+
}
|
|
236
|
+
if (pathname === '/api/gitignore') {
|
|
237
|
+
const result = vault.repairGitignore(context());
|
|
238
|
+
onLog('.gitignore repaired');
|
|
239
|
+
return result;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const match = /^\/api\/environments\/([^/]+)\/([a-z]+)$/.exec(pathname);
|
|
243
|
+
if (match && Object.hasOwn(envActions, match[2])) {
|
|
244
|
+
const name = decodeURIComponent(match[1]);
|
|
245
|
+
const action = match[2];
|
|
246
|
+
const result = envActions[action](context(), name, body);
|
|
247
|
+
onLog(`${action} ${name}: ${result.status ?? 'ok'}`);
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
250
|
+
throw new HttpError(404, 'Not found.');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function serveStatic(req, res, pathname) {
|
|
254
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'Method not allowed');
|
|
255
|
+
let decoded;
|
|
256
|
+
try {
|
|
257
|
+
decoded = decodeURIComponent(pathname);
|
|
258
|
+
} catch {
|
|
259
|
+
return send(res, 400, 'Bad request');
|
|
260
|
+
}
|
|
261
|
+
let file = path.resolve(UI_DIST_DIR, `.${decoded}`);
|
|
262
|
+
if (!isWithin(UI_DIST_DIR, file)) return send(res, 404, 'Not found');
|
|
263
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) file = path.join(UI_DIST_DIR, 'index.html');
|
|
264
|
+
if (!fs.existsSync(file)) {
|
|
265
|
+
return send(res, 503, 'Dashboard assets are not built. Run "npm run build:ui" in the dotcrypt package.', {
|
|
266
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const type = MIME_TYPES[path.extname(file).toLowerCase()] ?? 'application/octet-stream';
|
|
271
|
+
const immutable = decoded.startsWith('/assets/');
|
|
272
|
+
send(res, 200, req.method === 'HEAD' ? undefined : fs.readFileSync(file), {
|
|
273
|
+
'Content-Type': type,
|
|
274
|
+
'Cache-Control': immutable ? 'public, max-age=31536000, immutable' : 'no-store',
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const server = http.createServer(async (req, res) => {
|
|
279
|
+
try {
|
|
280
|
+
if (!allowedHosts.has(req.headers.host ?? '')) return send(res, 421, 'Misdirected request');
|
|
281
|
+
const { pathname } = new URL(req.url ?? '/', `http://${req.headers.host}`);
|
|
282
|
+
|
|
283
|
+
if (pathname === '/api' || pathname.startsWith('/api/')) {
|
|
284
|
+
try {
|
|
285
|
+
sendJson(res, 200, await handleApi(req, res, pathname));
|
|
286
|
+
} catch (err) {
|
|
287
|
+
const [status, payload] = errorResponse(err);
|
|
288
|
+
if (status === 500) onLog(`error: ${err.stack ?? err.message}`);
|
|
289
|
+
sendJson(res, status, { error: payload });
|
|
290
|
+
}
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
return serveStatic(req, res, pathname);
|
|
294
|
+
} catch (err) {
|
|
295
|
+
onLog(`error: ${err.message}`);
|
|
296
|
+
if (!res.headersSent) send(res, 500, 'Internal error');
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
return new Promise((resolve, reject) => {
|
|
302
|
+
server.once('error', reject);
|
|
303
|
+
server.listen(port, '127.0.0.1', () => {
|
|
304
|
+
const actualPort = server.address().port;
|
|
305
|
+
allowedHosts.add(`127.0.0.1:${actualPort}`);
|
|
306
|
+
allowedHosts.add(`localhost:${actualPort}`);
|
|
307
|
+
resolve({
|
|
308
|
+
server,
|
|
309
|
+
port: actualPort,
|
|
310
|
+
projectDir,
|
|
311
|
+
token,
|
|
312
|
+
url: `http://127.0.0.1:${actualPort}/?token=${token}`,
|
|
313
|
+
close: () =>
|
|
314
|
+
new Promise((done) => {
|
|
315
|
+
server.close(() => done());
|
|
316
|
+
server.closeAllConnections?.();
|
|
317
|
+
}),
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
}
|
package/src/sync.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { MAX_FILE_SIZE, gitOutput } from './utils.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Remembers, per machine, the fingerprints of the local env file and the vault file at the moment
|
|
9
|
+
* they were last known to match (after encrypt, decrypt, or an identical diff). Comparing the
|
|
10
|
+
* current files against that baseline tells us who changed what, without needing the password:
|
|
11
|
+
*
|
|
12
|
+
* local changed only -> you have changes to share
|
|
13
|
+
* vault changed only -> a teammate shared changes (after git pull)
|
|
14
|
+
* both changed -> conflict
|
|
15
|
+
*
|
|
16
|
+
* The state lives inside the Git directory (.git/dotcrypt/sync-state.json), so it is never
|
|
17
|
+
* committed and every clone or worktree tracks its own files.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const STATE_VERSION = 1;
|
|
21
|
+
|
|
22
|
+
export function fingerprint(data) {
|
|
23
|
+
return crypto.createHash('sha256').update(data).digest('hex');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** SHA-256 of a file, or null when it is missing, too large or unreadable. */
|
|
27
|
+
export function fingerprintFile(absPath) {
|
|
28
|
+
try {
|
|
29
|
+
if (fs.statSync(absPath).size > MAX_FILE_SIZE) return null;
|
|
30
|
+
const data = fs.readFileSync(absPath);
|
|
31
|
+
try {
|
|
32
|
+
return fingerprint(data);
|
|
33
|
+
} finally {
|
|
34
|
+
data.fill(0);
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function locateState(root) {
|
|
42
|
+
const out = gitOutput(root, ['rev-parse', '--absolute-git-dir', '--show-prefix']);
|
|
43
|
+
if (!out) return null;
|
|
44
|
+
const [gitDir, prefix = ''] = out.split('\n');
|
|
45
|
+
if (!gitDir) return null;
|
|
46
|
+
return { file: path.join(gitDir, 'dotcrypt', 'sync-state.json'), project: prefix.replace(/\/$/, '') || '.' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readState(file) {
|
|
50
|
+
try {
|
|
51
|
+
const state = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
52
|
+
if (state?.version === STATE_VERSION && state.projects && typeof state.projects === 'object') return state;
|
|
53
|
+
} catch {
|
|
54
|
+
// Missing or corrupt state simply means "unknown"; it is rebuilt on the next sync.
|
|
55
|
+
}
|
|
56
|
+
return { version: STATE_VERSION, projects: {} };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Opens the sync state for a project root. All methods are no-ops outside a Git repository. */
|
|
60
|
+
export function openSyncState(root) {
|
|
61
|
+
const location = locateState(root);
|
|
62
|
+
const state = location ? readState(location.file) : null;
|
|
63
|
+
const entries = () => (state ? (state.projects[location.project] ?? {}) : {});
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
get(envName) {
|
|
67
|
+
const entry = Object.hasOwn(entries(), envName) ? entries()[envName] : null;
|
|
68
|
+
return entry && typeof entry.local === 'string' && typeof entry.vault === 'string' ? entry : null;
|
|
69
|
+
},
|
|
70
|
+
set(envName, { local, vault }) {
|
|
71
|
+
if (!state || !local || !vault) return;
|
|
72
|
+
state.projects[location.project] = { ...entries(), [envName]: { local, vault, syncedAt: new Date().toISOString() } };
|
|
73
|
+
try {
|
|
74
|
+
fs.mkdirSync(path.dirname(location.file), { recursive: true });
|
|
75
|
+
const tmp = `${location.file}.${process.pid}.tmp`;
|
|
76
|
+
fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
77
|
+
fs.renameSync(tmp, location.file);
|
|
78
|
+
} catch {
|
|
79
|
+
// Best effort: sync tracking must never make encrypt or decrypt fail.
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Plain-language sync state of one environment:
|
|
87
|
+
* empty | not-shared | not-downloaded | unknown | synced | local-changes | incoming-changes | conflict
|
|
88
|
+
*/
|
|
89
|
+
export function computeSync({ localExists, vaultExists, localHash, vaultHash, baseline }) {
|
|
90
|
+
if (!localExists && !vaultExists) return 'empty';
|
|
91
|
+
if (!vaultExists) return 'not-shared';
|
|
92
|
+
if (!localExists) return 'not-downloaded';
|
|
93
|
+
if (!baseline || !localHash || !vaultHash) return 'unknown';
|
|
94
|
+
const localChanged = localHash !== baseline.local;
|
|
95
|
+
const vaultChanged = vaultHash !== baseline.vault;
|
|
96
|
+
if (localChanged && vaultChanged) return 'conflict';
|
|
97
|
+
if (localChanged) return 'local-changes';
|
|
98
|
+
if (vaultChanged) return 'incoming-changes';
|
|
99
|
+
return 'synced';
|
|
100
|
+
}
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import dotenv from 'dotenv';
|
|
6
|
+
|
|
7
|
+
export const MAX_FILE_SIZE = 5 * 1024 * 1024;
|
|
8
|
+
export const GITIGNORE_HEADER = '# dotcrypt: never commit plaintext environment files';
|
|
9
|
+
export const BASE_GITIGNORE_RULES = Object.freeze(['.env', '.env.*', '!.env.*.enc']);
|
|
10
|
+
|
|
11
|
+
export class VaultError extends Error {
|
|
12
|
+
constructor(message, { exitCode = 1, hint, code = 'VAULT_ERROR' } = {}) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = 'VaultError';
|
|
15
|
+
this.exitCode = exitCode;
|
|
16
|
+
this.hint = hint;
|
|
17
|
+
this.code = code;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Raised when an operation would discard data and needs explicit approval.
|
|
23
|
+
* `reason` is one of: "overwrite", "password-mismatch", "unreadable-vault", "config-exists".
|
|
24
|
+
*/
|
|
25
|
+
export class ConfirmationRequiredError extends VaultError {
|
|
26
|
+
constructor(message, { reason, diff, hint } = {}) {
|
|
27
|
+
super(message, { code: 'CONFIRMATION_REQUIRED', hint });
|
|
28
|
+
this.name = 'ConfirmationRequiredError';
|
|
29
|
+
this.reason = reason;
|
|
30
|
+
this.diff = diff;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isWithin(parent, child) {
|
|
35
|
+
const rel = path.relative(parent, child);
|
|
36
|
+
return rel === '' || (rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolves a config-supplied relative path, rejecting absolute paths and traversal.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveInside(root, relativePath) {
|
|
43
|
+
if (typeof relativePath !== 'string' || relativePath.trim() === '' || relativePath.includes('\0')) {
|
|
44
|
+
throw new VaultError(`Invalid path: ${JSON.stringify(relativePath)}`);
|
|
45
|
+
}
|
|
46
|
+
if (path.isAbsolute(relativePath) || path.win32.isAbsolute(relativePath)) {
|
|
47
|
+
throw new VaultError(`Path must be relative to the project root: ${relativePath}`);
|
|
48
|
+
}
|
|
49
|
+
const base = path.resolve(root);
|
|
50
|
+
const resolved = path.resolve(base, relativePath);
|
|
51
|
+
if (resolved === base || !isWithin(base, resolved)) {
|
|
52
|
+
throw new VaultError(`Path escapes the project directory: ${relativePath}`);
|
|
53
|
+
}
|
|
54
|
+
return resolved;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function displayPath(root, target) {
|
|
58
|
+
return path.relative(root, target).split(path.sep).join('/');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function fileExists(target) {
|
|
62
|
+
try {
|
|
63
|
+
fs.lstatSync(target);
|
|
64
|
+
return true;
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (err.code === 'ENOENT') return false;
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Guards a path we are about to read or write: it must live inside the project,
|
|
73
|
+
* must not be a symlink, and no symlinked parent directory may lead outside the
|
|
74
|
+
* project. Prevents a malicious repo from redirecting writes (e.g. to ~/.bashrc)
|
|
75
|
+
* or reads (e.g. of ~/.ssh/id_rsa into a committed vault file).
|
|
76
|
+
*/
|
|
77
|
+
export function assertSafePath(root, target) {
|
|
78
|
+
const base = path.resolve(root);
|
|
79
|
+
const resolved = path.resolve(target);
|
|
80
|
+
const shown = displayPath(base, resolved);
|
|
81
|
+
if (resolved === base || !isWithin(base, resolved)) {
|
|
82
|
+
throw new VaultError(`Refusing to access ${resolved}: it is outside the project directory.`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let stat = null;
|
|
86
|
+
try {
|
|
87
|
+
stat = fs.lstatSync(resolved);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
if (err.code !== 'ENOENT') throw err;
|
|
90
|
+
}
|
|
91
|
+
if (stat?.isSymbolicLink()) throw new VaultError(`Refusing to use ${shown}: it is a symbolic link.`);
|
|
92
|
+
if (stat && !stat.isFile()) throw new VaultError(`Refusing to use ${shown}: it is not a regular file.`);
|
|
93
|
+
|
|
94
|
+
let dir = path.dirname(resolved);
|
|
95
|
+
while (!fs.existsSync(dir)) dir = path.dirname(dir);
|
|
96
|
+
if (!isWithin(fs.realpathSync.native(base), fs.realpathSync.native(dir))) {
|
|
97
|
+
throw new VaultError(`Refusing to use ${shown}: its directory resolves outside the project.`);
|
|
98
|
+
}
|
|
99
|
+
return resolved;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function safeReadFile(root, target, { maxSize = MAX_FILE_SIZE } = {}) {
|
|
103
|
+
const resolved = assertSafePath(root, target);
|
|
104
|
+
let stat;
|
|
105
|
+
try {
|
|
106
|
+
stat = fs.statSync(resolved);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
if (err.code === 'ENOENT') throw new VaultError(`File not found: ${displayPath(root, resolved)}`);
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
if (stat.size > maxSize) {
|
|
112
|
+
throw new VaultError(`${displayPath(root, resolved)} is larger than the ${maxSize} byte limit.`);
|
|
113
|
+
}
|
|
114
|
+
return fs.readFileSync(resolved);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Atomic write: data goes to an exclusive temp file in the same directory, is
|
|
119
|
+
* fsync'd, then renamed over the target. A crash never leaves a half-written file.
|
|
120
|
+
*/
|
|
121
|
+
export function safeWriteFile(root, target, data, { mode = 0o600 } = {}) {
|
|
122
|
+
const resolved = assertSafePath(root, target);
|
|
123
|
+
const dir = path.dirname(resolved);
|
|
124
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
125
|
+
assertSafePath(root, resolved);
|
|
126
|
+
|
|
127
|
+
// Temp name starts with the target's basename so it matches the same ignore rules.
|
|
128
|
+
const tmp = path.join(dir, `${path.basename(resolved)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
129
|
+
let fd;
|
|
130
|
+
try {
|
|
131
|
+
fd = fs.openSync(tmp, 'wx', mode);
|
|
132
|
+
fs.writeFileSync(fd, data);
|
|
133
|
+
fs.fsyncSync(fd);
|
|
134
|
+
fs.closeSync(fd);
|
|
135
|
+
fd = undefined;
|
|
136
|
+
fs.renameSync(tmp, resolved);
|
|
137
|
+
try {
|
|
138
|
+
fs.chmodSync(resolved, mode);
|
|
139
|
+
} catch {
|
|
140
|
+
// chmod is best-effort (no-op on some Windows filesystems).
|
|
141
|
+
}
|
|
142
|
+
} catch (err) {
|
|
143
|
+
if (fd !== undefined) {
|
|
144
|
+
try {
|
|
145
|
+
fs.closeSync(fd);
|
|
146
|
+
} catch {
|
|
147
|
+
// ignore
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
fs.rmSync(tmp, { force: true });
|
|
151
|
+
throw err;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function buffersEqual(a, b) {
|
|
156
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function toPosix(p) {
|
|
160
|
+
return p.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** True when BASE_GITIGNORE_RULES already ignore this plaintext path. */
|
|
164
|
+
export function isCoveredByBaseRules(relPath) {
|
|
165
|
+
const name = path.posix.basename(toPosix(relPath));
|
|
166
|
+
return name === '.env' || (name.startsWith('.env.') && !name.endsWith('.enc'));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Appends any missing ignore rules for plaintext env files to .gitignore.
|
|
171
|
+
* Never removes or rewrites existing user rules.
|
|
172
|
+
* @returns {string[]} rules that were added.
|
|
173
|
+
*/
|
|
174
|
+
export function ensureGitignore(root, plaintextFiles = [], { dryRun = false } = {}) {
|
|
175
|
+
const gitignorePath = path.join(root, '.gitignore');
|
|
176
|
+
const content = fileExists(gitignorePath) ? safeReadFile(root, gitignorePath).toString('utf8') : '';
|
|
177
|
+
const existing = new Set(content.split(/\r?\n/).map((line) => line.trim()));
|
|
178
|
+
|
|
179
|
+
const wanted = [
|
|
180
|
+
...BASE_GITIGNORE_RULES,
|
|
181
|
+
...plaintextFiles
|
|
182
|
+
.map(toPosix)
|
|
183
|
+
.filter((file) => !isCoveredByBaseRules(file))
|
|
184
|
+
.map((file) => `/${file}`),
|
|
185
|
+
];
|
|
186
|
+
const missing = [...new Set(wanted)].filter((rule) => !existing.has(rule));
|
|
187
|
+
if (missing.length === 0 || dryRun) return missing;
|
|
188
|
+
|
|
189
|
+
const eol = content.includes('\r\n') ? '\r\n' : '\n';
|
|
190
|
+
let block = '';
|
|
191
|
+
if (content.length > 0) block += content.endsWith('\n') ? eol : eol + eol;
|
|
192
|
+
if (!existing.has(GITIGNORE_HEADER)) block += GITIGNORE_HEADER + eol;
|
|
193
|
+
block += missing.join(eol) + eol;
|
|
194
|
+
|
|
195
|
+
safeWriteFile(root, gitignorePath, content + block, { mode: 0o644 });
|
|
196
|
+
return missing;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function runGit(root, args) {
|
|
200
|
+
try {
|
|
201
|
+
execFileSync('git', args, { cwd: root, stdio: 'ignore', windowsHide: true });
|
|
202
|
+
return 0;
|
|
203
|
+
} catch (err) {
|
|
204
|
+
return typeof err.status === 'number' ? err.status : null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Reports whether a path is tracked or ignored by Git. `inRepo` is false when
|
|
210
|
+
* Git is unavailable or root is not inside a work tree.
|
|
211
|
+
*/
|
|
212
|
+
export function gitStatusOf(root, relPath) {
|
|
213
|
+
if (runGit(root, ['rev-parse', '--is-inside-work-tree']) !== 0) return { inRepo: false };
|
|
214
|
+
const file = toPosix(relPath);
|
|
215
|
+
return {
|
|
216
|
+
inRepo: true,
|
|
217
|
+
tracked: runGit(root, ['ls-files', '--error-unmatch', '--', file]) === 0,
|
|
218
|
+
ignored: runGit(root, ['check-ignore', '-q', '--', file]) === 0,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Returns trimmed stdout of a Git command, or null when it fails or Git is unavailable. */
|
|
223
|
+
export function gitOutput(root, args) {
|
|
224
|
+
try {
|
|
225
|
+
return execFileSync('git', args, {
|
|
226
|
+
cwd: root,
|
|
227
|
+
encoding: 'utf8',
|
|
228
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
229
|
+
windowsHide: true,
|
|
230
|
+
}).trimEnd();
|
|
231
|
+
} catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Describes how far a committed file is from the team:
|
|
238
|
+
* - uncommitted: changed or new in the working tree
|
|
239
|
+
* - unpushed: committed locally but not on the upstream branch yet
|
|
240
|
+
* - behind: the upstream branch has a newer version (visible after a fetch)
|
|
241
|
+
* Each flag is false when unknown (no repo, no upstream).
|
|
242
|
+
*/
|
|
243
|
+
export function gitShareStatus(root, relPath) {
|
|
244
|
+
const file = toPosix(relPath);
|
|
245
|
+
const uncommitted = Boolean(gitOutput(root, ['status', '--porcelain', '--untracked-files=all', '--', file]));
|
|
246
|
+
const hasUpstream = gitOutput(root, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']) !== null;
|
|
247
|
+
if (!hasUpstream) return { uncommitted, unpushed: false, behind: false, hasUpstream };
|
|
248
|
+
const touched = (range) => Boolean(gitOutput(root, ['log', '-1', '--format=%H', range, '--', file]));
|
|
249
|
+
return { uncommitted, unpushed: touched('@{u}..HEAD'), behind: touched('HEAD..@{u}'), hasUpstream };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function parseEnv(content) {
|
|
253
|
+
return dotenv.parse(content);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Compares variable maps by key. Returns key names only, never values.
|
|
258
|
+
* "added" = only in local, "removed" = only in vault, "changed" = value differs.
|
|
259
|
+
*/
|
|
260
|
+
export function diffEnv(vaultVars, localVars) {
|
|
261
|
+
const added = [];
|
|
262
|
+
const removed = [];
|
|
263
|
+
const changed = [];
|
|
264
|
+
let unchanged = 0;
|
|
265
|
+
for (const key of Object.keys(localVars)) {
|
|
266
|
+
if (!Object.hasOwn(vaultVars, key)) added.push(key);
|
|
267
|
+
else if (vaultVars[key] !== localVars[key]) changed.push(key);
|
|
268
|
+
else unchanged += 1;
|
|
269
|
+
}
|
|
270
|
+
for (const key of Object.keys(vaultVars)) {
|
|
271
|
+
if (!Object.hasOwn(localVars, key)) removed.push(key);
|
|
272
|
+
}
|
|
273
|
+
return { added: added.sort(), removed: removed.sort(), changed: changed.sort(), unchanged };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function hasDifferences({ added, removed, changed }) {
|
|
277
|
+
return added.length + removed.length + changed.length > 0;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Strips control characters so decrypted key names cannot inject terminal escapes. */
|
|
281
|
+
export function sanitizeForTerminal(value) {
|
|
282
|
+
return String(value).replace(/[\u0000-\u001f\u007f-\u009f]/g, '?');
|
|
283
|
+
}
|