Bash: thực thi lệnh có chứa dấu cách được lưu trong biến

Bash: thực thi lệnh có chứa dấu cách được lưu trong biến
Photo by Joshua J. Cotten from Unsplash

Với bash, việc lưu giá trị trong biến rồi thực hiện lệnh tương ứng không hiếm gặp. Tuy nhiên, nếu giá trị của biến chứa dấu cách, việc thực thi lệnh có thể cho ra kết quả không đúng với ý đồ của lập trình viên.

Ví dụ với lệnh ls đơn giản như sau:

$ ls -l "/tmp/test/foo bar/"
total 0

Nếu lưu lệnh này vào một biến và gọi biến đó để thực thi, kết quả sẽ như thế này:

$ abc='ls -l "/tmp/test/foo bar"'
$ $abc
ls: cannot access '"/tmp/test/foo': No such file or directory
ls: cannot access 'bar"': No such file or directory
$ "$abc"
bash: ls -l "/tmp/test/foo bar": No such file or directory

Để thực hiện lệnh này, có một số giải pháp như sau, nhưng kết quả của chúng cũng rất khác nhau.

$ bash -c $abc
'foo bar'
$ bash -c "$abc"
total 0
$ eval $abc
total 0
$ eval "$abc"
total 0

Trong những phần tiếp theo, tôi sẽ trình bày hiểu biết của mình về cách thứ bash phân tích và thực thi lệnh. Với bash, việc lưu giá trị trong biến không quan trọng giá trị đó là tên lệnh hay chỉ là tham số, mọi thứ được xử lý tương tự nhau.

Cách bash phân tích và thực thi lệnh

Khi bash phân tích một lệnh, nó sẽ lấy giá trị mà người dùng đã nhập, phân tích thành chuỗi các string ngăn cách bởi dấu cách. Tiếp theo, bash sẽ tiếp tục thực thi lệnh từ chuỗi string đã phân tích được. Nếu các ký tự được đóng trong một cặp dấu nháy kép sẽ được coi là 1 string. Ví dụ ls -l "foo bar" sẽ được phân tích thành 3 string: ls, -lfoo bar (tự động bỏ dấu nháy).

Sau khi phân tích xong, lệnh ls sẽ được thực thi với các tham số đi kèm.

Thế nhưng, khi gán giá trị cho biến, việc phân tích lệnh của bash sẽ khác đi. Ví dụ:

$ abc='ls -l "/tmp/test/foo bar"'

Lúc này, nếu gọi $abc để thực thi lệnh, giá trị của biến này sẽ được phân tích thành 4 string: ls, -l, "/tmp/test/foobar". Dấu nháy kép trong trường hợp này chỉ như một ký tự thông thường, và nó được phân tích thành một phần của string thứ 3 và thứ 4.

$ $abc
ls: cannot access '"/tmp/test/foo': No such file or directory
ls: cannot access 'bar"': No such file or directory

Nếu dùng dấu nháy đóng gói toàn bộ biến, giá trị của nó được coi là 1 string. Do đó, bash sẽ tìm cách thực thi lệnh ls -l "/tmp/test/foo bar". Đương nhiên, không có lệnh nào như thế, nên kết quả sẽ như này:

$ "$abc"
bash: ls -l "/tmp/test/foo bar": No such file or directory

Nếu sử dụng bash -c để thực thi lệnh, biến $abc sẽ được phân tích và chỉ string đầu tiên (ls) của nó được truyền cho -c. Do đó, lệnh này chỉ đơn giản là ls, các tham số tiếp theo sẽ bị bỏ qua.

$ bash -c $abc
foo bar

Để thực hiện lệnh đúng ý đồ, cần sử dụng dấu nháy kép bash -c "$abc". Lúc này, toàn bộ giá trị của biến sẽ được coi là 1 string và việc thực hiện lệnh sẽ chính xác hơn. Tuy nhiên, việc này có thể tiềm ẩn nhiều nguy cơ. Việc sử dụng dấu nháy kép trong bash script luôn phải hết sức cẩn thận.

Cách gọi lệnh lưu trong biến

Để gọi lệnh được lưu trong biến một cách an toàn và chính xác, có một số phương pháp như sau:

Sử dụng hàm

Định nghĩa một hàm chứa lệnh cần thực thi và gọi hàm đó như một lệnh thông thường. Sau đó, tên hàm có thể được gán cho biến và sử dụng bình thường. Chúng ta sẽ không gặp vấn đề với dấu cách cho lệnh nữa. Tuy nhiên, cách này chỉ áp dụng với trường hợp các lệnh là cố định.

my_cmd() {
    ls -l "/tmp/test/foo bar"
}

abc='my_cmd'
$abc

Sử dụng array

Array cho phép tạo biến chứa nhiều string, mỗi string lại có thể có dấu cách. Bằng cách này, mỗi tham số của lệnh sẽ được lưu thành string riêng và khi khai triển bằng cú pháp ${array[@]}, các tham số sẽ được khai triển đúng như ban đầu:

my_cmd=(ls -l "/tmp/test/foo bar")
"${my_cmd[@]}"

Cú pháp sử dụng array cũng rất đơn giản. Những gì được viết trong dấu ngoặc tròn hoàn toàn giống với lệnh thông thường. Bash cũng phân tích giá trị này tương tự như phân tích một lệnh. Chỉ khác một điều là giá trị phân tích được sẽ lưu vào biến thay vì được thực thi ngay.

Một lưu ý nhỏ, cú pháp khai triển array cần sử dụng dấu nháy kép như ở trên. Nếu không, kết quả sẽ rất khó đoán. Sử dụng array, chúng ta có thể sử dụng những lệnh thay đổi tham số tùy ý, phụ thuộc vào những gì người dùng nhập vào. Ví dụ:

my_cmd=(ls)
if [ "$want_detail" = 1 ]; then
    my_cmd+=(-l)
fi
my_cmd+=("$target_dir")

"${my_cmd[@]}"

Một cách sử dụng khác của array là dùng nó để lưu tham số còn lệnh sẽ gọi trực tiếp. Ví dụ:

options=(-x -v)
files=(foo "bar baz")
target=/foobar

ls "${options[@]}" "${files[@]}" "$target"

Tuy nhiên, array không phải cú pháp tiêu chuẩn của POSIX, nên không phải shell nào cũng hỗ trợ cú pháp này. Ví dụ /bin/sh trên Debian/Ubuntu (thực chất là dash) không hỗ trợ cú pháp này. Một số shell thông dụng như bash, zsh đều hỗ trợ array. Vì vậy, khi lập trình shell script, cần lưu ý về shell mà mình sử dụng.

Sử dụng “$@”

Nếu shell không hỗ trợ cú pháp array, vẫn có thể sử dụng $@. Cách sử dụng của nó khá giống array mặc dù cú pháp hơi phức tạp hơn một chút.

Cú pháp $@ có thể coi là cú pháp array đặc biệt, không có tên biến và giá trị được gán thông qua lệnh set. Ví dụ:

set -- ls -l "/tmp/test/foo bar"
"$@"

Lưu ý rằng, để khai triển $@ cũng cần dấu nháy kép để phòng tránh lỗi có thể xảy ra. Cú pháp này cũng có thể sử dụng để thay đổi tùy chọn lệnh:

set -- ls
if [ "$want_detail" = 1 ]; then
    set -- "$@" -l
fi
set -- "$@" "$target_dir"

"$@"

Hoặc chỉ sử dụng để lưu tham số còn lệnh là cố định:

set -- -x -v
set -- "$@" foo "bar baz"
set -- "$@" /foobar

ls "$@"

Lưu ý rằng, lệnh set sẽ “gán” giá trị cho $@ và giá trị mới sẽ ghi đè giá trị cũ. Vì vậy, khi cần sử dụng lại giá trị của $@ cần gọi nó kèm với lệnh set như trên.

Sử dụng eval (hết sức cẩn thận)

Lệnh eval nhận tham số là string và thực thi nó như một lệnh. Mọi ký tự đặc biệt như dấu cách, dấu nháy đơn, nháy kép sẽ được bash phân tích và thực thi như một lệnh bình thường. Điều này mang lại sự tiện lợi nhưng cũng tiềm ẩn nhiều rủi ro.

Ví dụ một trường hợp đơn giản như sau:

cmd='ls -l "/tmp/test/foo bar"'
eval "$cmd"

Kết quả đúng là thứ chúng ta mong muốn. Với eval, dấu nháy được phân tích như phân tích lệnh và kết quả là chúng ta có lệnh ls với hai tham số -ltmp/test/foo bar. eval cũng đủ thông minh để xử lý một số tình huống như có nhiều dấu cách liền nhau hay các string được bao trong dấu nháy. Vì vậy, với eval, sử dụng dấu nháy là rất cần thiết để tránh lệnh chạy theo hướng không mong muốn.

Tuy nhiên, nếu sử dụng eval kết hợp với dữ liệu người dùng nhập vào có thể sẽ rất nguy hiểm. Ví dụ:

read -r filename
cmd="ls -ld '$filename'"
eval "$cmd";

Trông mọi thứ rất bình thường, người dùng sẽ nhập tên file/thư mục và lệnh ls sẽ được thực thi. Thế nhưng, nếu dữ liệu người dùng nhập vào có dấu nháy đơn, lệnh trên sẽ đi theo một hướng khác.

Ví dụ, nếu người dùng nhập vào '$(whatever)'.txt, đoạn script trên sẽ phân tích lệnh thành ls -l ''$(whatever)'.txt'. Lúc này, whatever sẽ được thực thi như là một lệnh con. Điều này có thể rất nguy hiểm. Thử tưởng tượng, thay whatever thành rm -rf thì kết quả sẽ ra sao?

Do đó, với dữ liệu người dùng nhập vào, trước khi gọi eval cần trải qua các bước xác minh, kiểm tra cẩn thận.

Một cách khác để phòng tránh trường hợp này là đổi lại cách sử dụng dấu nháy đơn và nháy kép như bên dưới:

read -r filename
cmd='ls -ld "$filename"'
eval "$cmd";

Lưu ý rằng, nháy đơn được bao toàn bộ lệnh và nháy kép chỉ bao biến filename. Với nháy đơn bên ngoài, string sẽ không được khai triển. Do đó, giá trị của cmd sẽ là ls -l "$filename"eval sẽ nhận chính xác lệnh trên và thực thi. Giá trị của filename sẽ được khai triển khi eval thực thi lệnh.

Rõ ràng, việc sử dụng eval phức tạp hơn rất nhiều so với sử dụng hàm hoặc array. Với hàm hay array, những vấn đề khi khai triển giá trị biến hầu như không gặp phải.

read -r filename
cmd=(ls -ld -- "$filename")
"${cmd[@]}"

Do đó, eval chỉ nên được sử dụng trong một số trường hợp nhất định, khi mà cú pháp lệnh của shell không thể gán cho biến (pipeline, chuyển hướng, v.v…).

Tôi xin lỗi nếu bài viết có bất kỳ typo nào. Nếu bạn nhận thấy điều gì bất thường, xin hãy cho tôi biết.

Nếu có bất điều gì muốn nói, bạn có thể liên hệ với tôi qua các mạng xã hội, tạo discussion hoặc report issue trên Github.