Linux 脚本安全实践:让一个小脚本可检查、可失败、可重跑

一个好脚本不只是“这次运行成功”。它还应该告诉我们:处理了什么输入、在哪里写了结果、遇到异常是否停下,以及第二次运行会不会覆盖第一次的文件。

这篇从一个非常小的计数任务开始。脚本只在自己刚创建的临时目录里生成三行教学数据,不读取真实测序文件,不移动用户文件,也不自动删除任何目录。先建立这样的安全边界,再把思路迁移到正式工作流。

1. 先约定脚本的职责

我们的任务是读取两列教学 TSV:第一列样本名,第二列非负整数。结果包括有效样本数和计数之和。

输入范围有意很窄:样本名只能包含英文字母、数字、下划线或短横线;计数是十进制整数;两列都不能为空。它不是通用 CSV 解析器,也不是对真实表达矩阵进行生物学分析。

如果以后需要处理带引号、空字段、复杂注释或数十万列的数据,应该使用 Python/R 的表格读取工具,不能假定一个 read 循环可以处理所有格式。

正式任务还需要补充三个问题:如果某个样本失败,其他样本是否继续?哪些文件可以覆盖?结果怎么判定完整?这些属于分析策略,应当在写循环之前决定,而不是等错误发生后临时猜测。

2. 四个值得保留的习惯

路径变量通常放在双引号里,避免空格被拆成多个参数。需要把字符串原样写出时,使用 printf '%s\n' "$value",让格式串与数据分开。双引号与未引用变量的行为差别可查阅 Bash 的引用规则。

运行前明确输出目录。这里用 mktemp -d 创建新的目录,避免把“已有工作目录”误当成“本次练习目录”;不要用 mktemp -u 只猜一个名字。相关创建语义见 GNU Coreutils 的 mktemp 文档。

对预期内的失败显式处理,例如文件不存在或字段非法。对意外失败保留退出码和行号。只有“看到错误文字”不够:上游流程通常依据退出码决定是否继续。

尽量让每次运行产生独立结果。调试时保留失败现场,往往比写一个自动清理所有东西的退出钩子更有帮助。下面的程序结束后会打印练习目录,供你核对;它不会替你清理。

3. 完整练习:只在新的临时目录里工作

需要 Linux 或 WSL 中的 Bash 与 GNU Coreutils。请用编辑器将代码保存为 safe_counts.sh;保留 LF 换行,再执行 bash safe_counts.sh。不要把 bash 换成 sh:示例使用了 Bash 专属语法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077

on_error() {
local status=$?
printf 'ERROR: status=%s line=%s\n' "$status" "$1" >&2
exit "$status"
}
trap 'on_error "$LINENO"' ERR

task_dir=$(mktemp -d --tmpdir perry-shell.XXXXXX)
printf 'Practice directory: %s\n' "$task_dir"
cd -- "$task_dir"

mkdir -- 'input files' 'results' 'logs'
input_file="$task_dir/input files/counts.tsv"
result_file="$task_dir/results/summary.tsv"
log_file="$task_dir/logs/run.log"

printf 'sample\tcount\nS1\t12\nS2\t0\nS3\t7\n' > "$input_file"
printf 'START\nworkdir=%s\nbash=%s\n' "$task_dir" "$BASH_VERSION" > "$log_file"

if [[ ! -s "$input_file" ]]; then
printf 'Input is missing or empty: %s\n' "$input_file" >&2
exit 2
fi

sample_count=0
total=0
line_number=0
record_pattern=$'^([A-Za-z0-9_-]+)\t([0-9]{1,6})$'
while IFS= read -r record || [[ -n "${record:-}" ]]; do
line_number=$((line_number + 1))
if [[ "$line_number" -eq 1 ]]; then
if [[ "$record" != $'sample\tcount' ]]; then
printf 'Unexpected header\n' >&2
exit 2
fi
continue
fi
if [[ ! "$record" =~ $record_pattern ]]; then
printf 'Invalid teaching record at line %s\n' "$line_number" >&2
exit 2
fi
sample=${BASH_REMATCH[1]}
count=${BASH_REMATCH[2]}
# 10# makes zero-padded input decimal, not an octal literal.
value=$((10#$count))
sample_count=$((sample_count + 1))
total=$((total + value))
done < "$input_file"

if [[ "$sample_count" -eq 0 ]]; then
printf 'No data records\n' >&2
exit 2
fi

printf 'samples\ttotal\n%s\t%s\n' "$sample_count" "$total" > "$result_file"
printf 'SUCCESS\nsamples=%s\ntotal=%s\n' "$sample_count" "$total" >> "$log_file"
printf 'CHECK: samples=%s total=%s\n' "$sample_count" "$total"
printf 'Result: %s\nLog: %s\n' "$result_file" "$log_file"
printf 'Practice files are retained; no automatic deletion was performed.\n'

它特意创建了含空格的 input files 目录。如果去掉路径变量外侧的双引号,程序就可能把一个路径拆成两个参数。这种小范围练习比直接拿真实项目试错更容易理解引用规则。

读取时先保留整行,再用固定的正则约定两列,避免把连续制表符错误地压成一个字段分隔符。每条计数最多六位数字,只为避免把任意长的字符串直接送入 Bash 整数运算;shell 算术不是无限精度计算器。读取条件还保留了文件末尾没有换行时的非空记录,避免最后一行被悄悄漏掉。

4. 检查输出,而不是只看终端有没有报错

临时目录尾部的随机字符每次不同,但关键检查行应为:

1
CHECK: samples=3 total=19

将终端打印的完整 Result 路径复制给 sed -n '1,5p',应看到下面两行。路径需要放在双引号里;不要凭记忆拼接另一个临时目录。

1
2
samples  total
3 19

上面的展示用空格方便阅读,文件实际用制表符分列。日志结尾应该出现 SUCCESS、samples=3 与 total=19。有结果文件但没有成功标记时,不应把它默认为完整产物。

更重要的是检查输入:三条记录是否正是 S1、S2、S3?有没有意外多读了一行?本例只累计计数,不检测重复样本名;正式任务需要额外验证唯一性。明确知道程序尚未检查什么,本身就是安全实践的一部分。

5. set -e 与 pipefail 不是万能保险

-u 帮助发现未设置变量;pipefail 让管道中较早命令的非零退出也能影响整个管道状态;-e 在部分失败场景中终止脚本。但 if 条件、某些 &&/|| 链和其他上下文有例外,不能把它理解成“所有错误一定会立即停机”。具体条件应查 Bash 的 set 内建命令说明。

还要注意 head 提前退出的问题。一个长输出被 head -n 10 截断时,上游程序可能收到 SIGPIPE;打开 pipefail 后,整条管道可能呈现非零状态,即使你只是想看前十行。这不代表输入文件必然损坏,也不应该习惯性加上 || true 隐藏所有异常。

只预览文本文件,可以直接用 sed -n '1,10p' "$input_file";它不依靠另一个进程提前关闭管道。需要做“是否匹配”的判断时,把 grep 的退出状态放进明确的 if,区分未匹配与真正读取失败。对大型二进制数据或外部分析工具,先理解工具自身的退出码约定。

另一个小坑是 ((counter++)):当旧值是零时,算术命令的状态可能让 -e 触发。上面的脚本使用普通赋值 counter=$((counter + 1)),避免把计数的真假值混进错误处理。

6. 常见误区与练习

不要在未核对路径时批量重命名或删除;不要把任意文件名拼进一段 eval;不要一边读取原始文件,一边覆盖它;不要因为脚本有日志,就把未检查的结果称为可复现。

本例的 ERR 钩子提供诊断,不负责回滚已经写出的文件;它也不承诺覆盖所有信号与语法错误。可以先用 bash -n safe_counts.sh 做语法检查,但语法通过不等于逻辑正确。

完成后做三个小练习:把 S2 的计数改成 005,确认结果按十进制解释;在你自己的脚本副本里将一行计数改成 abc,确认程序拒绝输入;连续运行两次,核对两次目录不同且第一次的结果还在。

最后写下自己的检查清单:输入格式、样本唯一性、输出位置、覆盖策略、成功标记。把这些问题带到下一次真实分析里,比背下一长串 shell 命令更有用。

官方参考

继续阅读与公开资源