Skip to content

Commit 99221bf

Browse files
committed
Fix compliance shell portability
1 parent 7f6323e commit 99221bf

2 files changed

Lines changed: 137 additions & 48 deletions

File tree

lib/unified/spec_compliance/evidence.ex

Lines changed: 116 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,14 @@ defmodule Unified.SpecCompliance.Evidence do
2828

2929
@spec run_with_cache([map()], String.t(), Keyword.t(), String.t(), map()) :: {[map()], map()}
3030
def run_with_cache(evidence, root, opts, requirement_id, cache) do
31-
run_commands? = Keyword.get(opts, :run_commands, true)
31+
command_opts = %{
32+
run_commands: Keyword.get(opts, :run_commands, true),
33+
shell: Keyword.get(opts, :shell)
34+
}
3235

3336
Enum.reduce(evidence, {[], cache}, fn item, {findings, acc_cache} ->
3437
{item_findings, next_cache} =
35-
run_item_with_cache(item, root, run_commands?, requirement_id, acc_cache)
38+
run_item_with_cache(item, root, command_opts, requirement_id, acc_cache)
3639

3740
{findings ++ item_findings, next_cache}
3841
end)
@@ -101,7 +104,7 @@ defmodule Unified.SpecCompliance.Evidence do
101104
]
102105
end
103106

104-
defp run_item(%{"kind" => "path_exists", "path" => path}, root, _run_commands?, requirement_id) do
107+
defp run_item(%{"kind" => "path_exists", "path" => path}, root, _command_opts, requirement_id) do
105108
absolute_path = Path.expand(path, root)
106109

107110
if File.exists?(absolute_path) do
@@ -119,7 +122,7 @@ defmodule Unified.SpecCompliance.Evidence do
119122
end
120123
end
121124

122-
defp run_item(%{"kind" => "path_absent", "path" => path}, root, _run_commands?, requirement_id) do
125+
defp run_item(%{"kind" => "path_absent", "path" => path}, root, _command_opts, requirement_id) do
123126
absolute_path = Path.expand(path, root)
124127

125128
if File.exists?(absolute_path) do
@@ -140,7 +143,7 @@ defmodule Unified.SpecCompliance.Evidence do
140143
defp run_item(
141144
%{"kind" => "path_glob_nonempty", "glob" => glob},
142145
root,
143-
_run_commands?,
146+
_command_opts,
144147
requirement_id
145148
) do
146149
if root |> Path.join(glob) |> Path.wildcard() |> Enum.any?() do
@@ -158,7 +161,7 @@ defmodule Unified.SpecCompliance.Evidence do
158161
end
159162
end
160163

161-
defp run_item(%{"kind" => "command"} = item, _root, false, requirement_id) do
164+
defp run_item(%{"kind" => "command"} = item, _root, %{run_commands: false}, requirement_id) do
162165
[
163166
%{
164167
code: "command_skipped",
@@ -171,81 +174,101 @@ defmodule Unified.SpecCompliance.Evidence do
171174
]
172175
end
173176

174-
defp run_item(%{"kind" => "command"} = item, root, true, requirement_id) do
177+
defp run_item(
178+
%{"kind" => "command"} = item,
179+
root,
180+
%{run_commands: true, shell: shell_override},
181+
requirement_id
182+
) do
175183
cwd = Path.expand(item["cwd"] || ".", root)
176184
expected_exit_status = item["expect_exit_status"] || 0
177185
expected_stdout = item["expect_stdout_contains"]
178186

179-
{output, exit_status} =
180-
System.cmd("zsh", ["-lc", item["run"]], cd: cwd, stderr_to_stdout: true)
181-
182-
findings = []
183-
184-
findings =
185-
if exit_status == expected_exit_status do
186-
findings
187-
else
188-
[
189-
%{
190-
code: "command_exit_status_mismatch",
191-
severity: :error,
192-
requirement_id: requirement_id,
193-
command: item["run"],
194-
message:
195-
"Expected command #{inspect(item["run"])} to exit with #{expected_exit_status}, got #{exit_status}"
196-
}
197-
| findings
198-
]
199-
end
200-
201-
findings =
202-
if is_binary(expected_stdout) and not String.contains?(output, expected_stdout) do
187+
case command_shell(root, shell_override) do
188+
{:ok, shell} ->
189+
{output, exit_status} =
190+
System.cmd(shell, ["-c", item["run"]], cd: cwd, stderr_to_stdout: true)
191+
192+
findings = []
193+
194+
findings =
195+
if exit_status == expected_exit_status do
196+
findings
197+
else
198+
[
199+
%{
200+
code: "command_exit_status_mismatch",
201+
severity: :error,
202+
requirement_id: requirement_id,
203+
command: item["run"],
204+
message:
205+
"Expected command #{inspect(item["run"])} to exit with #{expected_exit_status}, got #{exit_status}"
206+
}
207+
| findings
208+
]
209+
end
210+
211+
findings =
212+
if is_binary(expected_stdout) and not String.contains?(output, expected_stdout) do
213+
[
214+
%{
215+
code: "command_stdout_mismatch",
216+
severity: :error,
217+
requirement_id: requirement_id,
218+
command: item["run"],
219+
message:
220+
"Expected command #{inspect(item["run"])} output to include #{inspect(expected_stdout)}"
221+
}
222+
| findings
223+
]
224+
else
225+
findings
226+
end
227+
228+
Enum.reverse(findings)
229+
230+
{:error, message} ->
203231
[
204232
%{
205-
code: "command_stdout_mismatch",
233+
code: "command_shell_unavailable",
206234
severity: :error,
207235
requirement_id: requirement_id,
208236
command: item["run"],
209-
message:
210-
"Expected command #{inspect(item["run"])} output to include #{inspect(expected_stdout)}"
237+
message: message
211238
}
212-
| findings
213239
]
214-
else
215-
findings
216-
end
217-
218-
Enum.reverse(findings)
240+
end
219241
end
220242

221-
defp run_item(_item, _root, _run_commands?, _requirement_id), do: []
243+
defp run_item(_item, _root, _command_opts, _requirement_id), do: []
222244

223245
defp run_item_with_cache(
224246
%{"kind" => "command"} = item,
225247
root,
226-
run_commands?,
248+
command_opts,
227249
requirement_id,
228250
cache
229251
) do
230-
key = command_cache_key(item, root, run_commands?)
252+
key = command_cache_key(item, root, command_opts)
231253

232254
case Map.fetch(cache, key) do
233255
{:ok, cached_findings} ->
234256
{restore_requirement_id(cached_findings, requirement_id), cache}
235257

236258
:error ->
237-
findings = run_item(item, root, run_commands?, requirement_id)
259+
findings = run_item(item, root, command_opts, requirement_id)
238260
{findings, Map.put(cache, key, strip_requirement_id(findings))}
239261
end
240262
end
241263

242-
defp run_item_with_cache(item, root, run_commands?, requirement_id, cache) do
243-
{run_item(item, root, run_commands?, requirement_id), cache}
264+
defp run_item_with_cache(item, root, command_opts, requirement_id, cache) do
265+
{run_item(item, root, command_opts, requirement_id), cache}
244266
end
245267

246-
defp command_cache_key(item, root, run_commands?) do
268+
defp command_cache_key(item, root, command_opts) do
247269
{
248-
run_commands?,
270+
command_opts.run_commands,
271+
command_opts.shell,
249272
Path.expand(item["cwd"] || ".", root),
250273
item["run"],
251274
item["expect_exit_status"] || 0,
@@ -261,6 +284,51 @@ defmodule Unified.SpecCompliance.Evidence do
261284
Enum.map(findings, &Map.put(&1, :requirement_id, requirement_id))
262285
end
263286

287+
defp command_shell(root, shell_override) do
288+
candidates =
289+
[
290+
shell_override,
291+
System.get_env("SHELL"),
292+
System.find_executable("zsh"),
293+
System.find_executable("bash"),
294+
System.find_executable("sh")
295+
]
296+
|> Enum.flat_map(&normalize_shell_candidate(&1, root))
297+
|> Enum.uniq()
298+
299+
case Enum.find(candidates, &File.exists?/1) do
300+
nil ->
301+
{:error,
302+
"No supported command shell was found. Expected one of SHELL, zsh, bash, or sh to be available"}
303+
304+
shell ->
305+
{:ok, shell}
306+
end
307+
end
308+
309+
defp normalize_shell_candidate(nil, _root), do: []
310+
defp normalize_shell_candidate("", _root), do: []
311+
312+
defp normalize_shell_candidate(shell, root) do
313+
shell
314+
|> String.trim()
315+
|> case do
316+
"" ->
317+
[]
318+
319+
value ->
320+
expanded =
321+
if Path.type(value) == :absolute do
322+
value
323+
else
324+
Path.expand(value, root)
325+
end
326+
327+
[value, System.find_executable(value), expanded]
328+
|> Enum.filter(&(&1 != ""))
329+
end
330+
end
331+
264332
defp require_string(map, key, file, requirement_id, code) do
265333
case map[key] do
266334
value when is_binary(value) and value != "" ->

test/spec_compliance/evidence_test.exs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,25 @@ defmodule Unified.SpecCompliance.EvidenceTest do
4141

4242
assert Enum.any?(findings, &(&1.code == "command_stdout_mismatch"))
4343
end
44+
45+
test "runs command evidence with a portable shell override" do
46+
root = tmp_root!("command_shell")
47+
shell = System.find_executable("sh")
48+
49+
assert is_binary(shell)
50+
51+
assert [] ==
52+
Evidence.run(
53+
[
54+
%{
55+
"kind" => "command",
56+
"run" => "! test -f missing.txt",
57+
"cwd" => "."
58+
}
59+
],
60+
root,
61+
[run_commands: true, shell: shell],
62+
"demo.package.portable_shell"
63+
)
64+
end
4465
end

0 commit comments

Comments
 (0)