@taliesinb/mash 0.0.0-stage → 0.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.
Files changed (203) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +275 -2
  3. package/bin/mash.ts +253 -0
  4. package/dist/bin/mash.d.ts +39 -0
  5. package/dist/bin/mash.d.ts.map +1 -0
  6. package/dist/bin/mash.js +281 -0
  7. package/dist/bin/mash.js.map +1 -0
  8. package/dist/src/analysis/analyze.d.ts +97 -0
  9. package/dist/src/analysis/analyze.d.ts.map +1 -0
  10. package/dist/src/analysis/analyze.js +1406 -0
  11. package/dist/src/analysis/analyze.js.map +1 -0
  12. package/dist/src/analysis/fs.d.ts +39 -0
  13. package/dist/src/analysis/fs.d.ts.map +1 -0
  14. package/dist/src/analysis/fs.js +117 -0
  15. package/dist/src/analysis/fs.js.map +1 -0
  16. package/dist/src/analysis/outputs.d.ts +16 -0
  17. package/dist/src/analysis/outputs.d.ts.map +1 -0
  18. package/dist/src/analysis/outputs.js +277 -0
  19. package/dist/src/analysis/outputs.js.map +1 -0
  20. package/dist/src/analysis/value.d.ts +76 -0
  21. package/dist/src/analysis/value.d.ts.map +1 -0
  22. package/dist/src/analysis/value.js +248 -0
  23. package/dist/src/analysis/value.js.map +1 -0
  24. package/dist/src/behaviors/defs/core.d.ts +15 -0
  25. package/dist/src/behaviors/defs/core.d.ts.map +1 -0
  26. package/dist/src/behaviors/defs/core.js +1084 -0
  27. package/dist/src/behaviors/defs/core.js.map +1 -0
  28. package/dist/src/behaviors/defs/devtools.d.ts +9 -0
  29. package/dist/src/behaviors/defs/devtools.d.ts.map +1 -0
  30. package/dist/src/behaviors/defs/devtools.js +432 -0
  31. package/dist/src/behaviors/defs/devtools.js.map +1 -0
  32. package/dist/src/behaviors/defs/macos.d.ts +12 -0
  33. package/dist/src/behaviors/defs/macos.d.ts.map +1 -0
  34. package/dist/src/behaviors/defs/macos.js +454 -0
  35. package/dist/src/behaviors/defs/macos.js.map +1 -0
  36. package/dist/src/behaviors/defs/tools.d.ts +9 -0
  37. package/dist/src/behaviors/defs/tools.d.ts.map +1 -0
  38. package/dist/src/behaviors/defs/tools.js +639 -0
  39. package/dist/src/behaviors/defs/tools.js.map +1 -0
  40. package/dist/src/behaviors/dsl.d.ts +95 -0
  41. package/dist/src/behaviors/dsl.d.ts.map +1 -0
  42. package/dist/src/behaviors/dsl.js +267 -0
  43. package/dist/src/behaviors/dsl.js.map +1 -0
  44. package/dist/src/behaviors/index.d.ts +12 -0
  45. package/dist/src/behaviors/index.d.ts.map +1 -0
  46. package/dist/src/behaviors/index.js +18 -0
  47. package/dist/src/behaviors/index.js.map +1 -0
  48. package/dist/src/behaviors/registry.d.ts +45 -0
  49. package/dist/src/behaviors/registry.d.ts.map +1 -0
  50. package/dist/src/behaviors/registry.js +266 -0
  51. package/dist/src/behaviors/registry.js.map +1 -0
  52. package/dist/src/behaviors/roles.d.ts +29 -0
  53. package/dist/src/behaviors/roles.d.ts.map +1 -0
  54. package/dist/src/behaviors/roles.js +198 -0
  55. package/dist/src/behaviors/roles.js.map +1 -0
  56. package/dist/src/behaviors/types.d.ts +120 -0
  57. package/dist/src/behaviors/types.d.ts.map +1 -0
  58. package/dist/src/behaviors/types.js +12 -0
  59. package/dist/src/behaviors/types.js.map +1 -0
  60. package/dist/src/index.d.ts +27 -0
  61. package/dist/src/index.d.ts.map +1 -0
  62. package/dist/src/index.js +24 -0
  63. package/dist/src/index.js.map +1 -0
  64. package/dist/src/integrations/deepsea.d.ts +121 -0
  65. package/dist/src/integrations/deepsea.d.ts.map +1 -0
  66. package/dist/src/integrations/deepsea.js +199 -0
  67. package/dist/src/integrations/deepsea.js.map +1 -0
  68. package/dist/src/machine.d.ts +139 -0
  69. package/dist/src/machine.d.ts.map +1 -0
  70. package/dist/src/machine.js +400 -0
  71. package/dist/src/machine.js.map +1 -0
  72. package/dist/src/parser/ast.d.ts +369 -0
  73. package/dist/src/parser/ast.d.ts.map +1 -0
  74. package/dist/src/parser/ast.js +17 -0
  75. package/dist/src/parser/ast.js.map +1 -0
  76. package/dist/src/parser/parser.d.ts +48 -0
  77. package/dist/src/parser/parser.d.ts.map +1 -0
  78. package/dist/src/parser/parser.js +1950 -0
  79. package/dist/src/parser/parser.js.map +1 -0
  80. package/dist/src/policy/effects.d.ts +86 -0
  81. package/dist/src/policy/effects.d.ts.map +1 -0
  82. package/dist/src/policy/effects.js +53 -0
  83. package/dist/src/policy/effects.js.map +1 -0
  84. package/dist/src/policy/policy.d.ts +130 -0
  85. package/dist/src/policy/policy.d.ts.map +1 -0
  86. package/dist/src/policy/policy.js +532 -0
  87. package/dist/src/policy/policy.js.map +1 -0
  88. package/dist/src/printer/doc.d.ts +151 -0
  89. package/dist/src/printer/doc.d.ts.map +1 -0
  90. package/dist/src/printer/doc.js +409 -0
  91. package/dist/src/printer/doc.js.map +1 -0
  92. package/dist/src/printer/highlight.d.ts +53 -0
  93. package/dist/src/printer/highlight.d.ts.map +1 -0
  94. package/dist/src/printer/highlight.js +339 -0
  95. package/dist/src/printer/highlight.js.map +1 -0
  96. package/dist/src/printer/html.d.ts +72 -0
  97. package/dist/src/printer/html.d.ts.map +1 -0
  98. package/dist/src/printer/html.js +423 -0
  99. package/dist/src/printer/html.js.map +1 -0
  100. package/dist/src/printer/print.d.ts +63 -0
  101. package/dist/src/printer/print.d.ts.map +1 -0
  102. package/dist/src/printer/print.js +484 -0
  103. package/dist/src/printer/print.js.map +1 -0
  104. package/dist/src/printer/summary.d.ts +50 -0
  105. package/dist/src/printer/summary.d.ts.map +1 -0
  106. package/dist/src/printer/summary.js +621 -0
  107. package/dist/src/printer/summary.js.map +1 -0
  108. package/dist/src/trace/events.d.ts +183 -0
  109. package/dist/src/trace/events.d.ts.map +1 -0
  110. package/dist/src/trace/events.js +9 -0
  111. package/dist/src/trace/events.js.map +1 -0
  112. package/dist/src/trace/progress.d.ts +58 -0
  113. package/dist/src/trace/progress.d.ts.map +1 -0
  114. package/dist/src/trace/progress.js +83 -0
  115. package/dist/src/trace/progress.js.map +1 -0
  116. package/dist/src/vm/arith.d.ts +29 -0
  117. package/dist/src/vm/arith.d.ts.map +1 -0
  118. package/dist/src/vm/arith.js +258 -0
  119. package/dist/src/vm/arith.js.map +1 -0
  120. package/dist/src/vm/builtins.d.ts +31 -0
  121. package/dist/src/vm/builtins.d.ts.map +1 -0
  122. package/dist/src/vm/builtins.js +1773 -0
  123. package/dist/src/vm/builtins.js.map +1 -0
  124. package/dist/src/vm/emulate.d.ts +75 -0
  125. package/dist/src/vm/emulate.d.ts.map +1 -0
  126. package/dist/src/vm/emulate.js +380 -0
  127. package/dist/src/vm/emulate.js.map +1 -0
  128. package/dist/src/vm/expand.d.ts +68 -0
  129. package/dist/src/vm/expand.d.ts.map +1 -0
  130. package/dist/src/vm/expand.js +744 -0
  131. package/dist/src/vm/expand.js.map +1 -0
  132. package/dist/src/vm/external.d.ts +34 -0
  133. package/dist/src/vm/external.d.ts.map +1 -0
  134. package/dist/src/vm/external.js +228 -0
  135. package/dist/src/vm/external.js.map +1 -0
  136. package/dist/src/vm/fifo.d.ts +66 -0
  137. package/dist/src/vm/fifo.d.ts.map +1 -0
  138. package/dist/src/vm/fifo.js +232 -0
  139. package/dist/src/vm/fifo.js.map +1 -0
  140. package/dist/src/vm/interp.d.ts +209 -0
  141. package/dist/src/vm/interp.d.ts.map +1 -0
  142. package/dist/src/vm/interp.js +1641 -0
  143. package/dist/src/vm/interp.js.map +1 -0
  144. package/dist/src/vm/io.d.ts +180 -0
  145. package/dist/src/vm/io.d.ts.map +1 -0
  146. package/dist/src/vm/io.js +502 -0
  147. package/dist/src/vm/io.js.map +1 -0
  148. package/dist/src/vm/pattern.d.ts +46 -0
  149. package/dist/src/vm/pattern.d.ts.map +1 -0
  150. package/dist/src/vm/pattern.js +498 -0
  151. package/dist/src/vm/pattern.js.map +1 -0
  152. package/dist/src/vm/proc.d.ts +94 -0
  153. package/dist/src/vm/proc.d.ts.map +1 -0
  154. package/dist/src/vm/proc.js +130 -0
  155. package/dist/src/vm/proc.js.map +1 -0
  156. package/dist/src/vm/state.d.ts +104 -0
  157. package/dist/src/vm/state.d.ts.map +1 -0
  158. package/dist/src/vm/state.js +233 -0
  159. package/dist/src/vm/state.js.map +1 -0
  160. package/dist/src/vm/test.d.ts +11 -0
  161. package/dist/src/vm/test.d.ts.map +1 -0
  162. package/dist/src/vm/test.js +277 -0
  163. package/dist/src/vm/test.js.map +1 -0
  164. package/package.json +63 -4
  165. package/src/analysis/analyze.ts +1395 -0
  166. package/src/analysis/fs.ts +121 -0
  167. package/src/analysis/outputs.ts +242 -0
  168. package/src/analysis/value.ts +264 -0
  169. package/src/behaviors/defs/core.ts +970 -0
  170. package/src/behaviors/defs/devtools.ts +400 -0
  171. package/src/behaviors/defs/macos.ts +426 -0
  172. package/src/behaviors/defs/tools.ts +596 -0
  173. package/src/behaviors/dsl.ts +293 -0
  174. package/src/behaviors/index.ts +19 -0
  175. package/src/behaviors/registry.ts +261 -0
  176. package/src/behaviors/roles.ts +187 -0
  177. package/src/behaviors/types.ts +127 -0
  178. package/src/index.ts +27 -0
  179. package/src/integrations/deepsea.ts +259 -0
  180. package/src/machine.ts +483 -0
  181. package/src/parser/ast.ts +472 -0
  182. package/src/parser/parser.ts +1826 -0
  183. package/src/policy/effects.ts +102 -0
  184. package/src/policy/policy.ts +578 -0
  185. package/src/printer/doc.ts +454 -0
  186. package/src/printer/highlight.ts +283 -0
  187. package/src/printer/html.ts +441 -0
  188. package/src/printer/print.ts +528 -0
  189. package/src/printer/summary.ts +601 -0
  190. package/src/trace/events.ts +102 -0
  191. package/src/trace/progress.ts +119 -0
  192. package/src/vm/arith.ts +236 -0
  193. package/src/vm/builtins.ts +1590 -0
  194. package/src/vm/emulate.ts +337 -0
  195. package/src/vm/expand.ts +721 -0
  196. package/src/vm/external.ts +265 -0
  197. package/src/vm/fifo.ts +226 -0
  198. package/src/vm/interp.ts +1623 -0
  199. package/src/vm/io.ts +570 -0
  200. package/src/vm/pattern.ts +477 -0
  201. package/src/vm/proc.ts +147 -0
  202. package/src/vm/state.ts +267 -0
  203. package/src/vm/test.ts +253 -0
@@ -0,0 +1,337 @@
1
+ /**
2
+ * In-VM emulation of commands that run other commands on data only known at
3
+ * runtime: `xargs` (arguments from stdin) and the actions of `find`
4
+ * (`-exec ... ;`, `-exec ... +`, `-execdir`, `-delete`).
5
+ *
6
+ * Instead of handing the whole job to the real binary (where every nested
7
+ * command is invisible), mash runs `find ... -print0` for the matches, or
8
+ * reads the xargs input itself, and then runs each resulting command through
9
+ * the VM. Every one is traced and checked against the policy with its
10
+ * concrete arguments. `-execdir` runs each command in a subshell whose cwd is
11
+ * the match's directory. Forms that cannot be emulated command by command
12
+ * (`-ok`, an action inside a compound find expression, xargs `-p` or an
13
+ * unknown option) are refused with status 126 rather than handed to the real
14
+ * binary, whose nested commands would run unchecked.
15
+ */
16
+
17
+ import * as fs from 'node:fs'
18
+ import * as path from 'node:path'
19
+ import type { Interpreter } from './interp.ts'
20
+ import { Capture, FdTable, NullFile } from './io.ts'
21
+ import type { Proc } from './proc.ts'
22
+ import { runExternal } from './external.ts'
23
+
24
+ const BSD = process.platform === 'darwin' || process.platform.endsWith('bsd')
25
+
26
+ export interface EmulationRequest {
27
+ execId: number
28
+ /** The resolved path of the real binary (find, for listing matches). */
29
+ path: string
30
+ argv: string[]
31
+ env: Record<string, string>
32
+ proc: Proc
33
+ node: import('../parser/ast.ts').SimpleCommand
34
+ }
35
+
36
+ /** Split xargs input into arguments (default mode: blanks/newlines, with '', "" and \\ quoting). */
37
+ function xargsTokens(text: string): string[] {
38
+ const out: string[] = []
39
+ let cur = ''
40
+ let has = false
41
+ for (let i = 0; i < text.length; i++) {
42
+ const c = text[i]
43
+ if (c === '\\' && i + 1 < text.length) {
44
+ cur += text[++i]
45
+ has = true
46
+ } else if (c === "'" || c === '"') {
47
+ const end = text.indexOf(c, i + 1)
48
+ if (end === -1) throw new Error(`unterminated quote in xargs input`)
49
+ cur += text.slice(i + 1, end)
50
+ has = true
51
+ i = end
52
+ } else if (c === ' ' || c === '\t' || c === '\n') {
53
+ if (has) out.push(cur)
54
+ cur = ''
55
+ has = false
56
+ } else {
57
+ cur += c
58
+ has = true
59
+ }
60
+ }
61
+ if (has) out.push(cur)
62
+ return out
63
+ }
64
+
65
+ /**
66
+ * Refuse a form the emulation cannot run command by command: the real binary
67
+ * would run its nested commands unchecked.
68
+ */
69
+ function refuse(interp: Interpreter, req: EmulationRequest, what: string): number {
70
+ interp.errorMessage(req.proc, `${path.basename(req.argv[0])}: ${what} is not supported by mash (its commands could not be checked against the policy)`)
71
+ return 126
72
+ }
73
+
74
+ export interface XargsOptions {
75
+ nul: boolean; delim: string | null; replace: string | null; replaceOne: string | null; maxArgs: number; maxLines: number
76
+ noRunIfEmpty: boolean; trace: boolean; eof: string | null; argFile: string | null; command: string[]
77
+ }
78
+
79
+ /** Parse xargs options, or name the one that cannot be emulated. */
80
+ export function planXargs(argv: string[]): XargsOptions | { refuse: string } {
81
+ // split bundled short options: `-0r` → `-0 -r`, `-0n1` → `-0 -n1`
82
+ const args: string[] = []
83
+ for (let k = 0; k < argv.length; k++) {
84
+ const a = argv[k]
85
+ const m = /^-([0rtx]+)([0rtxIndLPEsSJ].*)?$/.exec(a)
86
+ if (a === '--' || !a.startsWith('-')) {
87
+ args.push(...argv.slice(k))
88
+ break
89
+ }
90
+ if (m && (m[1].length > 1 || m[2])) {
91
+ for (const c of m[1]) args.push('-' + c)
92
+ if (m[2]) args.push('-' + m[2])
93
+ } else args.push(a)
94
+ }
95
+ let nul = false
96
+ let argFile: string | null = null
97
+ let replaceOne: string | null = null
98
+ let delim: string | null = null
99
+ let replace: string | null = null
100
+ let maxArgs = Infinity
101
+ let maxLines = 0
102
+ let noRunIfEmpty = BSD
103
+ let trace = false
104
+ let eof: string | null = null
105
+ let i = 0
106
+ for (; i < args.length; i++) {
107
+ const a = args[i]
108
+ if (!a.startsWith('-') || a === '-') break
109
+ if (a === '--') {
110
+ i++
111
+ break
112
+ }
113
+ const take = (): string | undefined => args[++i]
114
+ if (a === '-0' || a === '--null') nul = true
115
+ else if (a === '-r' || a === '--no-run-if-empty') noRunIfEmpty = true
116
+ else if (a === '-t' || a === '--verbose') trace = true
117
+ else if (a === '-d') delim = take() ?? null
118
+ else if (a.startsWith('--delimiter=')) delim = a.slice(12)
119
+ else if (a === '-I') replace = take() ?? null
120
+ else if (a === '-i' || a === '--replace') replace = '{}'
121
+ else if (a.startsWith('-I')) replace = a.slice(2)
122
+ else if (a.startsWith('--replace=')) replace = a.slice(10)
123
+ else if (a === '-n') maxArgs = Number(take())
124
+ else if (/^-n\d+$/.test(a)) maxArgs = Number(a.slice(2))
125
+ else if (a.startsWith('--max-args=')) maxArgs = Number(a.slice(11))
126
+ else if (a === '-L') maxLines = Number(take())
127
+ else if (/^-L\d+$/.test(a)) maxLines = Number(a.slice(2))
128
+ else if (a === '-P') take() // run sequentially
129
+ else if (a.startsWith('-P') || a.startsWith('--max-procs=')) continue
130
+ else if (a === '-E') eof = take() ?? null
131
+ else if (/^-e/.test(a)) eof = a.length > 2 ? a.slice(2) : null
132
+ else if (a.startsWith('--eof')) eof = a.startsWith('--eof=') ? a.slice(6) : null
133
+ else if (a === '-l' || a === '--max-lines') maxLines = 1
134
+ else if (/^-l\d+$/.test(a)) maxLines = Number(a.slice(2))
135
+ else if (a.startsWith('--max-lines=')) maxLines = Number(a.slice(12))
136
+ else if (a === '-a') argFile = take() ?? null
137
+ else if (a.startsWith('--arg-file=')) argFile = a.slice(11)
138
+ else if (a === '-J') replaceOne = take() ?? null
139
+ else if (a === '-s' || a === '-S' || a === '-R') take() // size and replacement limits: no effect on what runs
140
+ else if (/^-[sSR]\d+$/.test(a) || a.startsWith('--max-chars=') || a.startsWith('--process-slot-var=')) continue
141
+ else if (a === '-x' || a === '--exit' || a === '-o' || a === '--open-tty') continue
142
+ else return { refuse: `option ${a}` } // -p (prompts), unknown options
143
+ }
144
+ if (Number.isNaN(maxArgs) || Number.isNaN(maxLines)) return { refuse: 'a non-numeric count' }
145
+ const command = args.slice(i).length > 0 ? args.slice(i) : ['echo']
146
+ return { nul, delim, replace, replaceOne, maxArgs, maxLines, noRunIfEmpty, trace, eof, argFile, command }
147
+ }
148
+
149
+ /** Emulate xargs. Options it cannot emulate are refused rather than handed to the real xargs. */
150
+ export async function emulateXargs(interp: Interpreter, req: EmulationRequest): Promise<number | null> {
151
+ const plan = planXargs(req.argv.slice(1))
152
+ if ('refuse' in plan) return refuse(interp, req, plan.refuse)
153
+ const { nul, delim, replace, replaceOne, maxArgs, maxLines, noRunIfEmpty, trace, eof, argFile, command } = plan
154
+ let text: string
155
+ if (argFile !== null) {
156
+ try {
157
+ text = fs.readFileSync(path.resolve(req.proc.state.cwd, argFile), 'utf8')
158
+ } catch (e) {
159
+ interp.errorMessage(req.proc, `xargs: ${argFile}: ${(e as NodeJS.ErrnoException).code === 'ENOENT' ? 'No such file or directory' : (e as Error).message}`)
160
+ return 1
161
+ }
162
+ } else {
163
+ const stdin = req.proc.fds.get(0)
164
+ text = stdin?.readable ? new TextDecoder().decode(await stdin.readAll()) : ''
165
+ }
166
+
167
+ // build the list of invocations
168
+ const invocations: string[][] = []
169
+ if (replace !== null) {
170
+ const lines = text.split(nul ? '\0' : delim ?? '\n').map((l) => (nul || delim ? l : l.replace(/^[ \t]+/, ''))).filter((l) => l !== '')
171
+ for (const line of lines) {
172
+ if (eof !== null && line === eof) break
173
+ invocations.push(command.map((c) => c.split(replace!).join(line)))
174
+ }
175
+ } else {
176
+ let items: string[]
177
+ try {
178
+ items = nul ? text.split('\0').filter((x) => x !== '') : delim !== null ? text.split(delim).filter((x, k, all) => !(k === all.length - 1 && x === '')) : xargsTokens(text)
179
+ } catch (e) {
180
+ interp.errorMessage(req.proc, `xargs: ${(e as Error).message}`)
181
+ return 1
182
+ }
183
+ if (eof !== null) {
184
+ const k = items.indexOf(eof)
185
+ if (k >= 0) items = items.slice(0, k)
186
+ }
187
+ if (maxLines > 0 && !nul && delim === null) {
188
+ const lines = text.split('\n').filter((l) => l.trim() !== '')
189
+ for (let k = 0; k < lines.length; k += maxLines) invocations.push([...command, ...xargsTokens(lines.slice(k, k + maxLines).join('\n'))])
190
+ } else {
191
+ const batch = maxArgs === Infinity ? 5000 : maxArgs
192
+ // BSD -J: the items replace the first argument equal to the marker
193
+ const at = replaceOne === null ? -1 : command.indexOf(replaceOne)
194
+ const place = (chunk: string[]): string[] => (at === -1 ? [...command, ...chunk] : [...command.slice(0, at), ...chunk, ...command.slice(at + 1)])
195
+ for (let k = 0; k < items.length; k += batch) invocations.push(place(items.slice(k, k + batch)))
196
+ }
197
+ if (items.length === 0 && !noRunIfEmpty) invocations.push(replaceOne === null ? [...command] : command.filter((c) => c !== replaceOne))
198
+ }
199
+
200
+ // run each invocation through the VM with stdin from /dev/null
201
+ let status = 0
202
+ const fds = req.proc.fds.clone()
203
+ fds.set(0, new NullFile(interp.rt.channel('null', '/dev/null'), interp.rt.listeners))
204
+ const child = req.proc.derive({ fds })
205
+ try {
206
+ for (const argv of invocations) {
207
+ if (trace) await interp.write(child, 2, argv.join(' ') + '\n').catch(() => {})
208
+ const s = await interp.runArgv(argv, child, req.node, { skipFunctions: true })
209
+ if (s === 255) return 124
210
+ if (s === 127 || s === 126) return s
211
+ if (s > 128) return 125
212
+ if (s !== 0) status = BSD ? 1 : 123
213
+ }
214
+ } finally {
215
+ fds.closeAll()
216
+ }
217
+ return status
218
+ }
219
+
220
+ const FIND_ACTIONS = new Set(['-exec', '-execdir', '-ok', '-okdir', '-delete'])
221
+ /** Primaries with output or side effects: with one of these besides the action, listing matches would not be equivalent. */
222
+ const FIND_OUTPUTS = new Set(['-print', '-print0', '-printf', '-fprint', '-fprint0', '-fprintf', '-ls', '-fls'])
223
+
224
+ export type FindPlan =
225
+ | { kind: 'real' }
226
+ | { kind: 'refuse'; what: string }
227
+ | { kind: 'emulate'; action: string; cmd: string[]; batch: boolean; before: string[]; printEach: boolean }
228
+
229
+ /**
230
+ * How `find ARGS` is run. The action is emulated when it is the last element
231
+ * of the expression and the only action: then `find TESTS -print0`, with the
232
+ * action replaced in place, lists exactly the files it would act on, however
233
+ * complex the tests before it are (parentheses, `-o`, `-prune`, `-not`).
234
+ * An action inside parentheses or followed by more expression, several
235
+ * actions, and the prompting `-ok`/`-okdir` are refused.
236
+ */
237
+ export function planFind(args: string[]): FindPlan {
238
+ const actionAt = args.findIndex((a) => FIND_ACTIONS.has(a))
239
+ if (actionAt === -1) return { kind: 'real' }
240
+ const action = args[actionAt]
241
+ // -ok/-okdir prompt for each match: not emulated, and the real find would run them unchecked
242
+ if (action === '-ok' || action === '-okdir') return { kind: 'refuse', what: action }
243
+ let cmd: string[] = []
244
+ let batch = false
245
+ let end = actionAt + 1
246
+ if (action === '-exec' || action === '-execdir') {
247
+ const term = args.findIndex((a, k) => k > actionAt && (a === ';' || a === '+'))
248
+ // no terminator, or `{}` not last before `+`: the real find reports the usage error without running anything
249
+ if (term === -1) return { kind: 'real' }
250
+ cmd = args.slice(actionAt + 1, term)
251
+ batch = args[term] === '+'
252
+ if (batch && cmd[cmd.length - 1] !== '{}') return { kind: 'real' }
253
+ end = term + 1
254
+ }
255
+ let before = args.slice(0, actionAt)
256
+ // `find … -print -delete`: print each match before acting on it
257
+ const printEach = before[before.length - 1] === '-print'
258
+ if (printEach) before = before.slice(0, -1)
259
+ if (end !== args.length) return { kind: 'refuse', what: `${action} followed by more expression (or inside parentheses)` }
260
+ if (before.some((a) => FIND_OUTPUTS.has(a))) return { kind: 'refuse', what: `${action} together with other output actions` }
261
+ return { kind: 'emulate', action, cmd, batch, before, printEach }
262
+ }
263
+
264
+ /**
265
+ * Emulate `find`'s `-exec`/`-execdir`/`-delete` actions (see {@link planFind}).
266
+ * Returns null to run the real find unchanged when there is no action.
267
+ */
268
+ export async function emulateFind(interp: Interpreter, req: EmulationRequest): Promise<number | null> {
269
+ const plan = planFind(req.argv.slice(1))
270
+ if (plan.kind === 'real') return null
271
+ if (plan.kind === 'refuse') return refuse(interp, req, plan.what)
272
+ const { action, cmd, batch, before, printEach } = plan
273
+
274
+ // 1. list matches with the real find (a read-only invocation)
275
+ const listArgv = [req.argv[0], ...before, ...(action === '-delete' ? ['-depth'] : []), '-print0']
276
+ const capture = new Capture(interp.rt.channel('capture', `find matches (${listArgv.join(' ')})`, { nodeId: req.node.id }), interp.rt.listeners)
277
+ const fds = req.proc.fds.clone()
278
+ fds.set(1, capture.retain())
279
+ const lister = req.proc.derive({ fds })
280
+ let findStatus: number
281
+ try {
282
+ findStatus = await runExternal(interp, { execId: req.execId, path: req.path, argv: listArgv, env: req.env, cwd: req.proc.state.cwd, proc: lister, readsStdin: false })
283
+ } finally {
284
+ fds.closeAll()
285
+ }
286
+ const matches = capture.text().split('\0').filter((m) => m !== '')
287
+ capture.release()
288
+
289
+ // 2. run the action per match (or batched for `+`) through the VM
290
+ let status = findStatus === 0 ? 0 : 1
291
+ const run = async (argv: string[]): Promise<number> => interp.runArgv(argv, req.proc, req.node, { skipFunctions: true })
292
+ if (printEach) await interp.write(req.proc, 1, matches.map((m) => m + '\n').join('')).catch(() => {})
293
+ if (action === '-delete') {
294
+ for (const m of matches) {
295
+ if (m === '.' || m === path.resolve(req.proc.state.cwd)) continue
296
+ const s = await run(['rm', '-df', '--', m])
297
+ if (s !== 0) status = 1
298
+ }
299
+ return status
300
+ }
301
+ if (action === '-execdir') {
302
+ // each command runs in the directory of its match, with {} as ./NAME
303
+ const groups = new Map<string, string[]>()
304
+ for (const m of matches) {
305
+ const dir = path.resolve(req.proc.state.cwd, path.dirname(m))
306
+ const name = './' + path.basename(m)
307
+ if (batch) groups.set(dir, [...(groups.get(dir) ?? []), name])
308
+ else groups.set(`${groups.size}\0${dir}`, [name])
309
+ }
310
+ for (const [key, names] of groups) {
311
+ const dir = key.includes('\0') ? key.slice(key.indexOf('\0') + 1) : key
312
+ const sub = req.proc.subshell(interp.rt.nextPid())
313
+ sub.state.cwd = dir
314
+ const pwd = sub.state.ensure('PWD')
315
+ if (!pwd.readonly) pwd.value = { kind: 'string', value: dir }
316
+ try {
317
+ const argv = batch ? [...cmd.slice(0, -1), ...names] : cmd.map((c) => c.split('{}').join(names[0]))
318
+ const s = await interp.runArgv(argv, sub, req.node, { skipFunctions: true })
319
+ if (s !== 0 && batch) status = 1
320
+ } finally {
321
+ sub.fds.closeAll()
322
+ }
323
+ }
324
+ return status
325
+ }
326
+ if (batch) {
327
+ for (let k = 0; k < matches.length; k += 5000) {
328
+ const argv = [...cmd.slice(0, -1), ...matches.slice(k, k + 5000)]
329
+ if ((await run(argv)) !== 0) status = 1
330
+ }
331
+ return status
332
+ }
333
+ for (const m of matches) await run(cmd.map((c) => c.split('{}').join(m)))
334
+ return status
335
+ }
336
+
337
+ export { FdTable }