@hncode/hncode 0.5.1 → 0.5.2

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.
@@ -44,6 +44,31 @@
44
44
  * metacharacters — unquoted operator characters seen (for the read-only test)
45
45
  * redirects — unquoted redirect targets, e.g. ['/dev/null']
46
46
  */
47
+ /**
48
+ * Index of the `)` closing the `(` at `open`, or -1.
49
+ *
50
+ * Depth-counted so a nested `$(a $(b))` closes at the right place, and quote-aware so a `)`
51
+ * inside a string argument does not end the substitution early.
52
+ */
53
+ function matchingParen(s, open) {
54
+ let depth = 0;
55
+ for (let i = open; i < s.length; i++) {
56
+ const c = s[i];
57
+ if (c === '\\') { i++; continue; }
58
+ if (c === "'" || c === '"') {
59
+ const quote = c;
60
+ for (i++; i < s.length; i++) {
61
+ if (quote === '"' && s[i] === '\\') { i++; continue; }
62
+ if (s[i] === quote) break;
63
+ }
64
+ continue;
65
+ }
66
+ if (c === '(') depth++;
67
+ else if (c === ')') { depth--; if (depth === 0) return i; }
68
+ }
69
+ return -1;
70
+ }
71
+
47
72
  export function splitSegments(cmd) {
48
73
  const s = String(cmd == null ? '' : cmd);
49
74
  const segments = [];
@@ -91,11 +116,45 @@ export function splitSegments(cmd) {
91
116
  i += 1;
92
117
  continue;
93
118
  }
94
- if (c === '<' || c === '`') { metacharacters.add(c); i += 1; continue; }
119
+ if (c === '<') { metacharacters.add(c); i += 1; continue; }
120
+ // A backtick substitution is the same hazard as `$(…)`: it runs a command and splices the
121
+ // result into this one. Handled as a pair so the inner command is judged too.
122
+ if (c === '`') {
123
+ metacharacters.add(c);
124
+ const close = s.indexOf('`', i + 1);
125
+ if (close > 0) {
126
+ const inner = s.slice(i + 1, close);
127
+ if (inner.trim()) { segments.push(current); segments.push(inner); current = ''; }
128
+ current += s.slice(i, close + 1);
129
+ i = close + 1;
130
+ continue;
131
+ }
132
+ i += 1; continue;
133
+ }
95
134
  if (c === '$') {
96
- // `$(…)` / `${…}` can run a command or expand a path. Record the substitution
97
- // so the read-only test rejects the command, but keep the text for tokenizing.
98
- if (s[i + 1] === '(') metacharacters.add('$');
135
+ // `$(…)` runs a command and its OUTPUT is spliced into this one. The text has to stay
136
+ // in the segment for tokenizing, but the substitution is also a command in its own
137
+ // right — so it is emitted as a segment of its own. Without that, `npm test $(rm -rf /)`
138
+ // was a single segment that the `Bash(npm test *)` prefix rule matched, and
139
+ // isDestructiveCommand never saw `rm` at all.
140
+ if (s[i + 1] === '(') {
141
+ metacharacters.add('$');
142
+ const close = matchingParen(s, i + 1);
143
+ if (close > 0) {
144
+ const inner = s.slice(i + 2, close);
145
+ if (inner.trim()) {
146
+ // The outer text so far, then the substitution itself as its own command.
147
+ segments.push(current);
148
+ segments.push(inner);
149
+ current = '';
150
+ }
151
+ // The outer command continues with the substitution text in place, so what follows
152
+ // it stays attached to the same command line.
153
+ current += s.slice(i, close + 1);
154
+ i = close + 1;
155
+ continue;
156
+ }
157
+ }
99
158
  current += c; i += 1; continue;
100
159
  }
101
160
  if (c === '(' || c === ')') { metacharacters.add(c); current += c; i += 1; continue; }
@@ -296,6 +355,32 @@ function rmIsDestructive(args) {
296
355
  /** `mkfs`, `mkfs.ext4`, `mkfs.xfs` — the whole family. */
297
356
  function isMkfs(cmd) { return cmd === 'mkfs' || cmd.startsWith('mkfs.'); }
298
357
 
358
+ /**
359
+ * The git SUBCOMMAND from an argv tail.
360
+ *
361
+ * The obvious `args.find(a => !a.startsWith('-'))` returns the first non-flag WORD, which
362
+ * for `git -C /repo push` is `/repo` — the VALUE of `-C`. Every subcommand rule then missed
363
+ * and `git -C x push`, `git -c core.pager=x reset --hard`, `git -c foo=bar clean` all
364
+ * passed the destructive check. Three flag shapes have to be stepped over:
365
+ *
366
+ * -C <path> / -c <name=value> a separate value in the NEXT word
367
+ * --git-dir=<path> the value is inside the SAME word
368
+ * -p / --paginate no value at all
369
+ *
370
+ * Only the flags git treats as value-taking are listed, so a bare `-x` is still skipped as
371
+ * a plain flag and cannot swallow the subcommand beside it.
372
+ */
373
+ function gitSubcommand(args) {
374
+ const TAKES_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path']);
375
+ for (let i = 0; i < args.length; i++) {
376
+ const a = args[i];
377
+ if (!a.startsWith('-')) return a.toLowerCase();
378
+ // `--git-dir=/x` carries its own value, so the next word is the subcommand.
379
+ if (a.includes('=')) continue;
380
+ if (TAKES_VALUE.has(a)) i++; // step over the value
381
+ }
382
+ return '';
383
+ }
299
384
  // Judge ONE already-unwrapped command. Returns a reason string, or null when the
300
385
  // command is not destructive.
301
386
  function judgeUnwrapped(cmd, args) {
@@ -352,7 +437,7 @@ function judgeUnwrapped(cmd, args) {
352
437
  if (args.includes('prune')) return 'docker prune';
353
438
  break;
354
439
  case 'git': {
355
- const sub = (args.find((a) => !a.startsWith('-')) || '').toLowerCase();
440
+ const sub = gitSubcommand(args);
356
441
  // A dry run changes nothing, and flagging it is what made `git push
357
442
  // --dry-run` prompt in YOLO mode.
358
443
  if (args.some((a) => a === '-n' || a === '--dry-run')) break;
@@ -454,7 +539,11 @@ export function isReadOnlyCommand(cmd) {
454
539
  if (!u.cmd) return false; // a bare `sh -c "…"` is not provable
455
540
  if (!READ_ONLY_BINS.has(u.cmd)) return false;
456
541
  if (u.cmd === 'git') {
457
- const sub = (u.args.find((a) => !a.startsWith('-')) || '').toLowerCase();
542
+ // The shared extractor, not `args.find(a => !a.startsWith('-'))`: that form returns the
543
+ // VALUE of `-C`/`-c` (`git -C /repo status` yielded `/repo`), so the verb was never read
544
+ // and an ordinary read-only git command was refused. The destructive check learned this
545
+ // first; the two judged the same command differently until they shared one function.
546
+ const sub = gitSubcommand(u.args);
458
547
  if (!sub || !GIT_READ_SUBCMDS.has(sub)) return false;
459
548
  // `tag`/`branch`/`remote`/`config`/`worktree` are read-only only WITHOUT a
460
549
  // writing flag, so any of those flags refuses the whole command.