How to properly escape recipe newlines in multi-line variable? - makefile

The Question
Example 1
Consider the following user-defined function:
define func =
if [ -f $(1) ]; then \
printf "'%s' is a file\n" '$(1)'; \
printf "This is a relatively long command that"; \
printf " won't fit on one line\n"; \
fi
endef
all:
$(call func,foo)
This will output the following:
$ make
if [ -f foo ]; then printf "'%s' is a file\n" 'foo'; printf "This is a rel
atively long command that"; printf " won't fit on one line\n"; fi
For readability, I would like make to print the command on multiple lines,
as written in the Makefile. How do I accomplish this?
What I've Tried
Example 2
The following works the way I want, but does not allow the parameterized
function:
filename := foo
.PHONY: foo
foo:
if [ -f $(filename) ]; then \
printf "'%s' is a file\n" '$(filename)'; \
printf "This is a relatively long command that"; \
printf " won't fit on one line\n"; \
fi
Output:
$ make foo
if [ -f foo ]; then \
printf "'%s' is a file\n" 'foo'; \
printf "This is a relatively long command that"; \
printf " won't fit on one line\n"; \
fi
Example 3
My obvious first instinct was to escape the backslashes:
define func =
if [ -f $(1) ]; then \\
printf "'%s' is a file\n" '$(1)'; \\
printf "This is a relatively long command that"; \\
printf " won't fit on one line\n"; \\
fi
endef
Output:
$ make
if [ -f foo ]; then \\
printf "'%s' is a file\n" 'foo'; \\
printf "This is a relatively long command that"; \\
printf " won't fit on one line\n"; \\
fi
/bin/sh: \: command not found
'foo' is a file
/bin/sh: line 1: \: command not found
This is a relatively long command that/bin/sh: line 2: \: command not found
won't fit on one line
/bin/sh: line 3: \: command not found
make: *** [Makefile:11: all] Error 127
Example 4
Okay, so why not try \\\?
$ make
if [ -f foo ]; then \ printf "'%s' is a file\n" 'foo'; \ printf "This is a
relatively long command that"; \ printf " won't fit on one line\n"; \ fi
/bin/sh: -c: line 1: syntax error: unexpected end of file
make: *** [Makefile:11: all] Error 1
Example 5
Interesting. Let's go for four...
$ make
if [ -f foo ]; then \\\\
printf "'%s' is a file\n" 'foo'; \\\\
printf "This is a relatively long command that"; \\\\
printf " won't fit on one line\n"; \\\\
fi
/bin/sh: \\: command not found
'foo' is a file
/bin/sh: line 1: \\: command not found
This is a relatively long command that/bin/sh: line 2: \\: command not found
won't fit on one line
/bin/sh: line 3: \\: command not found
make: *** [Makefile:11: all] Error 127
Now we're back to where we were last time.
What Works
Example 6
This is the only thing that seems to work:
define func =
if [ -f $(1) ]; then #\\
printf "'%s' is a file\n" '$(1)'; #\\
printf "This is a relatively long command that"; #\\
printf " won't fit on one line\n"; #\\
fi
endef
Output:
$ make
if [ -f foo ]; then #\\
printf "'%s' is a file\n" 'foo'; #\\
printf "This is a relatively long command that"; #\\
printf " won't fit on one line\n"; #\\
fi
But man, that looks ugly, and it feels hackish. There's got to be a better
way to do this. Or am I just going about this the wrong way in the first
place?
It seems to me that make is just confused by the magic that happens when
escaping newlines within a recipe. The lines printed to the terminal during
execution do not match what the shell sees. Should this be considered a bug?
I am using GNU Make 4.2.1 on Cygwin.
Edit
To clarify: make normally gives special treatment to trailing backslashes within a recipe. They do not indicate line continuation, as they do elsewhere. Instead, they indicate that multiple recipe lines are to be treated as a single command, and they are passed to the shell intact.
When not in a recipe, but defining a variable, this special treatment does not apply. The lines are simply joined, as in Example 1. This is expected.
I would expect that a double backslash would be translated to a single literal backslash in the variable, but instead both backslashes are retained. When the variable is expanded in the recipe, I would expect make to behave as if the recipe had \\ at the end of every line. If this were the case, each line would be executed separately. But as you can see from Examples 3 and 6, the lines are executed together.
The point is, it is possible to get magic backslash parsing from the expansion of a variable. The problem is the mechanics of this behavior are inconsistent and confusing.

Urk! This is due to make's noddy parser.
Recipes are stored as-is.
They are expanded as-and-when make is about to call the shell.
Once the entire recipe has been expanded,
the first line is passed to a shell.
If that command succeeds, then the second is run.
Wash, rinse, repeat.
Backslashes at the end of line are preserved,
with the effect that the following line is passed at the same time as the first.
In recursive variable definition however,
backslashes at the end of line are removed as the definition is read
define oneline =
aa \
bb \
cc
endef
$(error [$(value oneline)])
which gives
$ make
Makefile:9: *** [ aa bb cc]. Stop.
What we are aiming for
We need to massage make's syntax so that a variable expands to exactly this text:
target:
if [ -f foo ]; then \
printf "'%s' is a file\n" 'foo'; \
printf "This is a relatively long command that"; \
printf " won't fit on one line\n"; \
fi
which we then simply feed to make via something like
$(eval ${var})
Backslashes
Just replace each newline with a space-backslash-newline-tab quad using $(subst).
A function to do that:
empty :=
tab := ${empty} # Trailing tab
define \n :=
endef
stanza = $(subst ${\n}, \${\n}${tab})
To check it works:
define func =
if [ -f $1 ]; then
printf "'%s' is a file\n" '$1';
printf "This is a relatively long command that";
printf " won't fit on one line\n";
fi
endef
$(error [$(call stanza,$(call func,foo))])
giving:
Makefile:23: *** [ if [ -f foo ]; then \
printf "'%s' is a file\n" 'foo'; \
printf "This is a relatively long command that"; \
printf " won't fit on one line\n"; \
fi]. Stop.
Note that the definition of func now has no annotation at the end of its lines.
Putting it all together
define \n :=
endef
empty :=
tab := ${empty} # Trailing tab
stanza = $(subst ${\n}, \${\n}${tab},$1)
define func =
if [ -f $1 ]; then
printf "'%s' is a file\n" '$1';
printf "This is a relatively long command that";
printf " won't fit on one line\n";
fi
endef
define rule =
target:
echo vvv
$(call stanza,$(call func,foo))
echo ^^^
endef
$(eval ${rule})
Leading to
$ touch foo; make -R --warn
echo vvv
vvv
if [ -f foo ]; then \
printf "'%s' is a file\n" 'foo'; \
printf "This is a relatively long command that"; \
printf " won't fit on one line\n"; \
fi
'foo' is a file
This is a relatively long command that won't fit on one line
echo ^^^
^^^
An interesting academic exercise.
Still can't put literal backslashes in either :)

define newline :=
$(strip)
$(strip)
endef
define func =
if [ -f $(1) ]; then \$(newline)\
printf "'%s' is a file\n" '$(1)'; \$(newline)\
printf "This is a relatively long command that"; \$(newline)\
printf " won't fit on one line\n"; \$(newline)\
fi
endef
func2 = if [ -f $(1) ]; then \$(newline) printf "'%s' is a file\n" '$(1)'; \$(newline) printf "This is a relatively long command that"; \$(newline) printf " won't fit on one line\n"; \$(newline)fi
all:
$(call func,foo)
#echo --------------
$(call func2,foo)
The first one seems to be space-stripped. The second looks nice at the output but.. oh well, seems like being stuck between a rock and a hard place :/

I found a nicer (albeit still hacky) solution that seems to work:
define func =
if [ -f $(1) ]; then $\\
printf "'%s' is a file\n" '$(1)'; $\\
printf "This is a relatively long command that"; $\\
printf " won't fit on one line\n"; $\\
fi
endef
all:
#echo vvvvvvvv
$(call func,foo)
#echo ^^^^^^^^
Output:
$ make
vvvvvvvv
if [ -f foo ]; then \
printf "'%s' is a file\n" 'foo'; \
printf "This is a relatively long command that"; \
printf " won't fit on one line\n"; \
fi
'foo' is a file
This is a relatively long command that won't fit on one line
^^^^^^^^
I think this is how it works:
During the first scan for line continuations, the $ is ignored, so the
parser sees \\, assumes the backslash is escaped, and leaves the lines
intact.
When expanding the function, $\ is recognized as a variable. Assuming
you haven't actually assigned a variable named \, this expands to
nothing.
Now we are left with a single backslash at the end of the line, which is
treated as if it were literally typed into the recipe.

Related

Echo multiple variables with delimiter and prevent new line (in code) being printed in the output

I would like to print a number of variables in a tab-delimited manor, and for the sake of code cleanliness, I would like it write it like the below:
for f in files*
do
echo -e "
$var1\t
$var2\t
$var3\t
$var4\t
"
done
output:
file1_var1 file1_var2 file1_var3 file1_var4
file2_var1 file2_var2 file2_var3 file2_var4
I can get it to work like this, but it is not very tidy:
echo -e "$var1\t$var2\t$var3\t$var4\t"
I have tried using something like this to replace the new lines with tabs, but it seems a bit cumbersome. Also I need to add a new line at the end to prevent multiple files being printed on the same line, and it adds a new line between each output line as well.
for i in files*
do
echo -e "
$var1\t
$var2\t
$var3\t
$var4\t
| sed '$!{:a;N;s/\n/\t/;ta}'
"
echo -e "\n"
done
output:
file1_var1 file1_var2 file1_var3 file1_var4
file2_var1 file2_var2 file2_var3 file2_var4
Basically never use echo -e. You can almost always make it more elegant with printf, or simply cat
If you don't mind getting one spurious tab,
printf "%s\t" "$var1" "$var2" "$var3" "$var4"
printf '\n'
With a here document, you can write a simple sed script to replace all internal newlines with tabs.
sed 'N;N;N;s/\n/\t/g' <<:
$var1
$var2
$var3
$var4
:
(This is probably not entirely portable, but it works on MacOS and Ubuntu, so it should work most places you care about.)
Perhaps also look into pr, which can arrange things in a matrix.
printf "%s\n" "$var1" "$var2" "$var3" "$var4" | pr -bt4
(Again, probably not entirely portable, but works e.g. on Linux. On MacOS I had to remove the b option.)
It's not clear where the variables should come from; perhaps write a function to wrap printf if you just want to print its arguments in groups of four:
fourcols () {
while [ $# -gt 4 ]; do
fourcols "$1" "$2" "$3" "$4"
shift 4 # not portable to sh
done
while [ $# -gt 0 ]; do
printf "%s" "$1"
shift
if [ $# -gt 0 ]; then
printf "\t%s" "$#"
done
printf "\n"
done
}
Then you can just call fourcols * if you want to print all files in four columns, for example.
Defining a function that joins its arguments as a TSV record:
tsvrecord() { first=$1; shift && printf '%s' "$first" "${#/#/$'\t'}"; echo; }
tsvrecord "$var1" "$var2" "$var3" "$var4"
remark: no TSV escaping is done here, so if a filepath contains \t or \n (which is allowed on Unix) then the resulting TSV will be broken.

How to replace a character in a variable in tcsh script while using inside a makefile function

I have a makefile with the below content :
mytarget:
#$(call my_func, echo \"hello world\")
define my_func
$(eval var := $(1))
if [[ "$(var)" != "" ]]; then \
$(var); \
fi;
endef
while executing with make command , this prints "hello world" (enclosed with the double quotes)
All I want is to get rid of the " from the echo "hello world" before the conditional if check.
I tried with below statement but of no use :
var2 = $(echo $var | tr '"' ''
then use $(var2) in the if condition instead of $(var)
It is not working.
Any help will be very much appreciated.
Thanks
First, you should never add # to your makefile recipes until they are working 100%. Otherwise you're trying to debug your makefiles blindfolded.
Your entire makefile script seems like it could just be written as:
mytarget:
$(call my_func, echo "hello world")
my_func = $1
and it would work exactly the same way: if my_func is called with an empty argument then $1 expands to the empty string, which is what you're trying to test anyway. Presumably you have some reason for wanting a more complicated configuration but, as I mentioned in my comment, it seems like an XY problem.
I'll give you a short answer: make doesn't care about quoting or about backslashes. Any quotes or backslashes you use are passed verbatim to the shell. So if you pass echo \"foo\" to the shell, it will print the quotes. That's exactly as expected.
If you remove the #, as I mention above, things will be a lot more clear to you.
So, why are you adding backslashes? It appears you're adding them because otherwise the condition if [ "$1" != "" ] will expand to if [ "echo "hello world"" != "" ] which is a syntax error.
One simple solution is to use single quotes instead of double quotes:
mytarget:
$(call my_func, echo "hello world")
define my_func
if [ '$1' != '' ]; then \
$1; \
fi;
endef
Of course, this will fail if you call my_func with a value containing singe-quotes.
You have two solutions:
First is to use a make conditional, not a shell conditional, since make doesn't care about quotes etc. That gives you:
mytarget:
$(call my_func, echo "hello world")
my_func = $(if $1,$1)
but that's the same as my first suggestion, just using $1 by itself.
If you really, really have to do this in the shell then you have to quote things for the shell. Make's subst function can do that for you:
mytarget:
$(call my_func, echo "hello world")
define my_func
if [ '$(subst ','\'',$1)' != '' ]; then \
$1; \
fi;
endef
which replaces all instances of ' with '\''.

Bash double quotes getting single quoted

I have a problem, where my script argument, goes from this:
'_rofiarg: -mesg "State: Active:Enabled"'
To this (I strip the _rofiarg: using cut btw):
-mesg '"State:' 'Active:Enabled"'
As you can see, my purposeful double quotes get ignored, and Bash splits them into two words.
I'll rewrite a pseudocode here, as the original script has a lot of local dependencies to my configs.
#script1.sh
# $active and $state have some value, of course
script2.sh \
"_rofiarg: -mesg \"State: $active:$state\"" \
${somearray[#]};
#script2.sh
#Check input for rofi arguments, add them to Args(), all else goes to Data()
for Arg in "$#"; do
if [[ "$Arg" =~ _rofiarg:* ]]; then
Args+=( "$(echo "$Arg" | cut -c 11-)" );
else
Data+=( "$Arg" );
fi;
done;
After this I just pass the ${Args[#]} to the target program, in this case Rofi - like this:
out="$(
printf '%s\n' "${Data[#]}" |
rofi -config $CONF/rofi/config.cfg \
-dmenu -no-custom \
-padding $padd \
${Args[#]};
)";
I've been at this problem for hours. All of my statements about what actually gets passed to which program is logged using set -o xtrace, and I'm at a point, where I think I literally tried all random combinations of single, double, $'', and all other quote types.
The line
${Args[#]};
with the unquoted variable expansion is the point where the coherence between "State: and …" is lost. To prevent that, we have to quote the expansion, but in order to remove the quotes and to separate -mesg from "State: …", we have to evaluate the command; this gives:
out="$(
printf '%s\n' "${Data[#]}" |
eval rofi -config $CONF/rofi/config.cfg \
-dmenu -no-custom \
-padding $padd \
"${Args[#]}"
)"

Iterate over a list of quoted strings

I'm trying to run a for loop over a list of strings where some of them are quoted and others are not like so:
STRING='foo "bar_no_space" "baz with space"'
for item in $STRING; do
echo "$item"
done
Expected result:
foo
bar_no_space
baz with space
Actual result:
foo
"bar_no_space"
"baz
with
space"
I can achieve the expected result by running the following command:
bash -c 'for item in '"$STRING"'; do echo "$item"; done;'
I would like to do this without spawning a new bash process or using eval because I do not want to take the risk of having random commands executed.
Please note that I do not control the definition of the STRING variable, I receive it through an environment variable. So I can't write something like:
array=(foo "bar_no_space" "baz with space")
for item in "${array[#]}"; do
echo "$item"
done
If it helps, what I am actually trying to do is split the string as a list of arguments that I can pass to another command.
I have:
STRING='foo "bar_no_space" "baz with space"'
And I want to run:
my-command --arg foo --arg "bar_no_space" --arg "baz with space"
Use an array instead of a normal variable.
arr=(foo "bar_no_space" "baz with space")
To print the values:
print '%s\n' "${arr[#]}"
And to call your command:
my-command --arg "${arr[0]}" --arg "${arr[1]}" --arg "{$arr[2]}"
Can you try something like this:
sh-4.4$ echo $string
foo "bar_no_space" "baz with space"
sh-4.4$ echo $string|awk 'BEGIN{FS="\""}{for(i=1;i<NF;i++)print $i}'|sed '/^ $/d'
foo
bar_no_space
baz with space
Solved: xargs + subshell
A few years late to the party, but...
Malicious Input:
SSH_ORIGINAL_COMMAND='echo "hello world" foo '"'"'bar'"'"'; sudo ls -lah /; say -v Ting-Ting "evil cackle"'
Note: I originally had an rm -rf in there, but then I realized that would be a recipe for disaster when testing variations of the script.
Converted perfectly into safe args:
# DO NOT put IFS= on its own line
IFS=$'\r\n' GLOBIGNORE='*' args=($(echo "$SSH_ORIGINAL_COMMAND" \
| xargs bash -c 'for arg in "$#"; do echo "$arg"; done'))
echo "${args[#]}"
See that you can indeed pass these arguments just like $#:
for arg in "${args[#]}"
do
echo "$arg"
done
Output:
hello world
foo
bar;
sudo
rm
-rf
/;
say
-v
Ting-Ting
evil cackle
I'm too embarrassed to say how much time I spent researching this to figure it out, but once you get the itch... y'know?
Defeating xargs
It is possible to fool xargs by providing escaped quotes:
SSH_ORIGINAL_COMMAND='\"hello world\"'
This can make a literal quote part of the output:
"hello
world"
Or it can cause an error:
SSH_ORIGINAL_COMMAND='\"hello world"'
xargs: unmatched double quote; by default quotes are special to xargs unless you use the -0 option
In either case, it doesn't enable arbitrary execution of code - the parameters are still escaped.
Pure bash parser
Here's a quoted-string parser written in pure bash (what terrible fun)!
Caveat: just like the xargs example above, this errors in the case of an escaped quoted.
Usage
MY_ARGS="foo 'bar baz' qux * "'$(dangerous)'" sudo ls -lah"
# Create array from multi-line string
IFS=$'\r\n' GLOBIGNORE='*' args=($(parseargs "$MY_ARGS"))
# Show each of the arguments array
for arg in "${args[#]}"; do
echo "$arg"
done
Output:
$#: foo bar baz qux *
foo
bar baz
qux
*
Parse Argument Function
Literally going character-by-character and adding to the current string, or adding to the array.
set -u
set -e
# ParseArgs will parse a string that contains quoted strings the same as bash does
# (same as most other *nix shells do). This is secure in the sense that it doesn't do any
# executing or interpreting. However, it also doesn't do any escaping, so you shouldn't pass
# these strings to shells without escaping them.
parseargs() {
notquote="-"
str=$1
declare -a args=()
s=""
# Strip leading space, then trailing space, then end with space.
str="${str## }"
str="${str%% }"
str+=" "
last_quote="${notquote}"
is_space=""
n=$(( ${#str} - 1 ))
for ((i=0;i<=$n;i+=1)); do
c="${str:$i:1}"
# If we're ending a quote, break out and skip this character
if [ "$c" == "$last_quote" ]; then
last_quote=$notquote
continue
fi
# If we're in a quote, count this character
if [ "$last_quote" != "$notquote" ]; then
s+=$c
continue
fi
# If we encounter a quote, enter it and skip this character
if [ "$c" == "'" ] || [ "$c" == '"' ]; then
is_space=""
last_quote=$c
continue
fi
# If it's a space, store the string
re="[[:space:]]+" # must be used as a var, not a literal
if [[ $c =~ $re ]]; then
if [ "0" == "$i" ] || [ -n "$is_space" ]; then
echo continue $i $is_space
continue
fi
is_space="true"
args+=("$s")
s=""
continue
fi
is_space=""
s+="$c"
done
if [ "$last_quote" != "$notquote" ]; then
>&2 echo "error: quote not terminated"
return 1
fi
for arg in "${args[#]}"; do
echo "$arg"
done
return 0
}
I may or may not keep this updated at:
https://git.coolaj86.com/coolaj86/git-scripts/src/branch/master/git-proxy
Seems like a rather stupid thing to do... but I had the itch... oh well.
Here is a way without an array of strings or other difficulties (but with bash calling and eval):
STRING='foo "bar_no_space" "baz with space"'
eval "bash -c 'while [ -n \"\$1\" ]; do echo \$1; shift; done' -- $STRING"
Output:
foo
bar_no_space
baz with space
If You want to do with the strings something more difficult then just echo You can split Your script:
split_qstrings.sh
#!/bin/bash
while [ -n "$1" ]
do
echo "$1"
shift
done
Another part with more difficult processing (capitalizing of a characters for example):
STRING='foo "bar_no_space" "baz with space"'
eval "split_qstrings.sh $STRING" | while read line
do
echo "$line" | sed 's/a/A/g'
done
Output:
foo
bAr_no_spAce
bAz with spAce

Short way to escape HTML in Bash?

The box has no Ruby/Python/Perl etc.
Only bash, sed, and awk.
A way is to replace chars by map, but it becomes tedious.
Perhaps some built-in functionality i'm not aware of?
Escaping HTML really just involves replacing three characters: <, >, and &. For extra points, you can also replace " and '. So, it's not a long sed script:
sed 's/&/\&/g; s/</\</g; s/>/\>/g; s/"/\"/g; s/'"'"'/\'/g'
Pure bash, no external programs:
function htmlEscape () {
local s
s=${1//&/&}
s=${s//</<}
s=${s//>/>}
s=${s//'"'/"}
printf -- %s "$s"
}
Just simple string substitution.
You can use recode utility:
echo 'He said: "Not sure that - 2<1"' | recode ascii..html
Output:
He said: "Not sure that - 2<1"
or use xmlstar Escape/Unescape special XML characters:
$ echo '<abc&def>'| xml esc
<abc&def>
I'm using jq:
$ echo "2 < 4 is 'TRUE'" | jq -Rr #html
2 < 4 is &apos;TRUE&apos;
This is an updated answer to miken32 "Pure bash, "no external programs":
bash 5.2 breaks backward compatibility in ways that are highly inconvenient.
From NEWS:
x. New shell option: patsub_replacement. When enabled, a '&' in
the replacement string of the pattern substitution expansion is
replaced by the portion of the string that matched the pattern.
Backslash will escape the '&' and insert a literal '&'.
The option is enabled by default. If you want to restore the previous
behavior, add shopt -u patsub_replacement.
So there is three ways to use miken32 code in bash 5.2+:
Either disable patsub_replacement:
shopt -u patsub_replacement
function htmlEscape () {
local s
s=${1//&/&}
s=${s//</<}
s=${s//>/>}
s=${s//'"'/"}
printf -- %s "$s"
}
, another option is to escape '&' with backslash in the replacement if you want to make it work regardless of the 5.2 feature, patsub_replacement:
function htmlEscape () {
local s
s=${1//&/\&}
s=${s//</\<}
s=${s//>/\>}
s=${s//'"'/\"}
printf -- %s "$s"
}
and another option is to quote string in the replacement:
function htmlEscape () {
local s
s=${1//&/"&"}
s=${s//</"<"}
s=${s//>/">"}
s=${s//'"'/"""}
printf -- %s "$s"
}
There's much better answers, but I just found this so I thought I'd share.
PN=`basename "$0"` # Program name
VER=`echo '$Revision: 1.1 $' | cut -d' ' -f2`
Usage () {
echo >&2 "$PN - encode HTML unsave characters, $VER
usage: $PN [file ...]"
exit 1
}
set -- `getopt h "$#"`
while [ $# -gt 0 ]
do
case "$1" in
--) shift; break;;
-h) Usage;;
-*) Usage;;
*) break;; # First file name
esac
shift
done
sed \
-e 's/&/\&/g' \
-e 's/"/\"/g' \
-e 's/</\</g' \
-e 's/>/\>/g' \
-e 's/„/\ä/g' \
-e 's/Ž/\Ä/g' \
-e 's/”/\ö/g' \
-e 's/™/\Ö/g' \
-e 's//\ü/g' \
-e 's/š/\Ü/g' \
-e 's/á/\ß/g' \
"$#"
The previous sed replacement defaces valid output like
<
into
&lt;
Adding a negative loook-ahead so "&" is only changed into "&" if that "&" isn't already followed by "amp;" fixes that:
sed 's/&(?!amp;)/\&/g; s/</\</g; s/>/\>/g; s/"/\"/g; s/'"'"'/\'/g'

Resources