十四 使用 shc 编译 linux shell 脚本为可执行文件

十四 使用 shc 编译 linux shell 脚本为可执行文件

【动机】

shc(Shell Compiler)是一个工具,用于将shell脚本编译成二进制执行文件,这样可以防止源代码泄露,并提高脚本的执行效率. 假设存在两个文件, 结构是:

其中, target 目录用来保存生成的可执行文件. hello.sh 的内容:

#!/bin/bash
say_hello(){
    echo "hello,world! from file lib/hello.sh"
}

main.sh 的内容:

#!/bin/bash

#source ./lib/hello.sh

echo "this content is from main.sh"
#say_hello

执行脚本 main.sh,结果没什么好说的:

现在编译 main.sh:

得到临时文件 main.sh.x.c 和 目标文件 target/main:

运行可执行文件 main:

一切正常!

现在, 打开 main.sh 源文件被注释掉的两行代码:

#!/bin/bash

source ./lib/hello.sh

echo "this content is from main.sh"
say_hello

运行 main.sh

先删除之前生成的 main.sh.x.c 和 target/main, 以免混淆视听. 然后编译 main.sh, 无报错, 貌似一切正常:

现在执行生成的可执行文件 target/main, 貌似一切正常:

最后, 删除 lib/hello.sh 或者将其改名:

再次运行 target/main, 报错:

这意味着, 编译后得到可执行体, 还依赖于其他内部被 source 的文件, 即可执行体迁移到哪, 这些文件还得跟到哪, 且需要保持相对目录位置正确!

如何在编译时同时编译内部被 source 的文件, 以消除生成的可执行体对上述被 source 的文件(甚至可能存在的嵌套 source 的文件)的依赖?

【意图】

根据入口源文件中 source 文件的各种形式, 将该文件内容嵌入到其上级文件的适当位置, 最后得到一个无外部依赖的完整文件, shc 命令编译该完整文件即可. 注意, 生成完整文件过程中, 还需要去除所有注释, 以保证 shc 执行不受注释的影响.

【预备】

在 file.sh 文件中, 添加函数 path_parse, 封装对已知路径的名称/扩展名等等方面的需求.

:<<COMMENT
    路径解析
    $1: 文件路径(可以是目录或文件)
    $2: 要解析的条目, 取值(以下提及的扩展名, 均包含 .):
        1) par 所在目录(父目录)绝对路径(无尾部斜杆)
        2) namex 名称(目录或文件, 包括扩展名)
        3) exts 所有扩展名
        4) ext 最后一个扩展名
        5) 其他(包括空字符), 默认为名称(目录或文件, 不包括扩展名)
    用法: path_parse <filepath> < par | namex | exts | ext | {any-else means only name} >
    注意:
        1) 不对给定路径的存在性检查
COMMENT
path_parse(){
    local path=$(readlink -m "$1" ) # 绝对路径
    local parent=$(dirname  "$path")
    local fullname=$(basename "$path")
    local result temp
    case "$2" in
        par)
            result=$parent;;
        namex)
            result=$fullname;;
        exts) 
            temp=${fullname#*.}
            [ "$temp" != "$fullname" ] && result=.$temp;;
        ext) 
            temp=${fullname##*.}
            [ "$temp" != "$fullname" ] && result=.$temp;;
        *)
            result=${fullname%%.*};;
    esac 
    echox "$result"
}

在 compile.sh 中, 添加函数 compile_single_file, 主要用于编译单一脚本, 在下面的写入自编译代码部分用到.

【实现】

在 compile.sh, 新建函数 script2execution 和 compile_single_file :

# 见 doc/compile.txt
script2execution(){

    # 在指定的独立文件的末尾(该独立文件可能尚未完成), 追加指定的被 source 的文件内容
    # $1: parent iso file
    # $2: son file
    # 注意: arr_keep_flag 在 main 中定义
    _source_file(){
        local par_iso="$1"
        local son_file="$2"

        #log_info "creating isolated file for <$son_file>, then embed to <$par_iso>......"
        local son_iso=$(_create_iso "$son_file")

        cat >> "$par_iso" <<-EOF
            # ${arr_keep_flag[0]}# <$par_iso> source <$son_iso> start:
EOF

        cat "$son_iso" >> "$par_iso"

        cat >> "$par_iso" <<-EOF
            # ${arr_keep_flag[0]}# <$par_iso> source <$son_iso> finished!
EOF
        #log_info "<$son_iso> inserted to <$par_iso>"
        # 删除危险, 必须保证不会误删源文件
        [[ -z "$preserve" && "$son_iso" != "$son_file" ]] &&  rm -f "$son_iso" 
    }

    # 获取表示区域开始或结束行的正则表达式
    # $1  flag: 区域标志字符串
    # $2  is_endregion: 空表示区域开始
    _reg_region(){
        [ -z "$2" ] && \
            echo "^[[:space:]]*#[[:space:]]*region[[:space:]]+$1($|[[:space:]]+)" || \
            echo "^[[:space:]]*#[[:space:]]*endregion[[:space:]]+$1($|[[:space:]]+)"
    }

    # 创建独立文件
    # 参数:
    #       $1: script path 源文件全路径
    # 返回值: 
    #       独立文件全路径
    _create_iso(){

        local _curr_src="$1"
        # local dir=$(dirname "$_curr_src")
        # local name=$(basename "$_curr_src")

        # 当前查验的行是否属于编译调用, 非空表示是
        local compiring

        # 当前是否是约定指定目录和排除文件的循环加载, 非空表示是
        local blocking

        # 当前是否是常规的 source 脚本, 非空表示是
        local sourcing

        # 当前行是否是 shebang 行, 非空表示是
        local shebang

        # 清空 iso
        local iso_f="$_curr_src$iso"
        > "$iso_f" 

        # 源文件先删除末尾所有空白行(如果有), 然后在末尾添加一空行,以确保最后一行可读取
        sed -i -e :a -e '/^\n*$/{$d;N;ba' -e '}' "$_curr_src"
        echo "" >> "$_curr_src"

        local line file cmdline statements=
        while IFS= read -r line; do
            [[ "$line" =~ ^[[:space:]]*#[[:space:]]*![[:space:]]*/bin/bash[[:space:]]*$ ]] && shebang=y || shebang=
            [[ "$line" =~ ^[[:space:]]*(source|\.)[[:space:]]+.+$ ]] && sourcing=y || sourcing=

            [[ "$line" =~ $(_reg_region "$ignore_flag") ]] && compiring=y
            [[ "$line" =~ $(_reg_region "$ignore_flag" end) ]] && compiring=

            [[ "$line" =~ $(_reg_region "$source_block_flag") ]] && blocking=y
            [[ "$line" =~ $(_reg_region "$source_block_flag" end) ]] && blocking=

            if [ -z "$shebang" -a -z "$compiring" ]; then
                if [[ "$blocking" ]]; then
                    #  log_info "blocking source section found.root dir:<$ROOT_DIR_PATH>, entry file: <$ENTRY_FILE_NAME}>"
                    if [[ ! "$line" =~ ^[[:space:]]*# && ! "$line" =~ ^\s*$ ]]; then
                        # 非注释语句, 也非空行, 则删除末尾转行符(如果有, 且转行符后一定无多余字符), 再用空格连接起来
                        statements+="${line%\\} " 
                    fi
                else
                    if [ "$statements" ]; then
                        # blocking 结束, 如果 statements 非空, 则解析其中的 find 语句
                        local finds=$(echo $statements | grep -E 'find[^)]+?' -o) 
                        local arr_find 
                        readarray -t arr_find <<< "$finds"
                        for cmdline in "${arr_find[@]}"; do
                            for file in $(eval "$cmdline"); do
                                _source_file "$iso_f" "$file"
                            done
                        done
                        # 清空, 准备接收下一次 blocking
                        statements=
                    fi

                    # 继续处理其他语句
                    if [ "$sourcing" ]; then
                        # 需要先解析 $line 字符串中可能包含的对于源代码生效的变量, 例如 $ROOT_DIR_PATH
                        file=$(eval "echo -n $line" | sed -r 's/^\s*[^ ]*\s+(.+)\s*$/\1/') #  ok
                        _source_file "$iso_f" "$file"
                    else
                        # 会输出 sourcing 和 compiling 的结束标志, 但不影响, 因为最后瘦身时会去掉.
                        # 如果一定要屏蔽这两句, 还需要引入正则表达式 
                        echo "$line" >> "$iso_f"
                    fi
                fi
            fi
        done < "$_curr_src"
        echo "$iso_f"
    }

    # 复制附属文件(例如运行中入口文件读取必须的 json 配置文件, 自读文件等)
    # src_dir / tgt_dir 位于调用端 main() 
    _copy_attaches(){
        local paths path par
        readarrayx "$attaches"  paths
        for path in "${paths[@]}";do
            if [ "$path" -a -e "$src_dir/$path" ];then
                par="$tgt_dir/$(dirname "$path")"
                [ ! -d "$par" ] && mkdir -p "$par"
                cp -r "$src_dir/$path" "$par" 2>/dev/null || log_error "copy file/folder <$src_dir/$path> failed."
            fi
        done
    }

    main(){
        local target_path attaches ignore_flag source_block_flag iso slim plf keep_flags preserve 
        eval $PASX
        params " 'target-path t' " \
            " 'attaches a ' " \
            " 'ignore-flag n' Ignored " \
            " 'source-block-flag b' Sourcing " \
            " 'iso i' .iso " \
            " 'slim s' .slm " \
            " 'plf f' .plf" \
            " keep-flags keep_line "

        # 多处使用
        param_appear preserve p && preserve=y

        # 只编译当前进程的入口脚本
        entry_path=$(readlink -f $0)

        log_info "$(param_look_up)"

        local arr_keep_flag
        readarrayx "$keep_flags" arr_keep_flag
        local src_dir=$(dirname $entry_path)
        local tgt_dir=$(dirname $target_path)

        if type shc &>/dev/null; then

            # 1. 创建入口脚本的独立文件, 与源文件同目录
            local iso_file=$(_create_iso "$entry_path")   

            # 2. 利用 awk 脚本, 生成瘦身文件
            # 2.1 临编译前才添加首行 shebang, 因为独立文件的 shebang 都已被忽略
            sed -i  '1i #!/bin/bash' $iso_file   #
    
            # 2.2 瘦身文件名只是在独立文件末尾添加扩展名
            local slim_file="$iso_file$slim"

            # 2.3 创建瘦身文件, 其中 awk 脚本通过当前 compile.sh 脚本位置定位, 
            # 注意本框架 source 的脚本, 全部是绝对路径
            awk -v keep_flags="$keep_flags"  -f "$(dirname "${BASH_SOURCE[0]}")/awk/slim.awk"  $iso_file  > $slim_file

            # 2.4 移动瘦身文件到目标可执行文件同位置
            ensure_par $target_path
            mv -f "$slim_file" "$tgt_dir"

            # 2.5 更新瘦身文件路径变量
            slim_file=$tgt_dir/$(path_parse "$slim_file" namex)

            # 3. 编译瘦身文件, 得到目标文件
            shc -rU -f  "$slim_file" -o "$target_path" # -e 03/01/2025 -m "sorry, it's expired"

            # 4. 删除: 危险! 必须保证不会误删源文件(但 --iso 被强制配置为空). 
            # 虽然 iso 已经被内定位设置为空时, --iso 自动变成 .iso
            [[ -z "$preserve"  && "$iso_file" != "$entry_path" ]] \
                    &&  rm -f "$iso_file"  "$slim_file.x.c"

            # 5. 根据参数指定, 将自编译代码也写入瘦身文件   
            if ! param_appear give-up-self-compile g; then
                local cln
                [ -z "$preserve" ] && cln="--clean"
                cat >> "$slim_file" <<- EOF
                    plf=$plf
                    cln=$cln
EOF
                cat >> "$slim_file" <<- 'EOF'
                    compile_single_file --source "$(readlink -f $0)" --exts "$plf" $cln
EOF
            fi

            # 7. 拷贝附件到目标文件同目录
            _copy_attaches

            log_info "Finished creating execution:<$target_path>" 2>/dev/null || :
        else
             log_error "shc not found. You must install it at first." 2>/dev/null || :           
        fi
    }  
 
    main "$@"

    unset -f _source_file _create_iso _copy_attaches _reg_region  main
}

:<<COMMENT
    编译单一文件, 不考虑内部 source 的脚本. 详情见 doc/compile.txt.  
    用法: compile_single_file   --source(-s) <path> \
                                --exts(-e)   <extention> \
                                --clean(-c)  ?
COMMENT
compile_single_file(){
    local source exts 
    eval $PASX
    params  " 'source s' " \
            " 'exts e' "
    if [ -f "$source" ]; then
        local orig_exts=$(path_parse "$source" exts)
        if [ "$exts" -a "$exts" != "$orig_exts" -a "$exts" != ".x.c" ]; then
            local name=$(path_parse "$source" name)
            local dir=$(path_parse "$source" par)
            local target=$dir/$name$exts

            # 虽然可执行文件运行时, target 得到目录而不是文件(! -f 判断就可以), 但 -e 更保险
            if [ ! -e "$target" ]; then
                if ! type shc 2>/dev/null; then
                    tip "shc is required, please wait a moment..."
                    func=apt
                    if ! type apt 2>/dev/null; then
                        func=yum
                        yum clean all
                    fi
                    $func update -y
                    $func install shc -y
                fi
                # 即时编译为适应当前平台的可执行文件
                shc -rU -f "$source" -o "$target"
                param_appear clean c && rm -f "$source"  "$source.x.c" || :
                return 0  # no essential
            fi
        else
            log_error "指定的目标文件扩展名非法, 可能为空, 或与源文件重名, 或者是 .x.c"
            return 2
        fi
    else
        log_error "指定的源文件不存在, 请检查是否拼写有无."
        return 1
    fi
}

该函数的详细说明, 见 doc/compile.txt, 该文件内容比代码还长, 篇幅关系, 就不在此粘贴了. 有需要的可前往下载区下载.

【测试】

作为一个完整的测试, 需要包含几个文件:

  1. 入口文件 ./main.sh;
  2. 使用 for 循环结合 find 命令 source 的库(模块), 略;
  3. 单一 source 命令加载的 ./test/lnx-code/spa ce     dir/model.sh;
  4. 在上述 model.sh 中, 嵌套 source 命令加载的 ./test/lnx-code/bingo.sh 文件.

注意上面的 model.sh 路径中一共包含6个空格(前1后5).

主要文件的基本构成如下:

入口脚本 main.sh 代码如下:

#!/bin/bash

# blogged

set -e 

# global constant:
declare -r __DEBUG__=

declare -r ENTRY_FILE_PATH=$(readlink -f $0)
declare -r ENTRY_FILE_NAME=$(basename $ENTRY_FILE_PATH)

declare -r ROOT_DIR_PATH=$(dirname $ENTRY_FILE_PATH) 
declare -r ROOT_DIR_NAME=$(basename $ROOT_DIR_PATH)

# 根据当前脚本与 library 和 execution 的相对位置, .. 可能有所变化
declare -r LNX_LIB_DIR="$ROOT_DIR_PATH/../../library"

# 块 source, 详情查看 library/doc/compile.txt
# region Sourcing-Here
for file in \
    $(find $ROOT_DIR_PATH \
        -path "$ROOT_DIR_PATH/log" -prune -o \
        -path "$ROOT_DIR_PATH/data" -prune -o \
        -path "$ROOT_DIR_PATH/doc" -prune -o \
        -path "$ROOT_DIR_PATH/test" -prune -o \
        -path "$ROOT_DIR_PATH/test-shc" -prune -o \
        -path "$ROOT_DIR_PATH/lib/bak" -prune -o \
        -type f -regex .*?\.sh$ \
        -not -wholename "$ENTRY_FILE_PATH" -print ) \
    $(find $LNX_LIB_DIR \
        -type f -regex .*?\.sh$ ) ; do
      source $file
done
# endregion Sourcing-Here   

caption "current process: \$\$:<$$>, \$PPID:<$PPID>"
view_global(){
    echo "All global readonly constants:
     root dir: <$ROOT_DIR_PATH>
     root dir name: <$ROOT_DIR_NAME>
     entry path: <$ENTRY_FILE_PATH>
     entry name: <$ENTRY_FILE_NAME>
     library path: <$LNX_LIB_DIR>
     "
}
   .     "$ROOT_DIR_PATH/test/lnx-code/spa ce     dir/model.sh"  
clear
view_global

menu "start testing...."
# -------------------------------start ---------------------------------

  __TEST__=re

  if [  -z "$__TEST__" ]; then
      work_start
  else
    #   testing1  
    #   testing2 
    #   testing3 
    #   testing4  
    #   testing5 
    #   testing6 
        testing7
  fi

echo 
aop "success -e" center "Thanks for your use. Byebye!"

model.sh 很简单:


#!/bin/bash 

first(){
    info "file: ${BASH_SOURCE[0]}, function: ${FUNCNAME[0]}"

} 

source "$ROOT_DIR_PATH/test/lnx-code/bingo.sh" 

#---------------------------------------start--------------------------------------

model_testing(){
    first
    tip "${BASH_SOURCE[0]} \t\t all done!"    
}
model_testing

bingo.sh 见上一篇文件瘦身.

下面是 unit_test7.sh(已裁剪掉与本篇无关的代码):

test_compiling(){
    #   region   Here-CompilingX       
    local cmdline="script2execution 
            --target-path \"$ROOT_DIR_PATH/../../execution/$ROOT_DIR_NAME/${ENTRY_FILE_NAME%.*}\"
            --attaches \" data/readme.txt 'lib/function.sh' asset 
                        'test/lnx-code/spa ce     dir'  ''  '   ' doc temp 
                        'abc kmd'  no-such-fold-or-file  kkb   \"
            --ignore-flag Here-CompilingX
            --source-block-flag Sourcing-Here
            --iso .alone
            --slim .thin
            --plf .lnx
            --keep-flags \"useful_comments keep_line donot_delete_this_comment\"
           # --preserve
        "
    # 极简调用方式, 但要注意将源文件的 compiling 块和 soucing 块的名称写成函数默认, 即 Ignored 和 Sourcing:
    # local cmdline="script2execution --target-path \"$ROOT_DIR_PATH/../../execution/$ROOT_DIR_NAME/${ENTRY_FILE_NAME%.*}\""
    
    #perform -p "编译中, 请稍候......" -l "$cmdline" -d 0.2 -erase
    tip "Congratulaions. file compiled."    
    #   endregion Here-CompilingX

    info "${FUNCNAME[0]} ended !"
}
testing7(){
   test_compiling      # compile.sh / script2execution 编译测试
}

注意, main.sh 中, 块 source 区域名为 Sourcing-Here; 在 unit_test7.sh / test_func_compiling 函数中, 编译区块的区域名为 Here-CompilingX. 这已经在 cmdline 字符串中对应起来.

第一步, 先将 unit_test7.sh / test_compiling 函数的 perform 语句注释掉, 执行 main.sh 得到下面的结果:

第二步, 开放上一句, 即测试 perform … 语句, 这是本篇的重点, 下面是运行过程:

这里的配置指示不保留中间文件, 所以在只是在目标目录得到以下文件结构:

其中 asset doc 等目录是作为附件(这些文件可能在可执行文件运行时需要读取)复制过来的. 我们的重点是 main 和 main.sh.alone.thin.

运行 main(无扩展名, 当然也可通过 –target-path 指定你喜欢的扩展名):

与上面执行 main.sh 结果一致, 除了看不到这句: Congratulaions. file compiled. 因为它已经和编译执行语句一起被收割了.

现在执行 main.sh.alone.thin, 结果如下:

注意, 这次比上面在末尾多了一句 shc is /usr/bin/shc, 这是自编译代码在测试 shc 是否已经安装留下的痕迹. 此时查看 execution/blogging 目录, 发现 main.sh.alone.thin 被删除, 另外生成了 main.lnx 文件(这也是一个可执行体):

运行 main.lnx, 结果如下:

可以看到, 原始脚本 main.sh / 可执行体 main / 可执行体 main.lnx, 运行结果完全一致. 而通过 main.sh.alone.thin 脚本运行一次得到 main.lnx 的优势在于, 如果发现 shc 与 linux 发行版(或版本)存在兼容性问题时, 只要让 .thin 脚本在该 OS 上运行一次, 即可得到需要的可执行体. 当然, 如果 script2execution 的调用参数, 指定 –give-up-self-compile 或 -g, 则放弃自编译, 瘦身文件仅仅作为一个无外部依赖的已打包子脚本在内的合成脚本.

谢谢收看!