用户、用户组与权限
本节目标
识别当前用户与用户组,读懂普通文件权限,并在严格限定的 dev-lab 中只给新建练习脚本增加用户执行位。
开始前状态
你已完成 LINUX-01:权威 shell checkpoint 仍保持原样,并精确新增了文件系统阶段的 notes/linux-notes.md。本节在任何写入前验证完整状态。
运行位置: LINUX-01 完成后的 dev-lab 根目录。预期输出: 显示当前用户和所属组,再列出笔记文件的权限。失败处理: 输出仅用于观察;若路径不存在,先恢复上一节规范状态,不要通过修改所有者或提升权限修复。
id
ls -l notes/linux-notes.md
必要原理
id 显示用户 ID、主要用户组和附加组。ls -l 的权限位分为用户、组与其他用户三组。普通文件即使包含 Bash 文本,也不会因为扩展名自动可执行;test -x 可以检查当前调用者是否有执行权限。
chmod u+x 只增加用户执行位。本节不递归修改权限,不修改所有者,也不对既有最终路径操作。符号链接会把操作引向其他位置,因此必须先拒绝链接,权限变化只能发生在刚创建并验证过的同目录草稿上。
安全警示:不要提升权限、递归修改权限或修改所有者来解决练习问题;不要对来源不明或尚未阅读的脚本增加执行位。
动手完成
状态迁移会先确认 DEVLAB_DIR、shell checkpoint、LINUX-01 笔记和所有现有权限,再写新草稿。只有新建且确认为非符号链接普通文件的脚本草稿会先规范普通文件权限,再执行 chmod u+x;最终路径不会先 chmod。
运行位置: 仓库中能够访问 code/devenv/dev-lab/checks/check-shell.sh 的目录,且 DEVLAB_DIR 指向完成 LINUX-01 的 lab。预期输出: 新增可执行脚本,并把 Linux 笔记原子推进到权限章节。失败处理: 任一检查失败都会保持 lab 和外部目标不变并清理草稿;先恢复规范前置状态再重试。
advance_linux_permissions() (
set -euo pipefail
devlab_input="${DEVLAB_DIR:-}"
[[ -n "$devlab_input" ]] || { printf 'DEVLAB_DIR 必须是明确的练习目录。\n'; exit 1; }
[[ -d "$devlab_input" ]] || { printf 'DEVLAB_DIR 必须是真实目录。\n'; exit 1; }
devlab_logical="$(cd -- "$devlab_input" && pwd -L)" || exit 1
devlab_dir="$(cd -- "$devlab_input" && pwd -P)" || exit 1
[[ "$devlab_logical" == "$devlab_dir" && ! -L "$devlab_input" ]] || {
printf 'DEVLAB_DIR 不得经过符号链接。\n'
exit 1
}
[[ "$devlab_dir" != / ]] || { printf 'DEVLAB_DIR 必须是明确的练习目录。\n'; exit 1; }
if [[ -n "${HOME:-}" && -d "$HOME" ]]; then
home_dir="$(cd -- "$HOME" && pwd -P)" || exit 1
[[ "$devlab_dir" != "$home_dir" ]] || { printf 'DEVLAB_DIR 不得是用户主目录。\n'; exit 1; }
fi
notes_dir="$devlab_dir/notes"
scripts_dir="$devlab_dir/scripts"
current_notes="$notes_dir/linux-notes.md"
notes_draft="$notes_dir/.linux-notes.md.next"
final_script="$scripts_dir/permission-demo.sh"
script_draft="$scripts_dir/.permission-demo.sh.next"
for required_directory in "$notes_dir" "$scripts_dir"; do
[[ -d "$required_directory" && ! -L "$required_directory" ]] || { printf '前置目录必须是真实目录。\n'; exit 1; }
done
file_identity() {
local identity
if identity="$(LC_ALL=C stat -L -c '%d:%i' "$1" 2>/dev/null)"; then :; else
identity="$(LC_ALL=C stat -L -f '%d:%i' "$1")" || return 1
fi
printf '%s' "$identity"
}
fd_matches_identity() {
local fd_identity
fd_identity="$(file_identity "$1")" || return 1
[[ "$fd_identity" == "$2" || "$(/usr/bin/uname -s 2>/dev/null || /bin/uname -s)" == Darwin \
&& "${fd_identity##*:}" == "${2##*:}" ]]
}
remove_if_owned() {
local owned_path="$1"
local owned_identity="$2"
local parent_path="${owned_path%/*}"
local owned_name="${owned_path##*/}"
local parent_identity=''
[[ -n "$owned_identity" ]] || return 0
case "$parent_path" in
"$notes_dir") parent_identity="$notes_dir_identity" ;;
"$scripts_dir") parent_identity="$scripts_dir_identity" ;;
*) return 1 ;;
esac
(
cd -P -- "$parent_path" || exit 1
cleanup_owned_boundary="$owned_name"
[[ "$(file_identity .)" == "$parent_identity" \
&& "$(file_identity ..)" == "$devlab_identity" ]] || exit 1
[[ ! -e "./$owned_name" && ! -L "./$owned_name" ]] && exit 0
[[ -f "./$owned_name" && ! -L "./$owned_name" \
&& "$(file_identity "./$owned_name" 2>/dev/null)" == "$owned_identity" ]] || exit 1
/bin/rm -f -- "./$owned_name"
)
}
create_owned_file() {
local owned_path="$1"
local parent_path="$2"
local parent_identity="$3"
local mode="$4"
local identity_variable="$5"
local owned_name="${owned_path##*/}" caller_directory="$PWD" created_identity=''
cd -P -- "$parent_path" || return 1
[[ "$(file_identity .)" == "$parent_identity" \
&& "$(file_identity ..)" == "$devlab_identity" ]] || return 1
set -o noclobber
exec 6> "./$owned_name" || { set +o noclobber; return 1; }
set +o noclobber
created_identity="$(file_identity "./$owned_name")" || { exec 6>&-; return 1; }
printf -v "$identity_variable" '%s' "$created_identity"
owned_post_create_boundary="$owned_name"
fd_matches_identity /dev/fd/6 "$created_identity" || { exec 6>&-; return 1; }
/bin/cat >&6 || { exec 6>&-; return 1; }
owned_post_write_boundary="$owned_name"
/bin/chmod "$mode" /dev/fd/6 || { exec 6>&-; return 1; }
owned_post_mode_boundary="$owned_name"
fd_matches_identity /dev/fd/6 "$created_identity" || { exec 6>&-; return 1; }
[[ -f "./$owned_name" && ! -L "./$owned_name" \
&& "$(file_identity "./$owned_name")" == "$created_identity" \
&& "$(file_identity .)" == "$parent_identity" \
&& "$(file_identity ..)" == "$devlab_identity" ]] || { exec 6>&-; return 1; }
exec 6>&-
cd -P -- "$caller_directory" || return 1
}
publish_owned_file() {
local owned_path="$1" owned_identity="$2" final_path="$3" parent_identity="$4" expected_final_identity="${5:-}"
local parent_path="${owned_path%/*}" owned_name="${owned_path##*/}" final_name="${final_path##*/}"
(
cd -P -- "$parent_path" || exit 1
publication_boundary="$owned_name->$final_name"
[[ "$(file_identity .)" == "$parent_identity" \
&& "$(file_identity ..)" == "$devlab_identity" ]] || exit 1
[[ -f "./$owned_name" && ! -L "./$owned_name" \
&& "$(file_identity "./$owned_name")" == "$owned_identity" ]] || exit 1
if [[ -n "$expected_final_identity" ]]; then
[[ -f "./$final_name" && ! -L "./$final_name" \
&& "$(file_identity "./$final_name")" == "$expected_final_identity" ]] || exit 1
publication_commit_boundary="$owned_name->$final_name"
[[ -f "./$owned_name" && ! -L "./$owned_name" \
&& "$(file_identity "./$owned_name")" == "$owned_identity" \
&& -f "./$final_name" && ! -L "./$final_name" \
&& "$(file_identity "./$final_name")" == "$expected_final_identity" ]] || exit 1
if [[ "$(/usr/bin/uname -s 2>/dev/null || /bin/uname -s)" == Darwin ]]; then
/bin/mv -f -h -- "./$owned_name" "./$final_name" || exit 1
else
/bin/mv -f -T -- "./$owned_name" "./$final_name" || exit 1
fi
else
[[ ! -e "./$final_name" && ! -L "./$final_name" ]] || exit 1
publication_commit_boundary="$owned_name->$final_name"
[[ -f "./$owned_name" && ! -L "./$owned_name" \
&& "$(file_identity "./$owned_name")" == "$owned_identity" \
&& ! -e "./$final_name" && ! -L "./$final_name" ]] || exit 1
if [[ "$(/usr/bin/uname -s 2>/dev/null || /bin/uname -s)" == Darwin ]]; then
/bin/mv -n -h -- "./$owned_name" "./$final_name" || exit 1
else
/bin/mv -n -T -- "./$owned_name" "./$final_name" || exit 1
fi
fi
[[ ! -e "./$owned_name" && ! -L "./$owned_name" \
&& -f "./$final_name" && ! -L "./$final_name" \
&& "$(file_identity "./$final_name")" == "$owned_identity" \
&& "$(file_identity .)" == "$parent_identity" \
&& "$(file_identity ..)" == "$devlab_identity" ]]
)
}
devlab_identity="$(file_identity "$devlab_dir")" || exit 1
notes_dir_identity="$(file_identity "$notes_dir")" || exit 1
scripts_dir_identity="$(file_identity "$scripts_dir")" || exit 1
current_notes_identity="$(file_identity "$current_notes")" || exit 1
for required_directory in config data notes output scripts; do
[[ -d "$devlab_dir/$required_directory" && ! -L "$devlab_dir/$required_directory" ]] || {
printf '前置目录必须是真实目录:%s\n' "$required_directory"
exit 1
}
done
expected_paths='README.md
config
config/bashrc.dev-lab
data
data/access.log
notes
notes/linux-notes.md
notes/shell-notes.md
output
output/summary.txt
scripts
scripts/report.sh'
actual_paths="$(cd "$devlab_dir" && find . -mindepth 1 -print | sed 's#^\./##' | LC_ALL=C sort)"
[[ "$actual_paths" == "$expected_paths" ]] || { printf 'LINUX-01 路径集合不匹配。\n'; exit 1; }
require_exact() {
local relative_path="$1"
local expected_content="$2"
[[ -f "$devlab_dir/$relative_path" && ! -L "$devlab_dir/$relative_path" ]] \
&& cmp -s "$expected_content" "$devlab_dir/$relative_path" \
|| { printf '前置文件不匹配:%s\n' "$relative_path"; exit 1; }
}
has_any_execute_bit() {
local mode
mode="$(LC_ALL=C ls -ld "$1")"
mode="${mode%% *}"
[[ "$mode" == *[xstST]* ]]
}
require_exact 'README.md' <(printf '%s\n' \
'# dev-lab Shell checkpoint' \
'' \
'这是终端与 Shell 板块完成后的权威参考状态。它保留固定访问日志、确定性摘要、隔离 Bash 配置和只会写入明确输出文件的报告脚本。' \
'' \
'请在此目录的父目录运行 `check-shell.sh`;checker 只读取目标,不会 source 配置或执行报告脚本。')
require_exact 'config/bashrc.dev-lab' <(printf '%s\n' \
"export DEVLAB_MODE='practice'" \
'' \
'devlab-root() {' \
' printf '\''%s\n'\'' "${DEVLAB_DIR:?set DEVLAB_DIR before using devlab-root}"' \
'}' \
'' \
'devlab-summary() {' \
' bash "${DEVLAB_DIR:?set DEVLAB_DIR first}/scripts/report.sh" "$DEVLAB_DIR"' \
'}')
require_exact 'data/access.log' <(printf '%s\n' \
'2026-08-12T09:00:00Z GET / 200' \
'2026-08-12T09:00:01Z GET /docs/devenv/welcome 200' \
'2026-08-12T09:00:02Z GET /docs/devenv/module-route 200' \
'2026-08-12T09:00:03Z GET /missing 404' \
'2026-08-12T09:00:04Z POST /practice 200' \
'2026-08-12T09:00:05Z GET /old-link 404')
require_exact 'notes/shell-notes.md' <(printf '%s\n' \
'# Shell 练习笔记' \
'' \
'历史记录可能包含参数、路径或令牌;先检查再分享或清理。Tab 补全帮助确认命令和文件名,但不代替阅读实际路径。' \
'' \
'本 checkpoint 的 Bash 配置仅用于 `DEVLAB_DIR` 指定的隔离练习目录。')
require_exact 'output/summary.txt' <(printf '%s\n' \
'total requests: 6' \
'successful requests: 4' \
'not found: 2')
require_exact 'scripts/report.sh' <(printf '%s\n' \
'#!/usr/bin/env bash' \
'set -euo pipefail' \
'' \
'[[ "$#" -eq 1 ]] || { printf '\''usage: report.sh DEV_LAB_DIR\n'\'' >&2; exit 64; }' \
'devlab_dir="${1:?usage: report.sh DEV_LAB_DIR}"' \
'input="$devlab_dir/data/access.log"' \
'output="$devlab_dir/output/summary.txt"' \
'[[ -f "$input" ]] || { printf '\''missing input: %s\n'\'' "$input" >&2; exit 1; }' \
'' \
'total="$(wc -l < "$input" | tr -d '\'' '\'')"' \
'successful="$(grep -c '\'' 200$'\'' "$input" || true)"' \
'not_found="$(grep -c '\'' 404$'\'' "$input" || true)"' \
'' \
'{' \
' printf '\''total requests: %s\n'\'' "$total"' \
' printf '\''successful requests: %s\n'\'' "$successful"' \
' printf '\''not found: %s\n'\'' "$not_found"' \
'} > "$output"')
[[ -x "$devlab_dir/scripts/report.sh" ]] || { printf 'report.sh 必须可执行。\n'; exit 1; }
bash -n "$devlab_dir/scripts/report.sh"
bash -n "$devlab_dir/config/bashrc.dev-lab"
for relative_path in README.md config/bashrc.dev-lab data/access.log notes/shell-notes.md output/summary.txt notes/linux-notes.md; do
! has_any_execute_bit "$devlab_dir/$relative_path" || { printf '前置文件不得可执行:%s\n' "$relative_path"; exit 1; }
done
[[ ! -e "$final_script" && ! -L "$final_script" ]] || { printf '目标已存在,停止并检查:scripts/permission-demo.sh\n'; exit 1; }
[[ ! -e "$script_draft" && ! -L "$script_draft" ]] || { printf '脚本草稿路径已存在,停止并检查。\n'; exit 1; }
[[ ! -e "$notes_draft" && ! -L "$notes_draft" ]] || { printf '笔记草稿路径已存在,停止并检查。\n'; exit 1; }
[[ -f "$current_notes" && ! -L "$current_notes" && ! -x "$current_notes" ]] || { printf 'LINUX-01 笔记类型或权限不匹配。\n'; exit 1; }
cmp -s <(printf '%s\n' \
'# Linux 基础练习笔记' \
'' \
'## 文件系统' \
'- 系统目录只观察;所有练习写入明确的 dev-lab。' \
'- Linux 与 WSL 的用户目录通常位于 /home,macOS 用户目录位于 /Users。') \
"$current_notes" || { printf 'LINUX-01 笔记字节不匹配。\n'; exit 1; }
script_draft_identity=''
notes_draft_identity=''
final_script_identity=''
cleanup_linux_permission_drafts() {
local status="$?" cleanup_failed=0
trap - EXIT
remove_if_owned "$script_draft" "$script_draft_identity" || cleanup_failed=1
remove_if_owned "$notes_draft" "$notes_draft_identity" || cleanup_failed=1
[[ "$cleanup_failed" -eq 0 ]] || exit 1
exit "$status"
}
trap cleanup_linux_permission_drafts EXIT
create_owned_file "$script_draft" "$scripts_dir" "$scripts_dir_identity" u=rw,go=r,u+x script_draft_identity <<'EXPECTED'
#!/usr/bin/env bash
set -euo pipefail
printf 'permission demo: ok\n'
EXPECTED
cmp -s <(cat <<'EXPECTED'
#!/usr/bin/env bash
set -euo pipefail
printf 'permission demo: ok\n'
EXPECTED
) "$script_draft" || exit 1
bash -n "$script_draft"
create_owned_file "$notes_draft" "$notes_dir" "$notes_dir_identity" u=rw,go=r notes_draft_identity <<'EXPECTED'
# Linux 基础练习笔记
## 文件系统
- 系统目录只观察;所有练习写入明确的 dev-lab。
- Linux 与 WSL 的用户目录通常位于 /home,macOS 用户目录位于 /Users。
## 用户、用户组与权限
- 先确认目标是非符号链接普通文件,再只增加用户执行位。
EXPECTED
cmp -s <(printf '%s\n' \
'# Linux 基础练习笔记' \
'' \
'## 文件系统' \
'- 系统目录只观察;所有练习写入明确的 dev-lab。' \
'- Linux 与 WSL 的用户目录通常位于 /home,macOS 用户目录位于 /Users。' \
'' \
'## 用户、用户组与权限' \
'- 先确认目标是非符号链接普通文件,再只增加用户执行位。') \
"$notes_draft" || exit 1
[[ "$(file_identity "$scripts_dir")" == "$scripts_dir_identity" \
&& "$(file_identity "$script_draft")" == "$script_draft_identity" ]] || exit 1
publish_owned_file "$script_draft" "$script_draft_identity" "$final_script" "$scripts_dir_identity"
final_script_identity="$script_draft_identity"
[[ "$(file_identity "$final_script")" == "$final_script_identity" ]] || exit 1
[[ "$(file_identity "$notes_dir")" == "$notes_dir_identity" \
&& "$(file_identity "$notes_draft")" == "$notes_draft_identity" ]] || exit 1
if ! publish_owned_file "$notes_draft" "$notes_draft_identity" "$current_notes" "$notes_dir_identity" "$current_notes_identity"; then
remove_if_owned "$final_script" "$final_script_identity"
exit 1
fi
[[ "$(file_identity "$current_notes")" == "$notes_draft_identity" ]] || exit 1
)
advance_linux_permissions
transition_status="$?"
unset -f advance_linux_permissions
[[ "$transition_status" -eq 0 ]]
验证脚本语法、用户执行位和精确输出;这些命令不再修改权限。
运行位置: 已完成迁移的 dev-lab 根目录。预期输出: test -x 成功,脚本 stdout 精确为 permission demo: ok。失败处理: 如果检查失败,不要补做宽泛 chmod;重新核对文件类型、内容和迁移日志。
ls -l scripts/permission-demo.sh
test -x scripts/permission-demo.sh
bash -n scripts/permission-demo.sh
scripts/permission-demo.sh
观察结果
脚本最初作为普通文件草稿创建,先以符号模式消除调用者 umask 的影响,内容与语法通过检查后才执行 chmod u+x,并原子移动到最终路径。最终模式只在规范普通文件权限上新增用户执行位,组与其他用户没有获得执行权限。
常见问题
为什么不直接对最终文件执行 chmod? 最终路径可能已被其他文件或符号链接占据。先创建、验证同目录草稿,能把权限变化严格绑定到本次新文件。
Permission denied 是否总靠 chmod 解决? 不是。目录访问、所有者、挂载选项和脚本解释器都可能影响执行;先用 id、ls -l、文件类型和 test -x 缩小原因。
完成检查
- 能用
id观察用户与用户组; - 能用
ls -l读普通文件权限,并用test -x检查执行权限; - 理解
chmod u+x只增加用户执行位; - 能解释普通文件与符号链接的安全差异;
- 脚本语法通过,stdout 精确为
permission demo: ok; - Linux 笔记只原子新增权限章节。
下一步
下一节会继续沿用“先验证精确前置状态,再限定目标”的原则,学习进程、任务与信号;Linux 基础七节现已完整发布。