From fb15ea8b7d37fd015257db5f6346d20c63415f91 Mon Sep 17 00:00:00 2001 From: Ivar Vong Date: Thu, 2 Jul 2026 17:28:33 -0400 Subject: [PATCH] fix: printf %b support and no-progress guard against format-recycle hangs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit printf recycles its format while arguments remain, but a directive the format regex doesn't recognize consumes nothing — so printf "%b" "x" (or any unknown directive with an argument) looped forever, hanging the host process. %b is now a real directive sharing echo -e's escape dialect, and a recycle pass that consumes no arguments stops after one iteration instead of recursing. Unknown directives pass through literally where bash errors; the divergence is noted in the tests. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FpxHS5iGm9ktgpnL9QZu9x --- lib/just_bash/commands/echo.ex | 6 +++++- lib/just_bash/commands/printf.ex | 16 ++++++++++++++-- test/commands/utilities_test.exs | 30 ++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/lib/just_bash/commands/echo.ex b/lib/just_bash/commands/echo.ex index 8d0c683..f79a08e 100644 --- a/lib/just_bash/commands/echo.ex +++ b/lib/just_bash/commands/echo.ex @@ -35,7 +35,11 @@ defmodule JustBash.Commands.Echo do defp parse_flags(rest, flags), do: {flags, rest} - defp interpret_escapes(str) do + # Also used by printf's %b directive, which shares echo -e's escape + # dialect (\0NNN octals, unlike $'...' ANSI-C quoting's \NNN). + @doc false + @spec interpret_escapes(String.t()) :: binary() + def interpret_escapes(str) do do_interpret_escapes(str, "") end diff --git a/lib/just_bash/commands/printf.ex b/lib/just_bash/commands/printf.ex index f970a94..a3e90b1 100644 --- a/lib/just_bash/commands/printf.ex +++ b/lib/just_bash/commands/printf.ex @@ -3,8 +3,9 @@ defmodule JustBash.Commands.Printf do @behaviour JustBash.Commands.Command alias JustBash.Commands.Command + alias JustBash.Commands.Echo - @format_regex ~r/%(-)?(0)?(\d+)?(?:\.(\d+))?([sdxXofec%])/ + @format_regex ~r/%(-)?(0)?(\d+)?(?:\.(\d+))?([sdxXofecb%])/ @impl true def names, do: ["printf"] @@ -42,7 +43,14 @@ defmodule JustBash.Commands.Printf do defp recycle_format(format, args, acc) do {result, remaining} = apply_formats_with_remaining(format, args) - recycle_format(format, remaining, [result | acc]) + + if length(remaining) < length(args) do + recycle_format(format, remaining, [result | acc]) + else + # A pass that consumed no arguments (an unrecognized directive) must + # not recycle the format forever. + recycle_format(format, [], [result | acc]) + end end defp apply_formats_with_remaining(format, args) do @@ -97,6 +105,10 @@ defmodule JustBash.Commands.Printf do {padded, []} end + defp do_format("b", arg, _precision) do + Echo.interpret_escapes(arg) + end + defp do_format("s", arg, precision) do case precision do "" -> arg diff --git a/test/commands/utilities_test.exs b/test/commands/utilities_test.exs index 02f0b6a..ac26725 100644 --- a/test/commands/utilities_test.exs +++ b/test/commands/utilities_test.exs @@ -136,6 +136,36 @@ defmodule JustBash.Commands.UtilitiesTest do assert result.stdout == "a\tb" end + test "printf %b expands escape sequences in the argument" do + bash = JustBash.new() + {result, _} = JustBash.exec(bash, ~S[printf "%b" "A\tB\n"]) + assert result.stdout == "A\tB\n" + assert result.exit_code == 0 + end + + @tag timeout: 5_000 + test "printf %b with hex escapes emits raw bytes and terminates" do + bash = JustBash.new() + {result, _} = JustBash.exec(bash, ~S[printf "%b" "A\xffB\n"]) + assert result.stdout == <> + end + + test "printf %b recycles the format across arguments" do + bash = JustBash.new() + {result, _} = JustBash.exec(bash, ~S[printf "%b\n" "a\tb" "c"]) + assert result.stdout == "a\tb\nc\n" + end + + # Bash errors on unknown directives; we pass them through literally. + # Either way, a directive that consumes no argument must not recycle + # the format forever. + @tag timeout: 5_000 + test "printf with an unrecognized directive terminates instead of looping" do + bash = JustBash.new() + {result, _} = JustBash.exec(bash, ~S[printf "%v\n" x]) + assert result.stdout == "%v\n" + end + test "printf with %x hex format" do bash = JustBash.new() {result, _} = JustBash.exec(bash, "printf '%x' 255")