envstash-cli 0.5.1 → 0.6.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/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)
package/src/sync.mjs ADDED
@@ -0,0 +1,137 @@
1
+ import { FILE_STATE, KEY_STATE } from './status.mjs'
2
+
3
+ /**
4
+ * What `envs sync` decided to do about one file.
5
+ *
6
+ * `MERGE` is the interesting one and the reason the command exists: both sides
7
+ * moved, and neither is wholly right.
8
+ */
9
+ export const SYNC_ACTION = {
10
+ /** Already identical. */
11
+ NONE: 'none',
12
+ /** Only the cloud has it — write it to disk. */
13
+ PULL: 'pull',
14
+ /** Only disk has it — send it up. */
15
+ PUSH: 'push',
16
+ /** Both sides moved; copy each side's additions to the other. */
17
+ MERGE: 'merge',
18
+ /** An opaque file that differs. Bytes do not merge — a human has to choose. */
19
+ CONFLICT: 'conflict',
20
+ }
21
+
22
+ /** `--prefer` values. */
23
+ export const PREFER = { LOCAL: 'local', CLOUD: 'cloud' }
24
+
25
+ /**
26
+ * Decide what sync does, for a whole comparison. Pure: no network, no disk.
27
+ *
28
+ * The rule, and the reason for it:
29
+ *
30
+ * There is no record of the last sync, so "which side moved" is unknowable —
31
+ * the same fact that makes `envs status` say `local_only` and `cloud_only`
32
+ * rather than "added" and "deleted". Sync therefore performs the one merge
33
+ * that cannot lose anything: every key that exists on one side only is copied
34
+ * to the other, in whichever direction that implies, and nothing is ever
35
+ * deleted or pruned. When the guess is wrong, the cost is a key coming back
36
+ * that a teammate had removed — visible in the next `envs status`, and fixed
37
+ * with `envs rm` — rather than a secret that exists nowhere any more.
38
+ *
39
+ * A key that exists on both sides with different values is the one case where
40
+ * copying would destroy something, so sync refuses to guess: it is reported as
41
+ * a conflict and both sides are left exactly as they are. `--prefer local` or
42
+ * `--prefer cloud` is how a developer says which side wins, and it is
43
+ * deliberately a separate, typed-out act.
44
+ */
45
+ export function planSync(files, { prefer } = {}) {
46
+ return files.map(file => planFile(file, prefer))
47
+ }
48
+
49
+ function planFile(file, prefer) {
50
+ const plan = {
51
+ name: file.name,
52
+ kind: file.kind,
53
+ action: SYNC_ACTION.NONE,
54
+ /** Keys to send up. */
55
+ upload: [],
56
+ /** Keys to bring down. */
57
+ download: [],
58
+ /** Keys that differ on both sides and nobody has chosen between. */
59
+ conflicts: [],
60
+ }
61
+
62
+ if (file.state === FILE_STATE.IN_SYNC) {
63
+ return plan
64
+ }
65
+
66
+ if (file.state === FILE_STATE.CLOUD_ONLY) {
67
+ return { ...plan, action: SYNC_ACTION.PULL }
68
+ }
69
+
70
+ if (file.state === FILE_STATE.UNTRACKED) {
71
+ return { ...plan, action: SYNC_ACTION.PUSH }
72
+ }
73
+
74
+ // DIFFERS. An opaque file has no keys to merge — half a certificate is not a
75
+ // certificate — so the whole file is the unit, and the whole file is the
76
+ // conflict until someone names a winner.
77
+ if (file.kind === 'file') {
78
+ if (prefer === PREFER.LOCAL) {
79
+ return { ...plan, action: SYNC_ACTION.PUSH }
80
+ }
81
+
82
+ if (prefer === PREFER.CLOUD) {
83
+ return { ...plan, action: SYNC_ACTION.PULL }
84
+ }
85
+
86
+ return { ...plan, action: SYNC_ACTION.CONFLICT }
87
+ }
88
+
89
+ const keysIn = state => file.keys.filter(entry => entry.state === state).map(entry => entry.key)
90
+ const modified = keysIn(KEY_STATE.MODIFIED)
91
+
92
+ const upload = keysIn(KEY_STATE.LOCAL_ONLY)
93
+ const download = keysIn(KEY_STATE.CLOUD_ONLY)
94
+
95
+ if (prefer === PREFER.LOCAL) {
96
+ upload.push(...modified)
97
+ } else if (prefer === PREFER.CLOUD) {
98
+ download.push(...modified)
99
+ }
100
+
101
+ return {
102
+ ...plan,
103
+ action: SYNC_ACTION.MERGE,
104
+ upload: upload.sort(),
105
+ download: download.sort(),
106
+ conflicts: prefer ? [] : modified,
107
+ }
108
+ }
109
+
110
+ /** True when this file needs a decision sync will not make on its own. */
111
+ export function isBlocked(plan) {
112
+ return plan.action === SYNC_ACTION.CONFLICT || plan.conflicts.length > 0
113
+ }
114
+
115
+ /** True when the plan would touch anything at all. */
116
+ export function isChanging(plan) {
117
+ return (
118
+ plan.action === SYNC_ACTION.PULL ||
119
+ plan.action === SYNC_ACTION.PUSH ||
120
+ plan.upload.length > 0 ||
121
+ plan.download.length > 0
122
+ )
123
+ }
124
+
125
+ /**
126
+ * Exit 1 when a conflict is left unresolved, the way `status` and `diff` exit 1
127
+ * on drift: sync having run is not the same as the repo being in sync, and a
128
+ * hook or a CI job should hear the difference. `--exit-zero` opts out, as it
129
+ * does there.
130
+ */
131
+ export function conflictExitCode(flags, plans) {
132
+ if (flags['exit-zero']) {
133
+ return 0
134
+ }
135
+
136
+ return plans.some(isBlocked) ? 1 : 0
137
+ }
package/src/ui.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { createInterface } from 'node:readline'
2
+
1
3
  /**
2
4
  * Terminal output helpers.
3
5
  *
@@ -71,20 +73,97 @@ export function fail(message, options) {
71
73
  }
72
74
 
73
75
  /**
74
- * Mask a secret for display: keep a little of the head and tail so a human can
75
- * recognise which value it is, hide the middle. Short values are hidden whole —
76
- * showing 2 of 6 characters of a PIN is worse than showing none.
76
+ * Mask a secret for display: a little of the head, so a human can tell which
77
+ * value it is, and the length. Never the tail — the end of a key is often the
78
+ * part a provider prints as its fingerprint, and head plus tail together gave
79
+ * away 8 characters of every secret on screen. Short values are hidden whole,
80
+ * length included — showing 2 of 6 characters of a PIN, or that it has 6, is
81
+ * worse than showing nothing.
77
82
  */
83
+ const MASK = '•'.repeat(8)
84
+
78
85
  export function maskValue(value) {
79
86
  if (value === '') {
80
87
  return style.dim('(empty)')
81
88
  }
82
89
 
83
90
  if (value.length <= 12) {
84
- return style.dim('•'.repeat(8))
91
+ return style.dim(MASK)
85
92
  }
86
93
 
87
- return `${value.slice(0, 4)}${style.dim('•'.repeat(8))}${value.slice(-4)}`
94
+ return `${sanitize(value.slice(0, 4))}${style.dim(`${MASK} (${value.length} chars)`)}`
95
+ }
96
+
97
+ /**
98
+ * Terminal control sequences, whole: CSI (`ESC [ … final`), OSC and the other
99
+ * string commands (`ESC ] … BEL` or `… ESC \\`), and two-byte escapes. Removed
100
+ * as units so a stripped sequence does not leave its `[2J` behind as litter.
101
+ */
102
+ const ESCAPE_SEQUENCE =
103
+ /\u001b(?:\[[0-?]*[ -/]*[@-~]|[\]PX^_][^\u0007\u001b]*(?:\u0007|\u001b\\)?|[@-Z\\-_]?)/g
104
+
105
+ /**
106
+ * Everything else a terminal acts on rather than prints: C0 controls, DEL, the
107
+ * C1 range (U+009B is a one-character CSI), and the Unicode line separators and
108
+ * bidi overrides that can reorder or break a line visually.
109
+ */
110
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g
111
+
112
+ /**
113
+ * Make a string safe to print when someone else chose it.
114
+ *
115
+ * Key names, error messages and account details come from the server, and a
116
+ * hostile one could otherwise send escape sequences that clear the screen and
117
+ * draw a convincing "all in sync" over the real output. Newlines become spaces
118
+ * by default, because a forged line is the attack; pass `{ newlines: true }`
119
+ * where a line break is meant.
120
+ */
121
+ export function sanitize(text, { newlines = false } = {}) {
122
+ const stripped = String(text ?? '').replace(ESCAPE_SEQUENCE, '')
123
+
124
+ return stripped.replace(CONTROL, char => {
125
+ if (char === '\n') {
126
+ return newlines ? '\n' : ' '
127
+ }
128
+
129
+ return char === '\t' ? ' ' : ''
130
+ })
131
+ }
132
+
133
+ /**
134
+ * True when a human is there to answer a question: both ends are a terminal.
135
+ * Checking stdin alone is not enough — `envs push > log.txt` has a keyboard but
136
+ * no screen to show the question on.
137
+ */
138
+ export function isInteractive() {
139
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY)
140
+ }
141
+
142
+ /**
143
+ * Ask a yes/no question; anything but y or yes is no.
144
+ *
145
+ * Resolves false if input ends before an answer (Ctrl-D, a closed pipe), so an
146
+ * unanswered question can never be read as consent.
147
+ */
148
+ export async function confirm(question, { input = process.stdin, output = process.stdout } = {}) {
149
+ const rl = createInterface({ input, output })
150
+
151
+ return await new Promise(resolve => {
152
+ let answered = false
153
+
154
+ rl.once('close', () => {
155
+ if (!answered) {
156
+ output.write('\n')
157
+ resolve(false)
158
+ }
159
+ })
160
+
161
+ rl.question(question, answer => {
162
+ answered = true
163
+ rl.close()
164
+ resolve(/^y(es)?$/i.test(answer.trim()))
165
+ })
166
+ })
88
167
  }
89
168
 
90
169
  /** Pad to a display width, ignoring colour codes. */