envstash-cli 0.5.2 → 0.6.1

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/context.mjs CHANGED
@@ -3,7 +3,7 @@ import { existsSync, readFileSync } from 'node:fs'
3
3
  import { isEnvName } from './blob.mjs'
4
4
  import { parseEnvFile } from './env-file.mjs'
5
5
  import { discoverTrackedFiles, findRepoRoot, readLink, resolveProject } from './project.mjs'
6
- import { resolveInsideRepo } from './repo-path.mjs'
6
+ import { findSymlink, resolveInsideRepo, resolveRepoFile } from './repo-path.mjs'
7
7
  import { requireHostToken } from './session.mjs'
8
8
  import { errOut, pluralize, style } from './ui.mjs'
9
9
 
@@ -36,9 +36,11 @@ export function resolveContext(flags) {
36
36
  * present — an explicit list is the only way to include a nested path like
37
37
  * `apps/web/.env.local`, which discovery does not scan for. With no link file,
38
38
  * fall back to the union of what discovery finds — root `.env*` files plus git-ignored
39
- * files in `.config/` — and whatever the cloud holds.
39
+ * files in `.config/` — and whatever the cloud holds. Those inferred names skip
40
+ * symbolic links with a warning rather than refusing (see `skipSymlinks`);
41
+ * `sink` is `'write'` for `pull`, so the warning says the file is not written.
40
42
  */
41
- export function resolveFileNames({ repoRoot, link, cloudFiles, requested }) {
43
+ export function resolveFileNames({ repoRoot, link, cloudFiles, requested, sink = 'read' }) {
42
44
  if (requested?.length) {
43
45
  return check([...new Set(requested)].sort())
44
46
  }
@@ -49,7 +51,41 @@ export function resolveFileNames({ repoRoot, link, cloudFiles, requested }) {
49
51
 
50
52
  const names = new Set([...discoverTrackedFiles(repoRoot), ...cloudFiles.map(file => file.name)])
51
53
 
52
- return check([...names].sort())
54
+ return skipSymlinks(repoRoot, check([...names].sort()), sink)
55
+ }
56
+
57
+ /**
58
+ * Drop, with a warning, the inferred names that are symbolic links on disk.
59
+ *
60
+ * Only for names nobody asked for. An explicit argument or a link-file entry
61
+ * that is a link still stops the command in `resolveRepoFile` — someone chose
62
+ * that name, so the refusal is the answer. But a name that is here only because
63
+ * the cloud holds a file by it says nothing about the developer's intent, and a
64
+ * `.env -> .env.development` or a worktree link is ordinary. Refusing there
65
+ * would take every sync command down over one file the developer never
66
+ * mentioned; skipping it leaves the link and whatever it points at untouched.
67
+ * (Discovery already leaves links out, so in practice these come from the
68
+ * cloud listing.)
69
+ */
70
+ function skipSymlinks(repoRoot, names, sink) {
71
+ return names.filter(name => {
72
+ const link = findSymlink(repoRoot, name)
73
+
74
+ if (!link) {
75
+ return true
76
+ }
77
+
78
+ const why = link === name ? 'it is a symbolic link' : `${link} is a symbolic link`
79
+ const effect =
80
+ sink === 'write'
81
+ ? `Not written: envstash never writes through a link. Replace ${link} with a real file to pull it.`
82
+ : `Not read: envstash never reads through a link. Replace ${link} with a real file to sync it.`
83
+
84
+ errOut(` ${style.yellow('!')} skipping ${name}: ${why}`)
85
+ errOut(` ${style.dim(effect)}`)
86
+
87
+ return false
88
+ })
53
89
  }
54
90
 
55
91
  /**
@@ -84,8 +120,9 @@ export function loadLocalFiles(repoRoot, names, kinds = new Map()) {
84
120
 
85
121
  for (const name of names) {
86
122
  // Resolved rather than joined: `join` happily walks out of the repo, and
87
- // this is the read side of the same hole `pull` had on the write side.
88
- const path = resolveInsideRepo(repoRoot, name)
123
+ // this is the read side of the same hole `pull` had on the write side. A
124
+ // symlink is refused here too, or `.env.local -> ~/.ssh/id_rsa` uploads the key.
125
+ const path = resolveRepoFile(repoRoot, name)
89
126
 
90
127
  if (!existsSync(path)) {
91
128
  files.set(name, null)
package/src/env-file.mjs CHANGED
@@ -38,10 +38,11 @@ export function parseEnvFile(contents) {
38
38
  const entries = []
39
39
  const skipped = []
40
40
  const seen = new Map()
41
+ const spans = new Map()
41
42
 
42
43
  let index = 0
43
44
  let lineNumber = 1
44
- const text = contents.replace(/^/, '')
45
+ const text = stripBom(contents)
45
46
 
46
47
  while (index < text.length) {
47
48
  const lineEnd = findLineEnd(text, index)
@@ -90,13 +91,29 @@ export function parseEnvFile(contents) {
90
91
  entries.push({ key, value })
91
92
  }
92
93
 
94
+ // Where this key's assignment sits in `text`, for `mergeEnvText`. A repeated
95
+ // key overwrites the earlier span, just as it overwrote the earlier value:
96
+ // the assignment that wins is the one a rewrite has to replace.
97
+ spans.set(key, { start: index, end: Math.min(nextIndex, text.length) })
98
+
93
99
  // Counted rather than incremented: a quoted value may have spanned several
94
100
  // lines, and a later skipped line has to report the number a human sees.
95
101
  lineNumber += countNewlines(text, index, nextIndex)
96
102
  index = nextIndex
97
103
  }
98
104
 
99
- return { entries, skipped }
105
+ return { entries, skipped, spans }
106
+ }
107
+
108
+ /**
109
+ * Drop a UTF-8 BOM.
110
+ *
111
+ * A function rather than a repeated regex because the offsets in `spans` are
112
+ * relative to its result: anything that rewrites the text has to start from
113
+ * byte zero of the same string the parser measured.
114
+ */
115
+ function stripBom(contents) {
116
+ return String(contents).replace(/^\ufeff/, '')
100
117
  }
101
118
 
102
119
  function findLineEnd(text, from) {
@@ -200,31 +217,95 @@ function needsQuoting(value) {
200
217
  * file. That matters because the entries handed to `envs pull` come from the
201
218
  * server, not from the developer.
202
219
  */
203
- const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
220
+ export const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
204
221
 
205
222
  /** Serialize entries back to `.env` text. Round-trips with `parseEnv`. */
206
223
  export function serializeEnv(entries) {
207
- const lines = entries.map(({ key, value }) => {
208
- if (!KEY_PATTERN.test(key)) {
209
- throw new CliError(`Refusing to write the key ${JSON.stringify(String(key))}.`, {
210
- hint: 'An environment variable name must be letters, digits and underscores.',
211
- })
212
- }
224
+ const lines = entries.map(serializeEntry)
225
+
226
+ return lines.length > 0 ? `${lines.join('\n')}\n` : ''
227
+ }
228
+
229
+ /** One `KEY=value` line, quoted as the value requires. No trailing newline. */
230
+ export function serializeEntry({ key, value }) {
231
+ if (!KEY_PATTERN.test(key)) {
232
+ throw new CliError(`Refusing to write the key ${JSON.stringify(String(key))}.`, {
233
+ hint: 'An environment variable name must be letters, digits and underscores.',
234
+ })
235
+ }
236
+
237
+ if (!needsQuoting(value)) {
238
+ return `${key}=${value}`
239
+ }
240
+
241
+ // `$` and backticks mean nothing to this parser inside double quotes, but
242
+ // bash, docker compose and dotenv-expand all expand them there — so
243
+ // `PASS='p$ss'` pulled back as `PASS="p$ss"` becomes `p` everywhere else.
244
+ // Single quotes are literal to all of them. They cannot hold a `'` or span
245
+ // lines without escapes, which single quotes do not have, so those values
246
+ // stay double-quoted.
247
+ if (/[$`]/.test(value) && !/['\n\r]/.test(value)) {
248
+ return `${key}='${value}'`
249
+ }
250
+
251
+ const escaped = value
252
+ .replace(/\\/g, '\\\\')
253
+ .replace(/"/g, '\\"')
254
+ .replace(/\n/g, '\\n')
255
+ .replace(/\r/g, '\\r')
213
256
 
214
- if (!needsQuoting(value)) {
215
- return `${key}=${value}`
257
+ return `${key}="${escaped}"`
258
+ }
259
+
260
+ /**
261
+ * Apply key/value changes to existing `.env` text, editing it in place.
262
+ *
263
+ * This is what makes `envs sync` safe to run on a file someone maintains by
264
+ * hand. `pull` regenerates the whole file from the cloud's entries, which is
265
+ * correct when the cloud's copy *is* the answer — but a merge only has an
266
+ * opinion about the handful of keys that moved, and rewriting the file would
267
+ * silently throw away every comment, blank line and grouping around them.
268
+ *
269
+ * A key that is already assigned has its assignment line replaced where it
270
+ * stands; a key that is new is appended. Replacements are applied back to
271
+ * front so that earlier offsets stay valid as the text changes length.
272
+ *
273
+ * Note that a replaced line loses an `export ` prefix or a trailing
274
+ * `# comment` on that one line: the span covers the whole assignment, and
275
+ * reproducing decoration around a value nobody can see is not worth a parser
276
+ * that has to understand it.
277
+ */
278
+ export function mergeEnvText(contents, changes) {
279
+ const { spans } = parseEnvFile(contents)
280
+
281
+ const replacements = []
282
+ const additions = []
283
+
284
+ for (const change of changes) {
285
+ const span = spans.get(change.key)
286
+
287
+ if (span) {
288
+ replacements.push({ span, line: serializeEntry(change) })
289
+ } else {
290
+ additions.push(change)
216
291
  }
292
+ }
293
+
294
+ let merged = stripBom(contents)
217
295
 
218
- const escaped = value
219
- .replace(/\\/g, '\\\\')
220
- .replace(/"/g, '\\"')
221
- .replace(/\n/g, '\\n')
222
- .replace(/\r/g, '\\r')
296
+ for (const { span, line } of replacements.sort((a, b) => b.span.start - a.span.start)) {
297
+ merged = `${merged.slice(0, span.start)}${line}\n${merged.slice(span.end)}`
298
+ }
223
299
 
224
- return `${key}="${escaped}"`
225
- })
300
+ if (additions.length > 0) {
301
+ // A file that does not end in a newline would otherwise get the first new
302
+ // key welded onto its last value.
303
+ const separator = merged === '' || merged.endsWith('\n') ? '' : '\n'
226
304
 
227
- return lines.length > 0 ? `${lines.join('\n')}\n` : ''
305
+ merged = `${merged}${separator}${additions.map(serializeEntry).join('\n')}\n`
306
+ }
307
+
308
+ return merged
228
309
  }
229
310
 
230
311
  /**
package/src/index.mjs CHANGED
@@ -9,15 +9,16 @@ import { pull } from './commands/pull.mjs'
9
9
  import { push } from './commands/push.mjs'
10
10
  import { remove } from './commands/remove.mjs'
11
11
  import { status } from './commands/status.mjs'
12
+ import { sync } from './commands/sync.mjs'
12
13
  import { DEFAULT_HOST } from './config.mjs'
13
14
  import { CLI_ALIASES, CLI_COMMAND_PAD, CLI_COMMANDS, CLI_ENV_VARS } from './manifest.mjs'
14
- import { CliError, errOut, out, style } from './ui.mjs'
15
+ import { CliError, errOut, out, sanitize, style } from './ui.mjs'
15
16
 
16
17
  /**
17
18
  * Flags that take a value. Everything else is a boolean, which is what lets
18
19
  * `--force` and `--project my-app` be parsed without a schema per command.
19
20
  */
20
- const VALUE_FLAGS = new Set(['host', 'port', 'project', 'label', 'cwd'])
21
+ const VALUE_FLAGS = new Set(['host', 'port', 'project', 'label', 'cwd', 'prefer'])
21
22
 
22
23
  /** Git-style `-C <dir>`: the one short flag that takes a value. */
23
24
  const SHORT_VALUE_ALIASES = { C: 'cwd' }
@@ -34,6 +35,7 @@ const HANDLERS = {
34
35
  diff,
35
36
  pull,
36
37
  push,
38
+ sync,
37
39
  remove,
38
40
  version: versionCommand,
39
41
  }
@@ -175,12 +177,14 @@ export async function run(argv) {
175
177
  const code = await command.run(flags, rest)
176
178
  return finish(code ?? 0)
177
179
  } catch (error) {
180
+ // Sanitized: an ApiError's message and a refused name both carry text the
181
+ // server chose, and this is where it reaches the terminal.
178
182
  if (error instanceof CliError) {
179
183
  errOut()
180
- errOut(` ${style.red('✗')} ${error.message}`)
184
+ errOut(` ${style.red('✗')} ${sanitize(error.message)}`)
181
185
 
182
186
  if (error.hint) {
183
- errOut(` ${style.dim(error.hint)}`)
187
+ errOut(` ${style.dim(sanitize(error.hint))}`)
184
188
  }
185
189
 
186
190
  errOut()
@@ -188,7 +192,7 @@ export async function run(argv) {
188
192
  }
189
193
 
190
194
  errOut()
191
- errOut(` ${style.red('✗')} ${error?.message ?? 'Unexpected error'}`)
195
+ errOut(` ${style.red('✗')} ${sanitize(error?.message ?? 'Unexpected error')}`)
192
196
  errOut()
193
197
 
194
198
  if (process.env.ENVSTASH_DEBUG) {
@@ -269,10 +273,14 @@ function printHelp(topic) {
269
273
  out(
270
274
  ` --json ${style.dim('Machine-readable output (status, list, whoami, version)')}`
271
275
  )
272
- out(` --exit-zero ${style.dim('Always exit 0, even when out of sync (status, diff)')}`)
273
- out(` --dry-run ${style.dim('Show what would happen (pull, push, remove)')}`)
274
276
  out(
275
- ` --shared ${style.dim('Target your account-wide shared files (pull, push, remove)')}`
277
+ ` --exit-zero ${style.dim('Always exit 0, even when out of sync (status, diff, sync)')}`
278
+ )
279
+ out(` --prefer <side> ${style.dim('Resolve conflicts as "local" or "cloud" (sync)')}`)
280
+ out(` --dry-run ${style.dim('Show what would happen (pull, push, sync, remove)')}`)
281
+ out(` -y, --yes ${style.dim('Upload newly discovered files without asking (push)')}`)
282
+ out(
283
+ ` --shared ${style.dim('Target your account-wide shared files (pull, push, sync, remove)')}`
276
284
  )
277
285
  out(` --dev ${style.dim('Log in to a local dev server if one is running')}`)
278
286
  out()
@@ -312,7 +320,8 @@ const COMMAND_HELP = {
312
320
  diff: [
313
321
  'Show which keys differ and what they differ to.',
314
322
  '',
315
- 'Values are masked by default; --show-values reveals them.',
323
+ 'Values are masked by default — at most the first 4 characters and the',
324
+ 'length — and --show-values reveals them.',
316
325
  '',
317
326
  'Like status, exits 1 when anything is out of sync; --exit-zero opts out.',
318
327
  '',
@@ -334,18 +343,42 @@ const COMMAND_HELP = {
334
343
  ' envs pull --force',
335
344
  ' envs pull --shared .npmrc',
336
345
  ],
346
+ sync: [
347
+ 'Reconcile this repo and the cloud in both directions, in one command.',
348
+ '',
349
+ 'Every file and every key that exists on one side only is copied to the',
350
+ 'other. Nothing is ever deleted — sync does not prune in either direction —',
351
+ 'so the worst a wrong guess costs is a key coming back, never one lost.',
352
+ '',
353
+ 'A key that exists on both sides with different values is a conflict: there',
354
+ 'is no record of the last sync, so nothing here knows which side moved. Both',
355
+ 'copies are left untouched, the key is named, and sync exits 1. Choose a side',
356
+ 'with --prefer local or --prefer cloud.',
357
+ '',
358
+ ' envs sync Merge everything both ways',
359
+ ' envs sync --dry-run Show what it would do',
360
+ ' envs sync --prefer local Conflicting keys: your value wins',
361
+ ' envs sync --prefer cloud Conflicting keys: the cloud’s value wins',
362
+ ' envs sync .env.local Just one file',
363
+ ' envs sync --shared .npmrc Merge against your account-wide shared files',
364
+ ],
337
365
  push: [
338
366
  'Send local files to the cloud.',
339
367
  '',
340
368
  'Keys in the cloud that are absent locally are left alone unless --prune is',
341
369
  'given, so pushing a partial .env cannot delete a teammate’s key.',
342
370
  '',
371
+ 'Files that would be new in the cloud are listed before anything uploads.',
372
+ 'Ones found by discovery — not named, not in .envstash.json — need a yes:',
373
+ 'asked on a terminal, --yes (-y) anywhere else.',
374
+ '',
343
375
  '--shared sends the named files to your account-wide shared files — for',
344
376
  'credentials that follow you across projects, like a global .npmrc. Pull',
345
377
  'them into any repo with `envs pull --shared <file>`.',
346
378
  '',
347
379
  ' envs push',
348
380
  ' envs push --dry-run',
381
+ ' envs push --yes Upload newly discovered files without asking',
349
382
  ' envs push --prune',
350
383
  ' envs push --shared .npmrc',
351
384
  ],
@@ -0,0 +1,34 @@
1
+ import { mkdirSync } from 'node:fs'
2
+ import { dirname } from 'node:path'
3
+
4
+ import { writeFileAtomic } from './atomic-write.mjs'
5
+ import { resolveRepoFile } from './repo-path.mjs'
6
+
7
+ /**
8
+ * Write a synced file to disk, with the care every such write needs.
9
+ *
10
+ * One function rather than the same six lines in `pull` and `sync`, because
11
+ * each of those lines is load-bearing and a copy that quietly drops one is not
12
+ * obviously wrong on sight:
13
+ *
14
+ * - The path is resolved at the sink, not only where the name entered. This
15
+ * is the call that creates directories and writes bytes, so it is the one
16
+ * that has to be certain the name is inside the repo and that no symlink on
17
+ * the way turns "inside" into somewhere else.
18
+ * - It is resolved *again* after the directories exist: the window between
19
+ * the first check and the write is where a link could appear.
20
+ * - The result is 0600. A synced file holds live credentials and has no
21
+ * business being readable by other accounts on a shared machine.
22
+ * - The write is atomic, so that holds for a file that already existed too,
23
+ * and a failed write leaves the old one intact rather than a truncated one.
24
+ */
25
+ export function writeSecretFile(repoRoot, name, body) {
26
+ const path = resolveRepoFile(repoRoot, name)
27
+
28
+ mkdirSync(dirname(path), { recursive: true })
29
+ resolveRepoFile(repoRoot, name)
30
+
31
+ writeFileAtomic(path, body, { mode: 0o600 })
32
+
33
+ return path
34
+ }
package/src/manifest.mjs CHANGED
@@ -27,6 +27,7 @@ export const CLI_COMMANDS = [
27
27
  { name: 'diff', summary: 'Show which keys differ, and how' },
28
28
  { name: 'pull', summary: 'Write the cloud’s version of each file to disk' },
29
29
  { name: 'push', summary: 'Send local files to the cloud' },
30
+ { name: 'sync', summary: 'Reconcile both directions at once, keeping every key' },
30
31
  { name: 'remove', summary: 'Delete files from the cloud (local copies stay)', aliases: ['rm'] },
31
32
  { name: 'version', summary: 'Show the CLI version and its environment' },
32
33
  ]
package/src/project.mjs CHANGED
@@ -1,10 +1,10 @@
1
1
  import { execFileSync } from 'node:child_process'
2
- import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
2
+ import { existsSync, lstatSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
3
3
  import { basename, dirname, join, resolve, sep } from 'node:path'
4
4
 
5
5
  import { MAX_BLOB_BYTES } from './blob.mjs'
6
- import { isSafeRepoPath, keepSafeRepoPaths } from './repo-path.mjs'
7
- import { CliError } from './ui.mjs'
6
+ import { findSymlink, isSafeRepoPath, keepSafeRepoPaths } from './repo-path.mjs'
7
+ import { CliError, sanitize } from './ui.mjs'
8
8
 
9
9
  /** The per-repo link file. Safe to commit — it names files, never values. */
10
10
  export const LINK_FILE = '.envstash.json'
@@ -98,7 +98,8 @@ export function readLink(repoRoot) {
98
98
  try {
99
99
  const parsed = JSON.parse(readFileSync(path, 'utf8'))
100
100
  return {
101
- project: typeof parsed.project === 'string' ? parsed.project : null,
101
+ // Printed in every command's header, and it came with someone else's repo.
102
+ project: typeof parsed.project === 'string' ? sanitize(parsed.project) : null,
102
103
  host: typeof parsed.host === 'string' ? parsed.host : null,
103
104
  // A link file arrives with a cloned repo, so its file list is untrusted
104
105
  // input. Names that would escape the repo are dropped at the door rather
@@ -113,7 +114,9 @@ export function readLink(repoRoot) {
113
114
  }
114
115
 
115
116
  export function writeLink(repoRoot, link) {
116
- const path = join(repoRoot, LINK_FILE)
117
+ // Not `join`: a cloned repo's `.envstash.json` can be a link to a file outside
118
+ // it, and init would then overwrite whatever that is.
119
+ const path = assertRegularTarget(repoRoot, LINK_FILE)
117
120
  const body = {
118
121
  project: link.project,
119
122
  files: link.files,
@@ -125,6 +128,19 @@ export function writeLink(repoRoot, link) {
125
128
  return path
126
129
  }
127
130
 
131
+ /** Same refusal as the sync commands, for the two files the CLI writes itself. */
132
+ function assertRegularTarget(repoRoot, name) {
133
+ const link = findSymlink(repoRoot, name)
134
+
135
+ if (link) {
136
+ throw new CliError(`Refusing to write ${name} through a symbolic link.`, {
137
+ hint: `${name} in this repo points somewhere else. Replace it with a real file.`,
138
+ })
139
+ }
140
+
141
+ return join(repoRoot, name)
142
+ }
143
+
128
144
  /**
129
145
  * Best guess at a project name: the git remote's repo name, falling back to the
130
146
  * directory name. Matching the repo name is what makes `envs pull` work on a
@@ -170,7 +186,9 @@ export function discoverEnvFiles(repoRoot) {
170
186
  .filter(name => !name.endsWith('.d.ts'))
171
187
  .filter(name => {
172
188
  try {
173
- return statSync(join(repoRoot, name)).isFile()
189
+ // lstat, not stat: a `.env.local` that is a link to `~/.ssh/id_rsa` is a
190
+ // link, not a file, and discovery must not offer it for upload.
191
+ return lstatSync(join(repoRoot, name)).isFile()
174
192
  } catch {
175
193
  return false
176
194
  }
@@ -266,7 +284,13 @@ export function discoverSecretFiles(repoRoot) {
266
284
  .filter(name => isSafeRepoPath(name))
267
285
  .filter(name => {
268
286
  try {
269
- const stats = statSync(join(repoRoot, name))
287
+ // A link anywhere on the path — `.config` itself, a subdirectory, or the
288
+ // file — could point outside the repo, so it is not offered.
289
+ if (findSymlink(repoRoot, name)) {
290
+ return false
291
+ }
292
+
293
+ const stats = lstatSync(join(repoRoot, name))
270
294
  // Skipping oversized files here means `envs init` never lists something
271
295
  // whose first push would fail.
272
296
  return stats.isFile() && stats.size <= MAX_BLOB_BYTES
@@ -330,6 +354,13 @@ export function ensureSecretDirIgnored(repoRoot) {
330
354
  return false
331
355
  }
332
356
 
357
+ // A .gitignore that is a link points outside the repo, or is shared with other
358
+ // repos; either way it is not this CLI's file to append to. Leaving `.config/`
359
+ // unignored only means discovery finds nothing there, the conservative side.
360
+ if (findSymlink(repoRoot, '.gitignore')) {
361
+ return false
362
+ }
363
+
333
364
  const path = join(repoRoot, '.gitignore')
334
365
  let existing = ''
335
366
 
package/src/repo-path.mjs CHANGED
@@ -1,4 +1,5 @@
1
- import { isAbsolute, relative, resolve } from 'node:path'
1
+ import { lstatSync } from 'node:fs'
2
+ import { isAbsolute, join, relative, resolve } from 'node:path'
2
3
 
3
4
  import { CliError } from './ui.mjs'
4
5
 
@@ -24,7 +25,44 @@ const SEGMENT = /^[A-Za-z0-9._-]+$/
24
25
 
25
26
  const MAX_LENGTH = 255
26
27
 
28
+ /**
29
+ * Directories whose contents run code, and the file that decides where this CLI
30
+ * sends credentials. The grammar keeps a name inside the repo, but inside the
31
+ * repo is not the same as harmless: a server that answers `.git/config` with an
32
+ * `fsmonitor` line, `.husky/pre-commit`, or `node_modules/.bin/next` gets a
33
+ * command run by the next `git status` or `pnpm dev`, and one that answers
34
+ * `.envstash.json` gets to repoint every later command at itself.
35
+ *
36
+ * Denied at any depth rather than only as the first segment: a nested package
37
+ * has its own `node_modules`, a vendored repo its own `.git`, and a nested link
38
+ * file is found first when commands run from that package. Compared without
39
+ * case, because on macOS and Windows `.GIT/config` is `.git/config`.
40
+ */
41
+ const DENIED_SEGMENTS = new Set(['.git', 'node_modules', '.husky'])
42
+ const DENIED_FILE = '.envstash.json'
43
+
27
44
  export function isSafeRepoPath(name) {
45
+ return isWellFormedRepoPath(name) && !isDeniedRepoPath(name)
46
+ }
47
+
48
+ /** True for a well-formed name that lands somewhere envstash never writes. */
49
+ export function isDeniedRepoPath(name) {
50
+ const segments = String(name).toLowerCase().split('/').map(stripWindowsTrailing)
51
+
52
+ return segments.some(segment => DENIED_SEGMENTS.has(segment)) || segments.at(-1) === DENIED_FILE
53
+ }
54
+
55
+ /**
56
+ * Win32 path normalisation drops trailing dots and spaces from every component,
57
+ * so on NTFS `.git./hooks/pre-commit` and `.git /hooks` both land in `.git/hooks`.
58
+ * `.git.` passes the segment grammar, so the deny list compares what Windows
59
+ * would actually open. Harmless elsewhere: no legitimate name ends in a dot.
60
+ */
61
+ function stripWindowsTrailing(segment) {
62
+ return segment.replace(/[. ]+$/, '')
63
+ }
64
+
65
+ function isWellFormedRepoPath(name) {
28
66
  if (typeof name !== 'string' || name === '' || name.length > MAX_LENGTH) {
29
67
  return false
30
68
  }
@@ -66,12 +104,76 @@ export function resolveInsideRepo(repoRoot, name) {
66
104
  return target
67
105
  }
68
106
 
107
+ /**
108
+ * Resolve `name` under `repoRoot` for an actual read or write, or refuse.
109
+ *
110
+ * `resolveInsideRepo` is lexical: it proves the *name* stays inside, but a
111
+ * symbolic link does not care what its name is. A cloned repo can ship
112
+ * `.env.local -> ~/.ssh/id_rsa`, and `envs push` would upload the key; `pull`
113
+ * would write through the same link. So every component from the repo root down
114
+ * to the file is lstat-ed, and any link — the file itself or a directory on the
115
+ * way — is refused. Components that do not exist yet are fine: `pull` creates
116
+ * them, as real directories.
117
+ *
118
+ * Refusing rather than following even a link that points inside the repo: this
119
+ * only ever handles `.env` files and credentials, which have no reason to be
120
+ * links, and "inside" is exactly the judgement an attacker gets to race.
121
+ */
122
+ export function resolveRepoFile(repoRoot, name) {
123
+ const target = resolveInsideRepo(repoRoot, name)
124
+ const link = findSymlink(repoRoot, name)
125
+
126
+ if (link) {
127
+ throw new CliError(`Refusing to follow a symbolic link: ${link}.`, {
128
+ hint:
129
+ link === name
130
+ ? `${name} is a link to somewhere else. envstash only reads and writes real files, so a link cannot redirect a secret. Replace it with the file itself.`
131
+ : `${name} is inside ${link}, which is a link to somewhere else. envstash only reads and writes real files inside the repo.`,
132
+ })
133
+ }
134
+
135
+ return target
136
+ }
137
+
138
+ /**
139
+ * The first symbolic link on the way from `repoRoot` to `name` (as a repo-relative
140
+ * path), or null. Links above the repo root are not this CLI's business — a home
141
+ * directory on a symlinked volume is ordinary.
142
+ */
143
+ export function findSymlink(repoRoot, name) {
144
+ const segments = name.split('/')
145
+
146
+ for (let depth = 1; depth <= segments.length; depth += 1) {
147
+ const partial = segments.slice(0, depth).join('/')
148
+ let stats
149
+
150
+ try {
151
+ stats = lstatSync(join(repoRoot, partial))
152
+ } catch {
153
+ // Nothing here yet, so nothing below it either.
154
+ return null
155
+ }
156
+
157
+ if (stats.isSymbolicLink()) {
158
+ return partial
159
+ }
160
+ }
161
+
162
+ return null
163
+ }
164
+
69
165
  /** Drop the names that are not safe, for the paths where refusing is too blunt. */
70
166
  export function keepSafeRepoPaths(names) {
71
167
  return names.filter(name => isSafeRepoPath(name))
72
168
  }
73
169
 
74
170
  function refuse(name) {
171
+ if (isWellFormedRepoPath(name) && isDeniedRepoPath(name)) {
172
+ return new CliError(`Refusing to use the file name ${JSON.stringify(String(name))}.`, {
173
+ hint: 'envstash never reads or writes .git/, node_modules/, .husky/ or .envstash.json — files there run code or redirect the CLI. Report this if it came from your own server.',
174
+ })
175
+ }
176
+
75
177
  return new CliError(`Refusing to use the file name ${JSON.stringify(String(name))}.`, {
76
178
  hint: 'A file name must stay inside the repo. Report this if it came from your own server.',
77
179
  })
package/src/session.mjs CHANGED
@@ -67,16 +67,21 @@ export function resolveHostSource(flags) {
67
67
  return explicit
68
68
  }
69
69
 
70
- try {
71
- const link = readLink(findRepoRoot())
70
+ let link = null
72
71
 
73
- if (link?.host) {
74
- return { host: resolveHost(link.host), source: 'link' }
75
- }
72
+ try {
73
+ link = readLink(findRepoRoot())
76
74
  } catch {
77
75
  // A malformed link file is reported by the commands that actually read it.
78
76
  }
79
77
 
78
+ // Outside the try: a host the CLI refuses (plain http to another machine) is
79
+ // an answer, not a malformed file, and falling back to the default instead
80
+ // would quietly run this repo's commands against a different server.
81
+ if (link?.host) {
82
+ return { host: resolveHost(link.host), source: 'link' }
83
+ }
84
+
80
85
  return { host: resolveHost(undefined), source: 'default' }
81
86
  }
82
87
 
package/src/status.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { digestFileBytes } from './blob.mjs'
2
- import { digestValue } from './env-file.mjs'
2
+ import { digestValue, KEY_PATTERN } from './env-file.mjs'
3
3
 
4
4
  /**
5
5
  * Per-key verdicts.
@@ -39,7 +39,15 @@ export function compareFile(name, localEntries, cloudKeys) {
39
39
 
40
40
  const keys = []
41
41
  const localMap = new Map((localEntries ?? []).map(entry => [entry.key, entry.value]))
42
- const cloudMap = new Map((cloudKeys ?? []).map(entry => [entry.key, entry.digest]))
42
+ // A cloud key outside the variable-name grammar is dropped, not compared. The
43
+ // server enforces the same grammar on the way in, so only a hostile or broken
44
+ // server sends one — and a key name is printed by status and diff, which makes
45
+ // it a way to put escape sequences on the developer's terminal.
46
+ const cloudMap = new Map(
47
+ (cloudKeys ?? [])
48
+ .filter(entry => typeof entry?.key === 'string' && KEY_PATTERN.test(entry.key))
49
+ .map(entry => [entry.key, entry.digest])
50
+ )
43
51
 
44
52
  for (const [key, value] of localMap) {
45
53
  const cloudDigest = cloudMap.get(key)