Linux 基础体系 · 第 40/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Linux 文件命令体系:ls、cp、mv、rm、stat、file 和安全操作

Linux 中“操作文件”并不只是读写一个文件名。一个路径通常要经过目录查找,最终定位到文件系统中的 inode;命令还会受到权限、符号链接、挂载点、文件系统边界、进程并发和 shell 展开的影响。

ls 主要用于观察目录项,stat 用于观察文件元数据,file 用于根据内容识别类型,cpmvrm 则改变目录结构或文件内容。要正确使用它们,必须先区分以下几个概念:

  • 路径名:用户输入的字符串,例如 /var/log/app.log
  • 目录项(directory entry,dentry):目录中“名字到 inode”的映射。
  • inode:保存文件类型、权限、所有者、时间、大小以及数据块位置等元数据的内核对象。
  • 文件内容:普通文件对应的数据;目录本身的内容是目录项集合。
  • 文件描述符:进程打开文件后获得的整数句柄。它指向一次打开实例,而不是简单指向文件名。

因此,文件名可以改变,inode 可以不变;多个文件名可以指向同一个 inode;删除文件名也不一定立即删除文件内容。


一、先建立文件命令的共同模型

设路径为:

/home/wr/report.txt

访问它时,内核大致执行如下路径查找:

/ -> home -> wr -> report.txt

每一级目录都需要执行目录查找。对于普通文件,最终目录项会指向一个 inode。若路径中的某一级是符号链接,内核还要读取符号链接内容并继续解析。

可以把普通文件理解成:

目录项 "report.txt" ──> inode 12345 ──> 文件数据块

创建硬链接后:

目录项 "report.txt" ──┐
                      ├──> inode 12345 ──> 文件数据块
目录项 "latest.txt" ──┘

两个名字对应同一个 inode。删除其中一个名字,只会减少 inode 的链接计数;只有当:

  1. 硬链接计数变为 0;
  2. 没有进程仍然打开该文件;

文件内容占用的空间才通常可以回收。这里的“通常”是因为具体回收还取决于文件系统实现。

符号链接则不同:

目录项 "latest.txt" ──> symlink inode ──> 字符串 "report.txt"

符号链接有自己的 inode,内容是一个路径字符串。它可能指向不存在的目标,这种状态称为悬空符号链接。

权限检查不是只检查文件本身

访问路径时,目录通常需要执行权限 x 才能继续查找。例如:

/home/wr/report.txt

即使 report.txt 具有读取权限,如果用户没有 /home/wr 的执行权限,仍然可能无法访问它。

删除文件时,主要检查的是父目录的写权限和执行权限,而不是被删除文件本身的写权限。目录的 sticky bit 会进一步限制删除者,例如 /tmp 通常具有:

drwxrwxrwt

在 sticky 目录中,通常只有文件所有者、目录所有者或特权用户可以删除目录项。


二、ls:查看目录项和基本元数据

1. 基本用法

ls
ls /var/log
ls -l /var/log
ls -la /var/log

常用选项:

  • -l:长格式显示。
  • -a:显示以 . 开头的隐藏目录项。
  • -A:显示隐藏项,但省略 ...
  • -h:与 -l 配合,以易读单位显示大小。
  • -d:显示目录本身,而不是列出目录内容。
  • -i:显示 inode 号。
  • -F:在名称后追加类型标记,如目录 /、可执行文件 *、符号链接 @
  • -L:跟随符号链接查看目标。
  • -H:通常只跟随命令行中给出的符号链接,不递归跟随目录内部发现的链接。
  • -t:按修改时间排序。
  • -S:按大小排序。
  • -R:递归列出子目录。

ls 的默认输出不是稳定的机器接口。脚本不应解析普通的 ls 输出,因为文件名可以包含空格、换行、制表符和转义字符。脚本应优先使用 find -print0stat 的格式化输出或专用 API。

2. 解读 ls -l

示例:

-rw-r--r-- 1 wr developers 1842 Mar 18 09:30 report.txt

字段含义如下:

字段 含义
-rw-r--r-- 文件类型和权限
1 硬链接计数
wr 所有者
developers 所属组
1842 文件大小,单位通常是字节
Mar 18 09:30 时间字段,默认通常是修改时间
report.txt 文件名

第一列的第一个字符表示文件类型:

  • -:普通文件
  • d:目录
  • l:符号链接
  • c:字符设备
  • b:块设备
  • p:FIFO
  • s:Unix domain socket

后面的九个权限位分为三组:

rw- r-- r--

分别表示:

所有者    所属组    其他用户
rw-       r--       r--

对目录而言,权限含义与普通文件不同:

  • r:能否读取目录项名称列表。
  • w:能否创建、删除或重命名目录项。
  • x:能否查找目录中的对象、访问已知名称。

因此,对目录只有 r 而没有 x,可能能看到名称列表,却不能访问其中对象;只有 x 而没有 r,可能能访问已知路径,却不能列出目录内容。

3. ls -l 对符号链接的误读

ln -s target.txt link.txt
ls -l link.txt

可能输出:

lrwxrwxrwx 1 wr developers 10 Mar 18 09:40 link.txt -> target.txt

符号链接显示的权限通常是 rwxrwxrwx,但这些权限通常不用于控制目标访问;访问目标时会检查目标文件以及路径目录的权限。

比较以下命令:

ls -l link.txt
ls -lL link.txt
  • 不加 -L:查看符号链接自身。
  • -L:跟随符号链接,查看目标对象。

这是一个重要边界:同一个路径,命令是否跟随符号链接,会改变它观察或操作的对象。

4. ls -i 验证硬链接

printf 'hello\n' > original.txt
ln original.txt alias.txt
ls -li original.txt alias.txt

可能得到:

123456 -rw-r--r-- 2 wr developers 6 Mar 18 10:00 alias.txt
123456 -rw-r--r-- 2 wr developers 6 Mar 18 10:00 original.txt

两个名称的 inode 号相同,硬链接计数为 2。修改任一名称读取到的都是同一份内容:

printf 'changed\n' > alias.txt
cat original.txt

输出:

changed

ls 能快速发现这种关系,但它不适合精确表达所有元数据,尤其是纳秒级时间、数字 UID/GID、设备号、文件系统类型和符号链接目标。需要这些信息时应使用 stat


三、stat:查看 inode 和文件系统元数据

1. statls 的区别

stat report.txt

GNU/Linux 上可能输出:

  File: report.txt
  Size: 1842       Blocks: 8          IO Block: 4096   regular file
Device: 08,01      Inode: 123456      Links: 1
Access: (0644/-rw-r--r--)  Uid: ( 1000/      wr)   Gid: ( 1001/developers)
Access: 2025-03-18 09:20:00.000000000 +0000
Modify: 2025-03-18 09:30:00.000000000 +0000
Change: 2025-03-18 09:30:00.000000000 +0000
 Birth: 2025-03-18 09:20:00.000000000 +0000

关键字段:

  • Size:逻辑大小,普通文件通常以字节计。
  • Blocks:实际分配的文件系统块数,通常以 512 字节单位报告,但具体解释依赖实现和文件系统。
  • IO Block:推荐的 I/O 块大小提示,不等于文件已经占用的磁盘空间。
  • File:对象名称。
  • Device:所在设备的设备号。
  • Inode:inode 编号。
  • Links:硬链接计数。
  • Access:权限模式和文本形式。
  • UidGid:数字 ID 和名称。
  • Access:最后访问时间,通常称为 atime。
  • Modify:文件内容最后修改时间,mtime。
  • Change:inode 元数据最后改变时间,ctime。
  • Birth:创建时间,若文件系统和工具支持。

2. atime、mtime、ctime 不是三个“创建时间”

假设执行:

printf 'abc\n' > sample.txt
stat sample.txt

此时:

  • 写入内容会改变 mtime。
  • 创建文件和设置元数据会改变 ctime。
  • 读取文件可能改变 atime,但 Linux 挂载选项可能使用 relatimenoatime 等策略,导致 atime 不在每次读取时更新。

执行:

chmod 600 sample.txt

通常会改变 ctime,因为权限元数据变了;不会因为 chmod 本身改变文件内容,所以 mtime 通常不变。

执行:

touch -m sample.txt

会显式改变 mtime,并通常也引起 ctime 改变。ctime 不是用户可任意设置的“创建时间”,而是内核维护的 inode 状态变更时间。

3. 逻辑大小和实际占用可能不同

创建稀疏文件:

truncate -s 1G sparse.bin
stat sparse.bin
du -h sparse.bin
du -h --apparent-size sparse.bin

典型现象是:

  • statSize 接近 1G
  • du -h sparse.bin 只显示实际分配的块,可能很小。
  • du --apparent-size 统计逻辑大小,接近 1G

原因是文件可以包含“洞”:逻辑地址范围存在,但对应的数据块尚未实际分配。读取洞时通常得到零字节。备份工具、复制工具和文件系统是否保留稀疏结构,取决于选项和实现。

4. 用格式字符串稳定输出

GNU/Linux 常用:

stat -c '%n inode=%i type=%F mode=%a size=%s links=%h uid=%u gid=%g mtime=%y' -- sample.txt

可能输出:

sample.txt inode=123456 type=regular file mode=600 size=4 links=1 uid=1000 gid=1001 mtime=2025-03-18 10:02:00.000000000 +0000

这里:

  • %n:名称
  • %i:inode
  • %F:文件类型
  • %a:八进制权限
  • %s:逻辑大小
  • %h:硬链接数
  • %u%g:数字 UID/GID
  • %y:人类可读的 mtime

不同系统的 stat 选项并不完全一致。GNU/Linux 上常见的是 -c;某些 BSD 系统使用 -f。脚本在跨发行版或跨 Unix 系统运行时必须确认目标实现。

5. stat 是否跟随符号链接

stat link.txt
stat -L link.txt

通常:

  • stat link.txt:查看符号链接自身。
  • stat -L link.txt:跟随链接查看目标。

这与 lstat(2)stat(2) 的概念对应:前者观察路径本身的符号链接对象,后者在路径解析后观察目标对象。


四、file:根据内容而不是文件名识别类型

1. 扩展名不是文件类型证明

mv image.jpg payload.bin
file payload.bin

file 不主要依赖 .jpg 这样的扩展名,而是读取文件内容,根据 magic database 中的签名和规则进行识别。

例如:

file /bin/sh

可能输出:

/bin/sh: symbolic link to dash

而:

file /bin/dash

可能输出包含 ELF、架构、动态链接器等信息。

常用选项:

  • -b:省略文件名,只输出识别结果。
  • -L:跟随符号链接。
  • -h:不跟随符号链接,查看链接本身。
  • --mime-type:只输出 MIME 类型。
  • --mime-encoding:输出编码。
  • -z:尝试检查压缩文件内部内容,具体行为依版本而定。

示例:

file --mime-type --mime-encoding report.txt

可能输出:

report.txt: text/plain; charset=us-ascii

2. file 的识别过程和边界

file 的判断大致可能经过:

  1. 检查是否为目录、设备、符号链接等特殊对象。
  2. 读取文件头部或特定偏移。
  3. 使用 magic database 识别已知格式。
  4. 对文本内容进行字符集或语言启发式判断。
  5. 输出最匹配的描述。

它不是安全沙箱,也不是完整解析器。以下情况都可能导致误判或信息不足:

  • 文件格式没有标准魔数。
  • 文件被截断。
  • 文件是多格式 polyglot。
  • 内容经过加密或压缩。
  • 恶意内容专门伪装成另一种格式。
  • 本地 magic database 版本不同。

因此,file 可以用于初步诊断和输入筛选,但不能替代真正的格式解析、签名验证或安全扫描。不能因为:

file upload.bin: PNG image data

就断定它一定是安全图片;也不能因为文件名是 .sh 就断定它一定是 shell 脚本。


五、cp:复制数据、元数据以及链接语义

1. 基本复制

cp source.txt backup.txt
cp source.txt /tmp/
cp -r project/ project-copy/

当目标是已存在目录时,源文件会复制到该目录下:

cp source.txt /tmp/

等价于目标路径:

/tmp/source.txt

若目标文件已经存在,普通 cp 通常会截断并覆盖它,但是否真正覆盖还会受到权限、别名、交互选项和目标类型影响。

常用选项:

  • -i:覆盖前询问。
  • -n:不覆盖已有目标;具体行为可能受实现影响。
  • -v:显示操作过程。
  • -r-R:递归复制目录。
  • -a:归档复制,GNU coreutils 中大致相当于 -dR --preserve=all
  • -p:尽可能保留模式、所有者、时间等属性。
  • -P:不跟随源符号链接,复制链接本身。
  • -L:跟随源符号链接。
  • -H:跟随命令行中给出的符号链接。
  • --reflink:请求使用写时复制或块克隆,是否支持由文件系统和实现决定。

2. 普通文件复制的机制

执行:

cp source.txt destination.txt

通常可以抽象为:

  1. 解析源路径并打开源文件读取。
  2. 创建或打开目标文件。
  3. 读取源数据并写入目标。
  4. 根据选项设置权限、时间、所有者、扩展属性等。
  5. 关闭文件描述符。
  6. 若发生错误,报告失败;目标可能已经部分写入。

这意味着普通 cp 不是事务操作。复制大文件时,如果磁盘满、进程被终止或发生 I/O 错误,目标可能存在但内容不完整。

生产中经常使用临时文件加重命名完成“同一文件系统内的替换”:

tmp=$(mktemp --tmpdir=. .config.XXXXXX) || exit 1

if cp -- .config "$tmp" &&
   chmod --reference=.config "$tmp" &&
   mv -f -- "$tmp" .config
then
    :
else
    rc=$?
    rm -f -- "$tmp"
    exit "$rc"
fi

这里的关键不是 cp 本身变成了原子操作,而是:

  • 先把完整内容写入临时名称;
  • 成功后再通过同一文件系统内的 rename 替换正式名称;
  • 读者通常不会看到“写了一半的正式文件”。

但还需要区分可见性原子性崩溃持久性rename 成功返回后,其他进程通常不会看到目标名称处于中间状态;如果要求断电后仍然保证目录项和数据持久化,还需要正确使用 fsync 文件和目录,shell 命令本身未必完整处理这些要求。

3. 目录复制和 -a

cp -a -- source-dir destination-dir

-a 常用于保留:

  • 目录层次;
  • 符号链接本身;
  • 权限模式;
  • 时间;
  • 所有者和组;
  • 硬链接关系;
  • 某些扩展属性和 ACL。

但“尽可能保留”不等于无条件保证:

  • 非特权用户通常无法把文件所有者设置成任意 UID。
  • 目标文件系统可能不支持源文件的 ACL、xattr、能力位或特殊属性。
  • 不同文件系统的时间精度和语义可能不同。
  • 某些属性复制失败会导致命令报错或只完成部分复制,需检查退出状态和错误输出。

4. 复制符号链接的反例

准备:

printf 'real\n' > real.txt
ln -s real.txt link.txt
mkdir out

执行:

cp link.txt out/followed
cp -P link.txt out/copied-link

通常结果是:

  • out/followed:复制链接目标的内容,成为普通文件。
  • out/copied-link:复制符号链接本身,仍指向 real.txt

验证:

file out/followed out/copied-link
readlink out/copied-link

若使用 cp -a,通常会保留符号链接。递归复制目录时是否跟随目录内符号链接,必须明确使用的选项和实现;盲目使用 -L 可能把链接指向的整个目录树复制进去,甚至造成循环或越界复制。

5. 目标路径的歧义

cp a.txt existing-dir/

目标是目录,因此生成 existing-dir/a.txt

如果需要把一个目录复制到另一个目录,结果取决于目标目录是否已经存在:

cp -r src dst
  • dst 不存在:通常生成 dst,内容来自 src
  • dst 已存在:通常生成 dst/src

部署脚本应先用 test -dtest -e 等检查状态,不能只凭视觉猜测命令最终写入哪里。


六、mv:重命名、移动,以及跨文件系统复制

1. 同一文件系统内的移动

mv old-name new-name
mv report.txt archive/

当源和目标位于同一文件系统,mv 通常可以调用 rename 系统调用。此时移动目录项即可,文件数据块不需要整体复制:

old-name ──> inode 123

变为:

new-name ──> inode 123

inode 通常不变,文件内容也不变。若目标名称已经存在,重命名还可能替换目标目录项;在同一文件系统中,这种名称替换对观察者通常具有原子性。

常用选项:

  • -i:覆盖前询问。
  • -f:强制覆盖,不询问;仍受权限和文件系统限制。
  • -n:不覆盖已有目标,具体语义依实现。
  • -T:把目标视为普通路径,而不是目录;GNU 扩展。
  • -v:显示过程。

2. 跨文件系统时不再只是改名

如果源和目标不在同一文件系统,rename 通常返回 EXDEV。例如:

mv large.iso /mnt/other-disk/

GNU/Linux 上的 mv 通常会退化为:

  1. 把源复制到目标文件系统;
  2. 尝试保留属性;
  3. 复制成功后删除源。

这时它具有 cp 的风险:

  • 可能耗时很长;
  • 目标可能部分复制;
  • 属性可能无法完整保留;
  • 复制成功前源和目标可能同时存在;
  • 删除源失败时会留下两份文件。

因此,“移动成功后源文件消失”不是跨文件系统过程中的中间保证。必须检查退出状态,并在大文件、生产数据和远程挂载场景中设计恢复策略。

3. mv 与符号链接

mv link.txt new-link.txt

通常移动的是符号链接目录项本身,不会把目标文件复制或重命名。

mv target-dir another-parent/

则移动目标目录本身。若路径中包含符号链接,最终行为依赖路径解析和目标是否为目录,不能只依据字符串判断。

4. 不能把 mv 当作跨系统事务

同一文件系统内:

mv new-config config

适合替换配置文件名称,但仍需注意:

  • 目标所在目录必须允许重命名;
  • 目标可能被其他进程同时修改;
  • 进程已经打开旧 inode 时,重命名不会改变其打开文件描述符;
  • 崩溃持久化仍需要文件系统同步策略。

如果服务进程已经打开 config,你用 mv 替换了路径,服务可能继续读取旧 inode;服务重新打开路径后才可能看到新文件。这正是“路径名”和“打开实例”不同的结果。


七、rm:删除目录项,而不是直接擦除所有进程看到的内容

1. 普通文件删除

rm report.txt

对普通文件,rm 通常调用 unlink。它删除的是父目录中的名称到 inode 的映射:

目录项 "report.txt" ──X──> inode 123

如果 inode 仍有其他硬链接,内容仍然可通过其他名称访问。如果某个进程已经打开该文件,即使名称被删除,进程仍可以通过文件描述符继续读写它。

可以用以下实验观察:

终端 A:

exec 3>held.log
printf 'before\n' >&3
rm held.log

终端 B:

ls -l held.log

名称已经不存在,但终端 A 仍可写入已打开的文件描述符:

printf 'after\n' >&3

空间通常要等文件描述符关闭后才回收。生产诊断中,常用:

lsof +L1

查找“已删除但仍被进程打开”的文件。lsof 不属于本文标题中的核心命令,但它是排查磁盘空间未释放的重要工具;某些最小系统可能未安装。

2. 删除目录使用 rmdirrm -r

rmdir empty-dir
rm -r project/

rmdir 只删除空目录。如果目录包含目录项,它会失败。

rm -r 会递归遍历并删除目录树。rm -rf 中:

  • -r:递归。
  • -f:忽略不存在的文件,尽量不询问。

rm -rf 不会绕过内核权限,也不会删除挂载点本身,但误选路径的后果非常严重。GNU rm--one-file-system 可以在递归时不跨越其他文件系统,但该选项属于实现能力,使用前应确认目标发行版的 rm 支持。

3. rm 不会默认跟随命令行符号链接进入目标目录

ln -s /important-data shortcut
rm shortcut

这里删除的是 shortcut 这个符号链接,不是 /important-data 目录树。

但这不意味着递归删除永远没有符号链接风险。风险可能来自:

  • 目录遍历期间目录项被其他进程替换;
  • 工具先检查路径,再执行操作,形成 TOCTOU 竞态;
  • 脚本把用户输入拼接成路径;
  • 使用了会跟随链接的其他命令或选项;
  • 通过 findxargs 等组合命令时边界处理不严谨。

4. 文件名以 - 开头

touch -- -danger.txt
rm -- -danger.txt

-- 表示选项结束,后面的字符串按文件名解释。更安全的习惯是:

rm -- "$path"
cp -- "$src" "$dst"
mv -- "$src" "$dst"

并且对 shell 变量使用双引号,避免空格、通配符和换行触发额外解析。

5. 不要把通配符当作安全过滤器

rm *.log

shell 会先展开 *.log,然后才启动 rm。如果当前目录没有匹配项,具体 shell 可能把 *.log 原样传给 rm,也可能展开为空;这不是 rm 自己决定的。

危险示例:

rm -rf "$BACKUP_DIR"/*

即使变量为空,某些展开结果也可能不符合预期;如果变量值错误地指向生产目录,后果就是递归删除生产数据。删除前应验证:

case "$BACKUP_DIR" in
    /srv/backups/*) ;;
    *) printf 'refuse unsafe path: %s\n' "$BACKUP_DIR" >&2; exit 1 ;;
esac

test -d "$BACKUP_DIR" || exit 1
rm -rf -- "$BACKUP_DIR"/*

这仍不是完整的并发安全方案,但至少把路径约束、存在性检查和选项边界显式化。


八、链接、删除和替换的统一示例

tmpdir=$(mktemp -d)
trap 'rm -rf -- "$tmpdir"' EXIT

cd -- "$tmpdir" || exit 1

printf 'v1\n' > data.txt
ln data.txt hard.txt
ln -s data.txt sym.txt

ls -li -- data.txt hard.txt sym.txt
stat -- data.txt hard.txt sym.txt
file -- data.txt hard.txt sym.txt

观察结果时应区分:

  1. data.txthard.txt inode 相同,链接计数为 2
  2. sym.txt inode 不同,类型是 symbolic link。
  3. stat sym.txt 通常查看链接自身;stat -L sym.txt 查看 data.txt
  4. file sym.txt 通常报告它是指向 data.txt 的符号链接;file -L sym.txt 则识别目标内容。

继续执行:

rm -- data.txt
cat -- hard.txt
cat -- sym.txt

预期:

  • hard.txt 仍能读取,因为它仍指向同一个 inode。
  • sym.txt 变成悬空链接,因为它保存的目标路径 data.txt 已不存在。

再执行:

mv -- hard.txt renamed.txt
cat -- renamed.txt

inode 不会因重命名而改变。最后:

rm -- renamed.txt sym.txt

此时普通文件 inode 的硬链接计数变为 0,若没有打开实例,文件空间可以回收;符号链接 inode 本身也被删除。


九、面向脚本的安全组合:find、空字符和参数边界

虽然 findxargs 是另一组命令主题,但文件操作脚本经常必须使用它们。

1. 不安全的空格处理

find . -type f -print | xargs rm

文件名:

notes 2025.txt

会被拆成两个参数。文件名中的换行还会破坏基于行的协议。

安全版本:

find . -type f -print0 | xargs -0 -- rm -f --

数据流是:

find 以 NUL 分隔路径
        │
        ▼
xargs -0 按 NUL 恢复参数
        │
        ▼
rm -- 每个参数按文件名处理

NUL 是 Unix 文件名中不能出现的字节,因此适合作为路径记录分隔符。这里仍需注意:

  • xargs 可能分批启动多个 rm
  • 删除过程中目录内容可能变化;
  • 如果没有匹配项,xargs 的行为依版本和选项不同,可使用 GNU -r 避免空调用;
  • 对目录树删除,find ... -delete 或基于文件描述符的专用程序可能比 find | xargs 更能减少竞态。

2. find -exec ... {} +

find /var/tmp -type f -name '*.cache' -exec rm -f -- {} +

{} + 会把多个匹配路径批量作为参数传给一次命令调用,避免逐个启动进程。它比解析 ls 输出可靠,但仍应评估路径树权限、并发修改和删除范围。

3. TOCTOU:检查和使用之间的竞态

以下逻辑存在一般性的竞态:

先检查 path 是普通文件
随后对 path 执行删除或写入

如果攻击者能在两步之间替换目录项,第二步操作的对象可能已经不是第一步检查的对象。stattest -ffile 的结果都不是未来操作的永久保证。

更强的程序设计会:

  • 使用目录文件描述符;
  • 使用 openatfstatat 等相对目录描述符的接口;
  • 配合 O_NOFOLLOWO_EXCL 等选项;
  • 在 Linux 特定场景使用 openat2 的解析约束;
  • 让操作尽量由一个内核调用完成,而不是“检查后再操作”。

这些是系统调用层的能力,普通 shell 命令无法自动把任意脚本变成无竞态程序。


十、权限、所有权、ACL 和特殊文件的实际影响

1. cpmv 不会自动解决权限问题

例如:

cp -- source.txt /root/

即使源文件可读,目标目录不可写也会失败:

cp: cannot create regular file '/root/source.txt': Permission denied

mv 同样需要父目录的写入和执行权限。删除文件时,文件本身是 0444 并不必然阻止删除;父目录权限才是关键条件。

2. ACL 和扩展属性

传统权限位只是权限模型的一部分。检查 ACL:

getfacl -- file.txt

检查扩展属性:

getfattr -d -- file.txt

某些文件还可能带有 Linux capabilities:

getcap -- file

ls -l 的权限字符串末尾可能出现 ACL 或安全属性提示,例如 +。复制和备份时,如果应用依赖 ACL、SELinux 标签、xattr 或 capabilities,必须验证目标是否实际保留,而不能只看文件名、大小和基本权限。

3. 特殊文件不能按普通文件理解

filels -l 可能显示:

  • 设备文件;
  • FIFO;
  • Unix socket;
  • procfs、sysfs、tmpfs 中的特殊对象。

对这些对象使用 cpcatrm,行为可能与普通文件完全不同。例如读取设备可能阻塞或产生设备数据,删除挂载的伪文件系统对象通常没有普通文件的语义。递归备份系统目录时必须明确排除或单独处理这些文件系统。


十一、生产操作的验证、失败路径和恢复

1. 执行命令后检查退出状态

if cp -- source.txt backup.txt; then
    printf 'copy succeeded\n'
else
    rc=$?
    printf 'copy failed, rc=%d\n' "$rc" >&2
    exit "$rc"
fi

命令输出为空不代表成功;成功或失败应以退出状态为准。脚本使用 set -e 也不能代替完整错误处理,因为条件命令、管道、子 shell 和清理逻辑有复杂例外。重要脚本应显式检查关键步骤。

2. 复制后验证什么

最低限度可以验证:

test -f backup.txt &&
cmp -- source.txt backup.txt

cmp 比较字节内容。若还要求属性:

stat -- source.txt backup.txt

需要注意,cmp 只证明两份内容相同,不证明:

  • 所有者相同;
  • ACL 相同;
  • xattr 相同;
  • 已经持久化到磁盘;
  • 复制过程中没有被源文件并发修改。

如果源文件在复制期间被其他进程写入,目标可能是一个时间点不明确的混合版本。要获得一致快照,需要应用层停写、文件系统快照或其他协调机制。

3. 删除后的恢复

rm 通常不会提供回收站。删除后能否恢复取决于:

  • 是否仍有进程打开文件;
  • 文件系统是否有快照;
  • 是否有备份;
  • 数据块是否已经被重用;
  • 文件系统和恢复工具的特性。

因此,rm 前的确认、备份和范围验证比“删除后恢复”可靠得多。对数据库、对象存储、日志目录和用户上传目录,删除策略应与应用自身的生命周期和备份策略协调,而不是直接把 rm -rf 当作清理机制。


十二、常见误解与正确判断

误解一:文件名后缀决定类型

错误。后缀只是命名约定,file 通过内容和 magic database 做初步识别。

误解二:mv 总是瞬间完成

只在同一文件系统内的普通重命名通常接近瞬时。跨文件系统时,mv 可能实际执行复制和删除。

误解三:删除文件后磁盘空间一定立即释放

错误。其他硬链接或打开文件描述符仍然存在时,空间可能不会释放。

误解四:ls -l 中的大小就是磁盘占用

错误。稀疏文件、压缩文件系统、重复数据删除和块分配都会使逻辑大小与实际占用不同。分别使用 statdu 观察两种概念。

误解五:看到 file 说是图片就可以安全处理

错误。file 是启发式识别工具,不是完整验证器。真正处理文件时仍要使用受限解析器、大小限制、资源限制和内容校验。

误解六:先 stat 检查再 rm 就安全

错误。检查和使用之间可能发生竞态。对不可信目录或攻击者可写目录,应尽量使用内核提供的原子接口和受限路径解析机制。


十三、一个可重复的安全实验脚本

以下脚本只在临时目录中操作,演示观察、复制、重命名、链接和删除:

#!/usr/bin/env bash
set -u

workdir=$(mktemp -d) || exit 1
cleanup() {
    rm -rf -- "$workdir"
}
trap cleanup EXIT

cd -- "$workdir" || exit 1

printf 'version 1\n' > original.txt
ln -- original.txt hard.txt
ln -s -- original.txt symlink.txt

printf '%s\n' '== ls ==' 
ls -lai -- .

printf '%s\n' '== stat ==' 
stat -- original.txt hard.txt symlink.txt
stat -L -- symlink.txt

printf '%s\n' '== file =='
file -- original.txt hard.txt symlink.txt
file -L -- symlink.txt

mkdir -- backup
cp -a -- original.txt hard.txt symlink.txt backup/

printf '%s\n' '== backup contents =='
find backup -maxdepth 1 -printf '%y %i %p -> %l\n'

mv -- original.txt renamed.txt
printf '%s\n' '== after rename =='
ls -lai -- .

rm -- renamed.txt
printf '%s\n' '== hard link remains =='
cat -- hard.txt

rm -- hard.txt symlink.txt
printf '%s\n' '== remaining tree =='
find . -maxdepth 2 -print

关键验证点:

  1. original.txthard.txt 初始 inode 相同。
  2. symlink.txt 的 inode 与它们不同。
  3. cp -a 通常保留符号链接,而不是复制其目标内容。
  4. mv 重命名后,inode 通常仍保持不变。
  5. 删除 renamed.txt 后,hard.txt 仍能读取数据。
  6. 删除最后一个硬链接后,若没有打开描述符,普通文件内容才具备回收条件。
  7. trap 使用带引号的变量和 --,避免临时目录路径中的特殊字符影响清理命令。

结语:把文件操作看成“名称、inode 和状态变化”

正确理解 Linux 文件命令,需要把命令行为还原成底层状态变化:

  • ls 主要观察目录项和可见元数据;
  • stat 观察 inode、时间、链接计数、权限和分配信息;
  • file 根据内容进行类型推断,不证明安全性;
  • cp 创建新的文件对象并复制内容及选定属性;
  • mv 在同一文件系统内通常只是重命名目录项,跨文件系统时可能退化为复制加删除;
  • rm 通常删除名称到 inode 的链接,未必立即销毁数据;
  • 硬链接共享 inode,符号链接保存路径字符串;
  • shell 展开、权限、文件系统边界和并发替换会改变命令的实际风险。

在非关键环境中,这些命令可以直接使用;在生产环境中,则应同时验证目标路径、文件类型、权限、退出状态、内容一致性和恢复方案。最重要的判断不是“命令看起来是否简单”,而是“它究竟改变了哪个目录项、哪个 inode,以及失败时系统会停留在哪个状态”。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。