From 9ca54880462db7d0c2f91d6e8f114294458fe08d Mon Sep 17 00:00:00 2001 From: Rasmus Villemoes Date: Mon, 20 Jul 2026 14:49:06 +0200 Subject: [PATCH 1/6] profile/systemd-osc-context: do not use sed for escaping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bash is perfectly capable of performing the simple substitutions needed for escaping according to the OSC 3008 spec, so there is no need for the fork+exec and other overhead of calling sed. In fact, when writing a test for ensuring that this is a drop-in replacement, I found out that the current sed method is flawed: If $PWD contains newline characters, they are passed through unchanged, because sed obviously is line-oriented. I do not know which bash version started supporting ${foo//pattern/replacement}, but I ran the below on all Debian images from docker hub going back to Debian 6 (EOL 2016), carrying BASH_VERSION = 4.1.5(1)-release, and they all succeeded. Since the logic otherwise relies on PROMPT_COMMAND being an array variable, which happened in 5.1, this should be all good. === test.sh === #!/bin/bash echo "BASH_VERSION = $BASH_VERSION" ret=0 using_sed() { echo "$1" | sed -e 's/\\/\\x5c/g' -e 's/;/\\x3b/g' -e 's/[[:cntrl:]]/⍰/g' } pure_bash() { local str="$1" str="${str//\\/\\x5c}" str="${str//;/\\x3b}" str="${str//[[:cntrl:]]/⍰}" printf "%s" "${str}" } do_test() { local r0="$1" local r1="$(using_sed "$r0")" local r2="$(pure_bash "$r0")" local s0="$(printf '%s' "$r0" | od -A x -t x1z -w40 | head -n1)" local s1="$(printf '%s' "$r1" | od -A x -t x1z -w40 | head -n1)" local s2="$(printf '%s' "$r2" | od -A x -t x1z -w40 | head -n1)" if [ "$r1" != "$r2" ] || [ "$s1" != "$s2" ] ; then echo "Input: $s0" echo "sed: $s1" echo "bash: $s2" ret=1 fi } do_test '' do_test "one ; semicolon" do_test "two ; semicolons ;" do_test 'bs \ sc ; bs \ sc ;' do_test $'\t' do_test $'sc ; cntrl \x01 \x02 \x03 \x1f \t bs \\ bs \\ sc ; ' do_test $'sc ; tab \x09 del \x7f bs \\ ;' # The last cases show that the existing function doesn't actually work in # the case of $PWD containing a newline character, because sed is # line-oriented, so a newline character will never be replaced. if [ "$1" = "all" ] ; then do_test $'embedded \n newline' do_test $'ending in newline\n' fi exit "$ret" === test.sh === --- profile.d/80-systemd-osc-context.sh | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/profile.d/80-systemd-osc-context.sh b/profile.d/80-systemd-osc-context.sh index ea8f456caf..9bbb71285f 100644 --- a/profile.d/80-systemd-osc-context.sh +++ b/profile.d/80-systemd-osc-context.sh @@ -35,13 +35,17 @@ shopt -q promptvars || return 0 __systemd_osc_context_escape() { - # Escape according to the OSC 3008 spec. Since this requires shelling out - # to 'sed' we'll only do it where it's strictly necessary, and skip it when - # processing strings we are pretty sure we won't need it for, such as - # uuids, id128, hostnames, usernames, since they all come with syntax - # requirements that exclude \ and ; anyway. This hence primarily is about - # escaping the current working directory. - echo "$1" | sed -e 's/\\/\\x5c/g' -e 's/;/\\x3b/g' -e 's/[[:cntrl:]]/⍰/g' + # Escape according to the OSC 3008 spec. We'll only do it where it's + # strictly necessary, and skip it when processing strings we are pretty + # sure we won't need it for, such as uuids, id128, hostnames, usernames, + # since they all come with syntax requirements that exclude \ and ; + # anyway. This hence primarily is about escaping the current working + # directory. + local systemd_value="$1" + systemd_value="${systemd_value//\\/\\x5c}" + systemd_value="${systemd_value//;/\\x3b}" + systemd_value="${systemd_value//[[:cntrl:]]/⍰}" + printf "%s" "$systemd_value" } __systemd_osc_context_common() { From 10a356629ad038eaa8e723e3cb9b99a18f87ae03 Mon Sep 17 00:00:00 2001 From: Rasmus Villemoes Date: Mon, 20 Jul 2026 15:04:21 +0200 Subject: [PATCH 2/6] profile/systemd-osc-context: do not zero-pad pid value The %.20d specifier was introduced in 2d738a0aee ("profile/systemd-osc-context: Enforce length limits"). But, for numeric conversions, the precision is not an upper bound, but rather a lower bound on the output width, padding as necessary with 0 on the left. So as-is, this does not in fact limit the output to at most 20 characters. Of course, in practice, pids on linux are never greater than 2^22, and certainly never larger than what would fit in a 20-digit decimal. On the other hand, that more or less guarantees that the pid= field is always emitted with 12+ leadings zeroes, which is a bit silly. Moreover, a leading 0 can cause a parser to treat it as octal. Since $$ does expand to the PID in decimal, just print that as a string, with the enforced 20 character limit. --- profile.d/80-systemd-osc-context.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/profile.d/80-systemd-osc-context.sh b/profile.d/80-systemd-osc-context.sh index 9bbb71285f..6e6812ce4d 100644 --- a/profile.d/80-systemd-osc-context.sh +++ b/profile.d/80-systemd-osc-context.sh @@ -52,7 +52,7 @@ __systemd_osc_context_common() { if [ -f /etc/machine-id ]; then printf ";machineid=%.36s" "$( Date: Mon, 20 Jul 2026 15:37:34 +0200 Subject: [PATCH 3/6] profile/systemd-osc-context: don't do arithmetic expansion on systemd_exitstatus There's really no point in having bash do arithmetic expansion on the systemd_exitstatus variable, i.e. have it convert the string to a number, do no actual arithmetic, then convert it back to a string to be used as the argument to the printf, which will again convert it to a number for the %d specifier, and finally emit it as a decimal. Also, it deviates for no obvious reason from how it is passed to printf in the "probably died by signal" case just above. --- profile.d/80-systemd-osc-context.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/profile.d/80-systemd-osc-context.sh b/profile.d/80-systemd-osc-context.sh index 6e6812ce4d..4cfecfed26 100644 --- a/profile.d/80-systemd-osc-context.sh +++ b/profile.d/80-systemd-osc-context.sh @@ -63,7 +63,7 @@ __systemd_osc_context_precmdline() { if [ "$systemd_exitstatus" -gt 128 ] && systemd_signal=$(kill -l "$systemd_exitstatus" 2>&-); then printf "\033]3008;end=%.64s;exit=failure;status=%d;signal=SIG%s\033\\" "$systemd_osc_context_cmd_id" "$systemd_exitstatus" "$systemd_signal" elif [ "$systemd_exitstatus" -ne 0 ]; then - printf "\033]3008;end=%.64s;exit=failure;status=%d\033\\" "$systemd_osc_context_cmd_id" $((systemd_exitstatus)) + printf "\033]3008;end=%.64s;exit=failure;status=%d\033\\" "$systemd_osc_context_cmd_id" "$systemd_exitstatus" else printf "\033]3008;end=%.64s;exit=success\033\\" "$systemd_osc_context_cmd_id" fi From ac4c3e42906e235674ac4a36327345dd092d176f Mon Sep 17 00:00:00 2001 From: Rasmus Villemoes Date: Tue, 21 Jul 2026 10:25:56 +0200 Subject: [PATCH 4/6] profile/systemd-osc-context: emit type= field first Not because it is required by the specification, but it will make the following patches simpler. Also, the fact that the spec explicitly does call out that type= can appear at the end or in the middle suggests that people would normally expect it to appear at the beginning. --- profile.d/80-systemd-osc-context.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/profile.d/80-systemd-osc-context.sh b/profile.d/80-systemd-osc-context.sh index 4cfecfed26..8eff01cee7 100644 --- a/profile.d/80-systemd-osc-context.sh +++ b/profile.d/80-systemd-osc-context.sh @@ -75,7 +75,7 @@ __systemd_osc_context_precmdline() { fi # Create or update the shell session - printf "\033]3008;start=%.64s%s;type=shell;cwd=%.255s\033\\" "$systemd_osc_context_shell_id" "$(__systemd_osc_context_common)" "$(__systemd_osc_context_escape "$PWD")" + printf "\033]3008;start=%.64s;type=shell%s;cwd=%.255s\033\\" "$systemd_osc_context_shell_id" "$(__systemd_osc_context_common)" "$(__systemd_osc_context_escape "$PWD")" # Prepare cmd id for next command read -r systemd_osc_context_cmd_id Date: Tue, 21 Jul 2026 10:32:27 +0200 Subject: [PATCH 5/6] profile/systemd-osc-context: fold emitting cwd= field into __systemd_osc_context_common Now that the %s specifier used to embed the common fields in the output appears immediately before the cwd= field, we can just emit that cwd= field as part of the common fields. --- profile.d/80-systemd-osc-context.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/profile.d/80-systemd-osc-context.sh b/profile.d/80-systemd-osc-context.sh index 8eff01cee7..6b4aa348cf 100644 --- a/profile.d/80-systemd-osc-context.sh +++ b/profile.d/80-systemd-osc-context.sh @@ -53,6 +53,7 @@ __systemd_osc_context_common() { printf ";machineid=%.36s" "$( Date: Tue, 21 Jul 2026 10:43:22 +0200 Subject: [PATCH 6/6] profile/systemd-osc-context: also escape $USER and $HOSTNAME Claude pointed out that while rather unlikely, it is possible for the USER and HOSTNAME environment variables to contain problematic characters that require escaping. Now that that escaping no longer involves the fork+exec overhead of calling sed, let's ensure those fields do get put through the escape routine. Refactor __systemd_osc_context_escape so that it not only takes the value and prints that, but also the format specifier used for emitting the field. That way, we avoid an extra subshell (i.e. fork+pipe and all that overhead), and combined with the previous commit, we now only spawn one subshell instead of two per start= sequence. That could be reduced to zero, if the "callers" were rewritten to something like printf "\033]3008;start=%.64s;type=shell" "$systemd_osc_context_shell_id" __systemd_osc_context_common printf "\033\\" but that will mean that the sequence is not emitted with a single write() system call [which isn't really guaranteed currently either, just very likely], so some background process could end up writing to the middle of the sequence, thus losing that output and mangling the OSC3008 info. This in turn could be overcome by building the whole sequence in a shell variable using 'printf -v' and only printing at the end, but that would be a somewhat invasive change. --- profile.d/80-systemd-osc-context.sh | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/profile.d/80-systemd-osc-context.sh b/profile.d/80-systemd-osc-context.sh index 6b4aa348cf..80c4740d47 100644 --- a/profile.d/80-systemd-osc-context.sh +++ b/profile.d/80-systemd-osc-context.sh @@ -35,25 +35,27 @@ shopt -q promptvars || return 0 __systemd_osc_context_escape() { - # Escape according to the OSC 3008 spec. We'll only do it where it's - # strictly necessary, and skip it when processing strings we are pretty - # sure we won't need it for, such as uuids, id128, hostnames, usernames, - # since they all come with syntax requirements that exclude \ and ; - # anyway. This hence primarily is about escaping the current working - # directory. - local systemd_value="$1" + # Escape according to the OSC 3008 spec. We'll skip it when processing + # strings we are pretty sure we won't need it for, such as uuids + # (originating from /proc/sys/kernel/ or /etc/machine-id) and special shell + # variables such as $$ and $? that always expand to decimal numbers. + local systemd_format="$1" + local systemd_value="$2" systemd_value="${systemd_value//\\/\\x5c}" systemd_value="${systemd_value//;/\\x3b}" systemd_value="${systemd_value//[[:cntrl:]]/⍰}" - printf "%s" "$systemd_value" + # shellcheck disable=SC2059 # always called with fixed format strings with a single string specifier + printf "$systemd_format" "$systemd_value" } __systemd_osc_context_common() { if [ -f /etc/machine-id ]; then printf ";machineid=%.36s" "$(