2

I can iterate over multiple ranges / sequences / numbers with constructions like:

for i in $(seq 1 3) 5 $(seq 7 9) 11; do echo $i; done
for i in {1..3} 5 {7..9} 11; do echo $i; done

But how to achieve the same result if ranges / numbers specified in var:

var="{1..3} 5 {7..9} 11"

I found that this can be done with eval and seq:

var="seq 1 3; echo 5; seq 7 9; echo 11"; for i in $(eval "$var"); do echo $i; done

Here shown that eval echo allows to use ranges in vars like (much nicer than the previous one):

var="{1..3} 5 {7..9} 11"; for i in $(eval echo "$var"); do echo $i; done

Is it possible to achieve the same result somehow without using eval? Or using eval in this particular case is OK?


Update

Since there are additional overhead with parsing ranges / numbers defined in plain string variable and since I have control over forming such parameter it will be also worth considering solutions for input array var defined like the following:

ranges=( '1 3' 5 '7 9' 11 )

Or even more straightforward and simpler for implementation (but a bit redundant in expression) with using also ranges instead of numbers:

ranges=( '1 3' '5 5' '7 9' '11 11' )

In case of using array with ranges as input string this question becomes pretty simple and boils down to double loop over array with ranges and over each range of this array.

With specifying ranges as array there will be no need of using eval or parsing string with ranges to array (that have the same but hidden danger of eval).

2
  • 4
    Try: var=({1..3} 5 {7..9} 11);for i in "${var[@]}";do echo $i;done Commented Jun 15 at 14:01
  • 1
    You could var="{1..3} 5 {7..9} 11"; declare -a "array=($var)" but you variable are still eval uated. Commented Jun 15 at 14:07

3 Answers 3

3

Without eval and without leaving any variables exposed to the shell for expansion, globbing, word splitting, etc.:

$ cat tst.sh
#!/usr/bin/env bash

ranges=( '1 3' 5 '7 9' 11 )
for range in "${ranges[@]}"; do
    printf '\nrange = %s\n' "$range"
    read -r -a beg_end <<< "$range"
    seq "${beg_end[0]}" "${beg_end[-1]}"
done

$ ./tst.sh

range = 1 3
1
2
3

range = 5
5

range = 7 9
7
8
9

range = 11
11

If there was something in the input that wasn't a valid range then seq would fail in the above:

$ cat tst.sh
#!/usr/bin/env bash

ranges=( '1 3' '$(date)' '7 9' 11 )
for range in "${ranges[@]}"; do
    printf '\nrange = %s\n' "$range"
    read -r -a beg_end <<< "$range"
    seq "${beg_end[0]}" "${beg_end[-1]}"
done

$ ./tst.sh

range = 1 3
1
2
3

range = $(date)
seq: invalid floating point argument: ‘$(date)’
Try 'seq --help' for more information.

range = 7 9
7
8
9

range = 11
11

If you wanted to handle that some other way there's several alternatives but personally I'd still just let seq be the one that detects the problem and then just add whatever handling I wanted, e.g. to print an error message then stop processing:

$ cat tst.sh
#!/usr/bin/env bash

ranges=( '1 3' '$(date)' '7 9' 11 )
for range in "${ranges[@]}"; do
    printf '\nrange = %s\n' "$range"
    read -r -a beg_end <<< "$range"
    seq "${beg_end[0]}" "${beg_end[-1]}" 2>/dev/null || {
        printf 'Bad range: "%s", exiting\n' "$range" >&2
        exit 1
    }
done

$ ./tst.sh

range = 1 3
1
2
3

range = $(date)
Bad range: "$(date)", exiting

I'm using '$(date)' as the "bad" value above so you can compare how this solution handles a potential command injection attack (by treating it as just a literal string and not executing it as a command) vs eval or any other solution you may be considering.

The problem with trying to use brace expansions like {1..3} stored in a variable is that brace expansion happens before parameter (variable) expansion (see https://www.gnu.org/software/bash/manual/html_node/Shell-Expansions.html) so it's impossible for var='{1..3}' to expand to 1 2 3 without 2 passes of expansion using eval or similar so the variable is expanded to {1..3} in a first pass before the shell in a second pass then expands that brace expression when calling the command you want to run on the result. As long as you're completely in control of the contents of the variable that contains the range you want to expand, eval or an unquoted variable would be safe in this particular case but it's not necessary to rely on that as I show above.

6
  • To create ranges array based on var string: var="'1 3' 5 '7 9' 11"; declare -a "ranges=($var)" Commented Jun 15 at 16:29
  • 1
    @AntonSamokat I'd suggest that you don't do that as it's no better than using eval, it'd still expand any variable in var, including executing any command in it. Try var="'1 3' $(date) '7 9' 11"; declare -a "ranges=($var)"; declare -p ranges or even var="'1 3' '$(date)' '7 9' 11"; declare -a "ranges=($var)"; declare -p ranges and note that the output contains the result of executing date instead of the string $(date) which is the problem with eval you're trying to avoid. Don't try to avoid eval by using something else with the same problem but more obscure syntax.
    – Ed Morton
    Commented Jun 15 at 16:53
  • 1
    I added the potential command injection attack to the example in my answer so you can see how my code handles it. You could have similar problems in other solutions with any potential variable expansion (e.g. '$HOME') or globbing chars (e.g. '*') that my answer would again just treat as literal strings.
    – Ed Morton
    Commented Jun 15 at 17:10
  • 1
    I see. Yes, this is even worse than eval because of such hidden danger that is known about eval. Thanks. It turns out that in this case instead of using string var with ranges it is more appropriate to use array var with ranges. And will pass between functions array rather than string. Commented Jun 15 at 17:12
  • Running seq N N for printing N is overkill! With just one test, you could use echo instead! Commented Jul 2 at 13:11
2

1. First answer...

You could use:

var="{1..3} 5 {7..9} 11"

declare -a "array=($var)"
for i in "${array[@]}"; do
    echo $i
done
1
2
3
5
7
8
9
11

But care!! This work like an eval:

var='{1..3} $(uptime) 6 7'
echo "$var"
{1..3} $(uptime) 6 7
declare -a "array=($var)"
for i in "${array[@]}"; do
    echo $i
done
1
2
3
16:36:29
up
22
days,
4:54,
23
users,
load
average:
2.48,
1.89,
1.83
6
7

Note that using double quotes:

var='{1..3} "$(uptime)" 6 7'

Will produce more readable:

1
2
3
 16:41:03 up 22 days, 4:59, 23 users, load average: 1.47, 2.09, 1.98
6
7

About:

Or using eval in this particular case is OK?

This is your own responsibility! If you are knowing what you do, you are confident on where come $var content, then it could be ok.

2. Without eval, you have do loops.

If you want to avoid eval, you could use a function like:

readRange() { # Usage: readRange "$var" <arrayName>
    local -a arin
    local elem
    local -i iter start end
    local -n arout=$2
    arout=()
    read -ra arin <<< "$1"
    for elem in "${arin[@]}"; do
        case $elem in
            '{'[0-9]*'..'*[0-9]'}' )
                IFS='.{}' read -r _ start _ end <<< "$elem"
                for ((iter=start; iter<=end; iter++)); do
                    arout+=($iter)
                done
            ;;
            *[^0-9]* )
                echo ERROR 1>&2
                return -1
            ;;
            *)
                arout+=($elem)
            ;;
        esac
    done
}

var="{1..3} 5 {7..9} 11"
readRange "$var" resultArray
for i in "${resultArray[@]}"; do
    echo "$i"
done
1
2
3
5
7
8
9
11

3. Or to use seq:

readRange() { # Usage: readRange "$var" <arrayName>
    local -a arin
    local elem start end
    local -n arout=$2
    arout=()
    read -ra arin <<< "$1"
    for elem in "${arin[@]}"; do
        read -r start end <<< ${elem//[^0-9]/ }
        if [[ -n $end ]]; then
            arout+=($(seq $start $end))
        else
            arout+=($start)
        fi
    done
}

var="{1..3} 5 {7..9} 11"
readRange "$var" resultArray
for i in "${resultArray[@]}"; do
    echo "$i"
done
1
2
3
5
7
8
9
11
1

If possible, I'd skip the simple scalar string variable and just use an array. You were already doing that, but if possible, remove the hard strings that declare the ranges, and just declare the ranges in the array definition itself.

$: lst=( {1..3} 5 {7..9} 11 )
$: printf " %s" "${lst[@]}" $'\n'
 1 2 3 5 7 8 9 11

If that's not an option, then as already stated, you can use declare -

$: var="{1..3} 5 {7..9} 11"
$: declare -a "lst=( $var )"
$: printf " %s" "${lst[@]}" $'\n'
 1 2 3 5 7 8 9 11

But watch for the malicious embedded commands in your data, as shown.

If your sequences are not huge, you might store the entire sequence instead of just the "control" data.

$: lst=( {1..3} 5 {7..9} 11 )  # create the set
$: seq="${lst[@]}"             # store as flat string
$: unset lst                   # delete it, or go out of scope
$: echo "[$seq][${lst[@]:-}]"  # string has it, array does not
[1 2 3 5 7 8 9 11][]
$: lst=( $seq )                # re-create the array - insecure
$: for n in "${lst[@]}"; do echo $n; done
1
2
3
5
7
8
9
11

This doesn't address your possible security issue if the ranges are coming from an untrusted source, but might simplify processing and storage.

2
  • This is interesting step forward. I thought that it will be good to use arrays of arrays for eliminating the need to parse strings with ranges.But bush does not support multidimensional arrays. Will check this one. Thanks! Commented Jun 17 at 18:35
  • 1
    True - bash does not support multidimensional arrays, but it does support associative arrays with arbitrary strings as keys, which can be the keys you would have used in your multidimensional lookup with any delimiter that won't be a part of any key - ${table["1,3,15"]} works just fine. Commented Jun 17 at 20:50

Not the answer you're looking for? Browse other questions tagged or ask your own question.