From 224ce9c4f7688262ae437fd5bb348fc2e5fef73e Mon Sep 17 00:00:00 2001 From: Anton Vlasenko Date: Thu, 16 Oct 2025 14:44:56 +0200 Subject: [PATCH 1/3] Wrap exec command call with shell to avoid corrupted output --- lib/spoom/context/exec.rb | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/spoom/context/exec.rb b/lib/spoom/context/exec.rb index db3e46cd..a894b2fb 100644 --- a/lib/spoom/context/exec.rb +++ b/lib/spoom/context/exec.rb @@ -30,11 +30,17 @@ def exec(command, capture_err: true) Bundler.with_unbundled_env do opts = { chdir: absolute_path } #: Hash[Symbol, untyped] + # Work around Open3 buffering bug in certain Ruby builds (e.g., Nix Ruby). + # Direct binary execution via Open3.capture3 can produce corrupted output. + # Prepending "true &&" forces Ruby to invoke the shell, which prevents corruption. + # The "true" command is a no-op that always succeeds. + command_with_shell = "true && #{command}" + if capture_err - out, err, status = Open3.capture3(command, opts) + out, err, status = Open3.capture3(command_with_shell, opts) ExecResult.new(out: out, err: err, status: T.must(status.success?), exit_code: T.must(status.exitstatus)) else - out, status = Open3.capture2(command, opts) + out, status = Open3.capture2(command_with_shell, opts) ExecResult.new(out: out, err: nil, status: T.must(status.success?), exit_code: T.must(status.exitstatus)) end end From f642040dec78c5f15fe1649ade1f2073e5ae5fee Mon Sep 17 00:00:00 2001 From: Anton Vlasenko Date: Thu, 16 Oct 2025 16:24:40 +0200 Subject: [PATCH 2/3] Add tests for shell wrapping of exec command --- test/spoom/context/exec_test.rb | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/spoom/context/exec_test.rb b/test/spoom/context/exec_test.rb index 358b6604..6aeaa020 100644 --- a/test/spoom/context/exec_test.rb +++ b/test/spoom/context/exec_test.rb @@ -9,9 +9,11 @@ class ExecTest < Minitest::Test def test_context_exec context = Context.mktmp! - assert_raises(Errno::ENOENT) do - context.exec("command/not/found") - end + # Test that unknown commands return a failed status (not raise an exception) + # due to shell wrapping in exec method + res = context.exec("command_that_does_not_exist") + refute(res.status) + refute_empty(res.err) res = context.exec("echo 'Hello, world!'") assert_equal("Hello, world!\n", res.out) From 29e551ac25a371bd2dbd5d8b03c7fb07db1f1208 Mon Sep 17 00:00:00 2001 From: Emily Samp Date: Fri, 21 Nov 2025 16:08:07 -0600 Subject: [PATCH 3/3] Use `/bin/sh -c` instead of `true &&` --- lib/spoom/context/exec.rb | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/lib/spoom/context/exec.rb b/lib/spoom/context/exec.rb index a894b2fb..b0ac3ab7 100644 --- a/lib/spoom/context/exec.rb +++ b/lib/spoom/context/exec.rb @@ -1,6 +1,8 @@ # typed: strict # frozen_string_literal: true +require "shellwords" + module Spoom class ExecResult < T::Struct const :out, String @@ -30,11 +32,15 @@ def exec(command, capture_err: true) Bundler.with_unbundled_env do opts = { chdir: absolute_path } #: Hash[Symbol, untyped] - # Work around Open3 buffering bug in certain Ruby builds (e.g., Nix Ruby). - # Direct binary execution via Open3.capture3 can produce corrupted output. - # Prepending "true &&" forces Ruby to invoke the shell, which prevents corruption. - # The "true" command is a no-op that always succeeds. - command_with_shell = "true && #{command}" + # When Ruby is wrapped in another dev environment (e.g. Nix), that environment might set + # env variables that cause Sorbet (or other tools) to link to incorrect versions of + # dependencies. + # + # In the case of Sorbet, this can lead to corrupted JSON output. + # + # Executing the command directly through the shell bypasses any Ruby wrappers and + # ensures that we are using the versions of tools that are default on the system. + command_with_shell = "/bin/sh -c #{command.shellescape}" if capture_err out, err, status = Open3.capture3(command_with_shell, opts)