Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

2.5 文件属性、扩展属性与 ACL

本章回答

stat 的哪些字段会随操作变化?为什么 st_size 与 st_blocks 能差一个数量级?扩展属性(xattr)和 ACL 到底存在哪?chmod 为什么会让 ACL 的 mask 变?

读完你会

用 lstat/fstatat 正确区分符号链接;解释三个时间戳与稀疏文件;用 xattr 存元数据并处理 ERANGE;读写 ACL 并说清 mask 与 chmod 的交互。

2.2 把文件当成「字节流」,2.3 加上了映射与锁。但文件还有另一半:它本身是什么——类型、权限、属主、大小、时间、扩展元数据。ls -l 显示的每一列,背后都是 stat 结构的一个字段;getfacl 显示的每条规则,落盘时是一个名为 system.posix_acl_access 的扩展属性。

这一章的主线是「元数据也是数据」:它同样占空间、同样有并发问题、同样有缓存与持久化语义。理解这一点,你才能解释为什么 du 和 ls -l 对同一个文件给出不同大小,为什么拷文件会丢 ACL,为什么 chmod 640 会悄悄改掉 ACL 的 mask。

2.5.1 stat 家族与 struct stat

#include <sys/stat.h>
int stat(const char *path, struct stat *buf);        /* 跟随符号链接 */
int lstat(const char *path, struct stat *buf);       /* 不跟随,看链接本身 */
int fstat(int fd, struct stat *buf);                 /* 已打开的 fd */
int fstatat(int dirfd, const char *path, struct stat *buf, int flags);

成功返回 0;失败返回 -1,errno 常见:

  • ENOENT:路径不存在(或路径中间某级不存在);
  • EACCES:路径中某一级目录没有搜索权限;
  • ELOOP:符号链接层数超过限制(通常 40);
  • ENAMETOOLONG:路径或某一段太长;
  • EOVERFLOW:文件大小/ino 超出 stat 结构能表示的范围(32 位程序读大文件)。

fstatat 的 flags 里最有用的是 AT_SYMLINK_NOFOLLOW(等价于 lstat)和 AT_EMPTY_PATH(对 dirfd 本身取属性,需要 CAP_DAC_READ_SEARCH)。fstatat(dirfd, "name", ..., 0) 与 stat 的区别是:路径相对 dirfd 解析,不受进程 cwd 变化影响。

字段逐个看

字段含义什么时候变
st_dev所在文件系统的设备号挂载/移动文件跨设备
st_inoinode 号文件创建时分配,rename 不变
st_mode类型 + 权限 + 特殊位chmod、chown、创建
st_nlink硬链接数link/unlink
st_uid/st_gid属主/属组chown
st_rdev设备文件的设备号仅对块/字符设备有意义
st_size文件逻辑大小(字节)write、truncate、ftruncate
st_blksize最优 I/O 块大小文件系统决定,通常 4096
st_blocks已分配的 512 字节块数实际占用的磁盘空间
st_atime最后访问时间读(受 relatime 影响)
st_mtime最后修改时间写内容
st_ctimeinode 状态改变时间chmod/chown/link/rename

st_ctime 不是「创建时间」。Linux 上 struct stat 没有创建时间;要拿 btime 用 statx(2.5.7)。ctime 会随元数据修改而更新,mtime 只在内容变时更新。

关于 `stat` 的四个误解
  1. 「stat 和 lstat 一样」——对符号链接不同:stat 跟随到目标,lstat 返回链接本身(S_IFLNK,大小是目标路径长度)。
  2. 「st_size 就是占用的磁盘空间」——不是。稀疏文件、压缩、块对齐都会让 st_blocks * 512 与 st_size 不同。
  3. 「st_ctime 是创建时间」——是 inode 状态变更时间。chmod 会改它,创建后改内容不会改它(改的是 mtime)。
  4. 「stat 返回的字段是一次快照」——是快照,但拿到后文件可能立刻被改;不要用 stat 的结果做安全判断(TOCTOU)。

2.5.2 文件类型、权限位与检查算法

st_mode 的高 4 位是类型,低 12 位是权限与特殊位。判断类型必须用宏,不要手写掩码:

S_ISREG(m)   /* 普通文件 */
S_ISDIR(m)   /* 目录 */
S_ISLNK(m)   /* 符号链接(仅 lstat 能拿到) */
S_ISCHR(m)   /* 字符设备 */
S_ISBLK(m)   /* 块设备 */
S_ISFIFO(m)  /* FIFO/管道 */
S_ISSOCK(m)  /* socket */

权限位:

位八进制对文件对目录
S_IRUSR0400读内容列出目录项
S_IWUSR0200写内容创建/删除目录项
S_IXUSR0100执行进入/查找
S_ISUID04000执行时 euid=文件属主(忽略)
S_ISGID02000执行时 egid=文件属组新文件继承目录属组
S_ISVTX01000(忽略)sticky:只有属主/目录属主/root 能删

权限检查算法

内核检查 open/exec/mkdir 时按这个顺序:

  1. euid == 0:读/写直接放行;执行要求至少一个 x 位(或 CAP_DAC_OVERRIDE/CAP_DAC_READ_SEARCH)。
  2. euid == st_uid:用 owner 三位。
  3. egid == st_gid 或 st_gid 在辅助组里:用 group 三位。
  4. 否则用 other 三位。

目录的 x 位是「能否穿过」,r 是「能否列出名字」,w 是「能否增删目录项」。只给 r 不给 x,ls 能看到名字但 stat 失败;只给 x 不给 r,知道路径就能访问,但列不出目录。

umask 是创建时的掩码:open(..., 0666) 的实际权限是 0666 & ~umask。umask(2) 返回旧值,永不失败:

mode_t old = umask(027);       /* 新文件:组只读、其他无权限 */
int fd = open(path, O_CREAT | O_WRONLY, 0666);
umask(old);                    /* 恢复;umask 是进程级状态,多线程共享 */

umask 是进程级(不是线程级)状态,工作线程里改它会影响其他线程;在启动时设一次,或创建后显式 fchmod。

access() 的竞态

if (access(path, W_OK) == 0) {          /* 用真实 UID 检查 */
    int fd = open(path, O_WRONLY);      /* 用有效 UID 打开 */
}

两个问题:

  1. 身份不一致:access 用 real UID/GID,open 用 effective。setuid 程序里 access 说「不可写」,open 却可能成功,反之亦然。要按 effective 检查,用 faccessat(AT_FDCWD, path, mode, AT_EACCESS)(或 faccessat2)。
  2. TOCTOU:检查与打开之间,文件可能被替换或权限被改。安全检查不要用 access 做门禁;直接 open,失败就处理 EACCES。
为什么权限检查不放在 `stat` 里

stat 只返回「权限位是什么」,不告诉你「当前进程能不能」。能不能取决于 euid/egid/辅助组/capability,这些是进程的属性,不是文件的。把两者混在一起,就会出现「我 stat 看到 0644,为什么还是打不开」——因为目录没有 x,或者文件系统只读,或者 LSM 拦了。

完整示例:打印 stat 的全部关键字段

/* showstat.c — 用 lstat 看文件本身,不跟随符号链接 */
#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <time.h>

static char type_char(mode_t m)
{
    if (S_ISREG(m))  return '-';
    if (S_ISDIR(m))  return 'd';
    if (S_ISLNK(m))  return 'l';
    if (S_ISCHR(m))  return 'c';
    if (S_ISBLK(m))  return 'b';
    if (S_ISFIFO(m)) return 'p';
    if (S_ISSOCK(m)) return 's';
    return '?';
}

static void show_time(const char *label, time_t t)
{
    struct tm tm;
    char buf[32];

    if (!localtime_r(&t, &tm)) return;
    if (strftime(buf, sizeof buf, "%Y-%m-%d %H:%M:%S", &tm) == 0) return;
    printf("  %s=%s", label, buf);
}

int main(int argc, char *argv[])
{
    if (argc != 2) { fprintf(stderr, "usage: %s PATH\n", argv[0]); return 1; }

    struct stat st;
    if (lstat(argv[1], &st) == -1) { perror("lstat"); return 1; }

    printf("%s\n", argv[1]);
    printf("  type=%c mode=%04o nlink=%lu uid=%u gid=%u\n",
           type_char(st.st_mode), (unsigned)st.st_mode & 07777,
           (unsigned long)st.st_nlink, (unsigned)st.st_uid, (unsigned)st.st_gid);
    printf("  size=%lld blocks=%lld blksize=%ld ino=%llu\n",
           (long long)st.st_size, (long long)st.st_blocks,
           (long)st.st_blksize, (unsigned long long)st.st_ino);
    show_time("atime", st.st_atime);
    show_time("mtime", st.st_mtime);
    show_time("ctime", st.st_ctime);
    putchar('\n');
    return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 showstat.c -o showstat
./showstat /etc/hosts
./showstat /dev/null
ln -s /etc/hosts /tmp/hosts.link && ./showstat /tmp/hosts.link   # type=l, size=10

最后一行是关键:lstat 对符号链接返回 type=l,st_size 是目标路径 "/etc/hosts" 的长度(10),不是目标文件的大小。

2.5.3 st_size、st_blocks 与三个时间

稀疏文件:逻辑大小 ≠ 占用空间

truncate -s 1G sparse.img
ls -lh sparse.img      # -rw-r--r-- 1 u u 1.0G ...
du -h sparse.img       # 0
stat -c 'size=%s blocks=%b blksize=%B' sparse.img
# size=1073741824 blocks=0 blksize=512

st_size 是 1 GiB,st_blocks 是 0:文件有 1 GiB 的逻辑长度,但没有分配任何数据块。读这些空洞返回 0,不触发磁盘 I/O。fallocate 才会真正分配(见 2.3)。

st_blocks 的单位固定是 512 字节,与 st_blksize 无关。占用字节数 = st_blocks * 512。

三个时间

时间更新时机能否设置
atime读文件(默认 relatime:atime 早于 mtime 或超过 24h 才更新)utimensat
mtime写内容、truncateutimensat
ctimeinode 变更:chmod/chown/link/rename不能
#include <sys/stat.h>
int utimensat(int dirfd, const char *path, const struct timespec times[2], int flags);
int futimens(int fd, const struct timespec times[2]);

times[0] 是 atime,times[1] 是 mtime。两个特殊值:UTIME_NOW(现在)、UTIME_OMIT(不改)。flags 可带 AT_SYMLINK_NOFOLLOW。返回 0/-1;errno:EACCES(无权)、EPERM(非属主且无 CAP_FOWNER)、EROFS、EINVAL。

struct timespec ts[2] = {
    { .tv_nsec = UTIME_OMIT },              /* 不动 atime */
    { .tv_sec = 1700000000, .tv_nsec = 0 }, /* 固定 mtime */
};
if (utimensat(AT_FDCWD, path, ts, 0) == -1)
    errExit("utimensat");

2.5.4 chmod、chown 与特殊位

int chmod(const char *path, mode_t mode);       /* 跟随符号链接 */
int fchmod(int fd, mode_t mode);
int fchmodat(int dirfd, const char *path, mode_t mode, int flags);
int chown(const char *path, uid_t owner, gid_t group);
int fchown(int fd, uid_t owner, gid_t group);
int lchown(const char *path, uid_t owner, gid_t group);  /* 不跟随 */

返回值都是 0/-1。chmod 的 errno:EPERM(不是属主且无 CAP_FOWNER)、EROFS、ENOENT。chown 额外有 EINVAL(uid/gid 非法)、EPERM;chown 成功会清掉 setuid/setgid 位(安全设计;Linux 2.2.13 起 root 也一样)。

chmod 只传低 12 位;文件类型位会被忽略。fchmodat 的 flags 在 glibc 里只支持 0 或 AT_SYMLINK_NOFOLLOW(后者在 Linux 上对符号链接返回 ENOTSUP)。

权限与 ACL 的四个坑
  1. 「chmod 777 能解决所有权限问题」——目录还需要 x 才能进入;文件系统可能只读;SELinux/AppArmor 可能仍然拒绝。
  2. 「chown 只改属主」——它还会清 setuid/setgid,可能让原本能提权的程序失效。
  3. 「access() 判断的是有效 UID」——它用真实 UID;setuid 程序里必须用 faccessat(..., AT_EACCESS)。
  4. 「umask 只影响 open」——mkdir、mkfifo、mknod 都受它影响;它还是进程级共享状态。

2.5.5 扩展属性:xattr

xattr 是「挂在 inode 上的键值对」,名字是 namespace.name 形式。它不是文件内容,但随 inode 持久化,cp -a/tar --xattrs 才会带走。

命名空间谁能写典型用途
user.*文件属主(受文件权限约束)应用自定义元数据
trusted.*CAP_SYS_ADMIN系统级标记
security.*LSM/特权进程SELinux label、security.capability
system.*内核POSIX ACL、system.posix_acl_access

限制(ext4/XFS):名字 ≤ 255 字节,值 ≤ 64 KiB,所有 xattr 总和受 inode 空间限制;符号链接和特殊文件通常不允许 user.*。

#include <sys/xattr.h>
int setxattr(const char *path, const char *name, const void *value, size_t size, int flags);
ssize_t getxattr(const char *path, const char *name, void *value, size_t size);
ssize_t listxattr(const char *path, char *list, size_t size);
int removexattr(const char *path, const char *name);

返回语义:

  • setxattr/removexattr:0/-1;flags 可带 XATTR_CREATE(已存在则 EEXIST)或 XATTR_REPLACE(不存在则 ENODATA)。
  • getxattr:返回值是属性值长度;size 不够返回 -1、errno=ERANGE;属性不存在返回 -1、errno=ENODATA。
  • listxattr:返回名字列表的总字节数,名字之间以 \0 分隔;size 不够 ERANGE。

标准的两步查询(先问大小,再取):

ssize_t n = getxattr(path, name, NULL, 0);   /* 只查长度 */
if (n == -1) { /* ENODATA / ENOTSUP */ }
char *buf = malloc((size_t)n + 1);
n = getxattr(path, name, buf, (size_t)n);
buf[n] = '\0';

getxattr(path, name, NULL, 0) 对存在的属性返回长度;对不存在的属性返回 ENODATA。listxattr 在没有任何属性时返回 0,malloc(0) 可能返回 NULL,要单独处理。

完整示例:读写与枚举

/* xattr_demo.c — set/get/list/remove user.* 扩展属性 */
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/xattr.h>

static void die(const char *what)
{
    fprintf(stderr, "%s: %s\n", what, strerror(errno));
    exit(1);
}

int main(int argc, char *argv[])
{
    if (argc != 2) { fprintf(stderr, "usage: %s FILE\n", argv[0]); return 1; }
    const char *path = argv[1];
    const char *name = "user.release";
    const char *value = "2026.10";

    if (setxattr(path, name, value, strlen(value), 0) == -1)
        die("setxattr");

    ssize_t n = getxattr(path, name, NULL, 0);
    if (n == -1) die("getxattr size");

    char *buf = malloc((size_t)n + 1);
    if (!buf) die("malloc");
    n = getxattr(path, name, buf, (size_t)n);
    if (n == -1) die("getxattr");
    buf[n] = '\0';
    printf("%s = %s\n", name, buf);
    free(buf);

    ssize_t len = listxattr(path, NULL, 0);
    if (len == -1) die("listxattr size");
    if (len > 0) {
        char *list = malloc((size_t)len);
        if (!list) die("malloc");
        len = listxattr(path, list, (size_t)len);
        if (len == -1) die("listxattr");
        for (ssize_t i = 0; i < len; i += (ssize_t)strlen(list + i) + 1)
            printf("xattr: %s\n", list + i);
        free(list);
    }

    if (removexattr(path, name) == -1) die("removexattr");
    printf("removed %s\n", name);
    return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 xattr_demo.c -o xattr_demo
touch /tmp/x.txt && ./xattr_demo /tmp/x.txt
getfattr -d /tmp/x.txt 2>/dev/null || true

getfattr/setfattr 来自 attr 包;没有也能用程序验证。ENOTSUP 表示文件系统不支持 xattr(例如某些 tmpfs 挂载选项、VFAT)。

2.5.6 ACL:比 rwx 更细的权限

ACL 把「owner/group/other 三组」扩展成「若干条 ACE(access control entry)」:

user::rw-              # 属主
user:1001:r--          # 命名用户
group::r--             # 属组
group:dev:rw-          # 命名组
mask::rw-              # 掩码:限制上面所有命名用户/组 + group::
other::r--             # 其他人

关键规则:mask 是所有命名用户、命名组和 group:: 的有效权限上限。chmod g=... 改的不是 group::,而是 mask(当文件有 ACL 时),所以一次 chmod 可能让命名用户的权限「莫名其妙」变小。

setfacl -m u:alice:rw file
getfacl file
chmod 640 file          # 把 mask 设成 r--,alice 的有效权限也变成 r--
getfacl file            # mask::r--, user:alice:rw-(有效仍是 r--)

setfacl -n 可以不让 chmod 重算 mask,但默认行为就是上面这样。要恢复,setfacl -m m::rw file。

libacl API

#include <sys/acl.h>
acl_t acl_get_file(const char *path, acl_type_t type);
int acl_set_file(const char *path, acl_type_t type, acl_t acl);
acl_t acl_from_text(const char *text);
char *acl_to_text(acl_t acl, ssize_t *len);
int acl_create_entry(acl_t *acl, acl_entry_t *entry);
int acl_set_tag_type(acl_entry_t entry, acl_tag_t type);
int acl_set_qualifier(acl_entry_t entry, const void *qualifier);
int acl_get_permset(acl_entry_t entry, acl_permset_t *permset);
int acl_add_perm(acl_permset_t permset, acl_perm_t perm);
int acl_set_permset(acl_entry_t entry, acl_permset_t permset);
int acl_calc_mask(acl_t *acl);
void acl_free(void *obj);

type 是 ACL_TYPE_ACCESS 或 ACL_TYPE_DEFAULT(只能用于目录)。acl_get_file 失败返回 NULL,错误在 errno(ENOENT、ENOTSUP、EACCES)。acl_to_text 返回的字符串要用 acl_free 释放;acl_t 本身也要 acl_free。

acl_calc_mask() 必须在 acl_set_file 之前调用:它按当前 ACE 重算 mask,否则你可能写入一个 mask 与条目不一致的 ACL。

完整示例:给命名用户加读权限

/* acl_demo.c — 给指定用户加 ACL 读权限;编译需 -lacl */
#include <errno.h>
#include <pwd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/acl.h>

int main(int argc, char *argv[])
{
    if (argc != 3) { fprintf(stderr, "usage: %s FILE USER\n", argv[0]); return 1; }

    struct passwd *pw = getpwnam(argv[2]);
    if (!pw) { fprintf(stderr, "no such user: %s\n", argv[2]); return 1; }

    acl_t acl = acl_get_file(argv[1], ACL_TYPE_ACCESS);
    if (!acl) { perror("acl_get_file"); return 1; }

    acl_entry_t entry;
    if (acl_create_entry(&acl, &entry) == -1) { perror("acl_create_entry"); return 1; }
    if (acl_set_tag_type(entry, ACL_USER) == -1) { perror("acl_set_tag_type"); return 1; }

    uid_t uid = pw->pw_uid;
    if (acl_set_qualifier(entry, &uid) == -1) { perror("acl_set_qualifier"); return 1; }

    acl_permset_t perms;
    if (acl_get_permset(entry, &perms) == -1) { perror("acl_get_permset"); return 1; }
    if (acl_clear_perms(perms) == -1 ||
        acl_add_perm(perms, ACL_READ) == -1 ||
        acl_set_permset(entry, perms) == -1) {
        perror("set perms"); return 1;
    }

    if (acl_calc_mask(&acl) == -1) { perror("acl_calc_mask"); return 1; }
    if (acl_set_file(argv[1], ACL_TYPE_ACCESS, acl) == -1) { perror("acl_set_file"); return 1; }

    char *text = acl_to_text(acl, NULL);
    if (!text) { perror("acl_to_text"); return 1; }
    printf("%s", text);
    acl_free(text);
    acl_free(acl);
    return 0;
}
# Ubuntu/Debian:sudo apt install libacl1-dev attr
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 acl_demo.c -o acl_demo -lacl
touch /tmp/a.txt && chmod 640 /tmp/a.txt
./acl_demo /tmp/a.txt "$USER"
getfacl /tmp/a.txt

acl_set_file 需要你是文件属主(或有 CAP_FOWNER),否则 EPERM。在 tmpfs 上通常可用;FAT/exFAT 不支持 ACL,会返回 ENOTSUP。

默认 ACL 与继承

目录可以设置默认 ACL(ACL_TYPE_DEFAULT):此后在该目录里新建的文件/子目录会继承它。默认 ACL 只影响新建对象,不改变目录本身的访问权限。

setfacl -d -m u:alice:rwx /srv/shared     # 默认 ACL
setfacl -m u:alice:rwx /srv/shared        # 访问 ACL(目录本身)
getfacl /srv/shared
touch /srv/shared/new && getfacl /srv/shared/new   # 继承

新建文件时,默认 ACL 的 mask 会与 open 的 mode、umask 共同作用:文件不会因为 ACL 就获得 x 位(除非目录的默认 ACL 给了 x 且创建者请求了可执行)。

两条命令的常用选项比背 API 更常遇到:

命令选项作用
getfacl-a / -d只看访问 ACL / 只看默认 ACL(默认两者都显示)
getfacl-R、-n、-t递归;显示数字 UID/GID;表格化输出
setfacl-m、-x修改/增加条目;删除指定条目(如 u:alice)
setfacl-b、-k删除所有扩展 ACL(保留 base);删除默认 ACL
setfacl-d、-R、--set操作默认 ACL;递归;整体替换

getfacl 的输出可以直接喂回 setfacl:getfacl file1 | setfacl --set-file=- file2 复制 ACL;getfacl -R -p /srv > acl.bak 之后用 setfacl --restore=acl.bak 恢复整棵子树(-p 保留绝对路径,避免恢复时相对当前目录)。setfacl -x u:alice file 只删命名用户条目,不动 base 三项;-b 才是清空扩展条目。注意 -n 在两个命令里含义不同:getfacl -n 显示数字 ID,setfacl -n 是不重算 mask。

2.5.7 statx:更新的字段

statx(2) 补上了 stat 缺的东西:创建时间、挂载 ID、直接 I/O 对齐要求、属性掩码。

struct statx stx;
if (statx(AT_FDCWD, path, AT_STATX_SYNC_AS_STAT,
          STATX_BASIC_STATS | STATX_BTIME, &stx) == -1)
    errExit("statx");

if (stx.stx_mask & STATX_BTIME)
    printf("birth=%lld\n", (long long)stx.stx_btime.tv_sec);
printf("ino=%llu mnt_id=%llu\n",
       (unsigned long long)stx.stx_ino, (unsigned long long)stx.stx_mnt_id);

stx_mask 告诉你哪些字段有效:文件系统不支持 btime 时 STATX_BTIME 位不会被置。AT_STATX_SYNC_AS_STAT 是默认缓存行为;AT_STATX_FORCE_SYNC 强制回源,AT_STATX_DONT_SYNC 只读缓存。旧内核没有 statx 时返回 ENOSYS,要回退到 stat。

statvfs:文件系统剩余空间与 inode

stat/statx 描述「一个文件」,statvfs(fd/path, &buf) 描述「这个文件所在的文件系统」:

#include <sys/statvfs.h>
struct statvfs vfs;
if (statvfs("/srv/data", &vfs) == 0) {
    unsigned long long total = (unsigned long long)vfs.f_blocks * vfs.f_frsize;
    unsigned long long free  = (unsigned long long)vfs.f_bavail * vfs.f_frsize;
    unsigned long long inodes = vfs.f_files;
    unsigned long long ifree  = vfs.f_favail;
    printf("total=%llu free=%llu inodes=%llu ifree=%llu\n", total, free, inodes, ifree);
}

关键字段:f_bsize(文件系统块大小)、f_frsize(片段大小,算空间用它)、f_blocks/f_bfree/f_bavail(总/空闲/非特权可用块)、f_files/f_ffree/f_favail(inode 总数/空闲/可用)、f_namemax(文件名最大长度)。

`df` 与 `statvfs` 的差异

df 默认显示 1K 块,statvfs 用 f_frsize;f_bavail 已扣除 root 保留块(通常 5%),f_bfree 没有。要判断「普通用户还能写多少」,用 f_bavail,不要用 f_bfree。

2.5.8 struct stat 的 ABI 与字段布局

这一节回答:struct stat 的字段在 32 位与 64 位、glibc 与 musl 之间为什么不能混用?st_dev/st_ino 凭什么唯一标识一个文件?

stat 的字段不是「一堆整数」,而是内核与用户态共享的 ABI。内核在 cp_new_stat()/cp_statx() 里按固定布局填充,glibc 的 stat 包装再把它转成用户态的 struct stat。字段宽度随 _FILE_OFFSET_BITS、架构、libc 变化:

字段类型说明
st_devdev_t文件所在设备;用 major()/minor() 拆
st_inoino_tinode 号;只在同一 st_dev 内唯一
st_modemode_t类型 + 权限 + 特殊位
st_nlinknlink_t硬链接数;已删除但被打开的文件可能为 0
st_uid/st_giduid_t/gid_t属主/属组
st_rdevdev_t设备文件的主/次设备号
st_sizeoff_t逻辑大小;符号链接是目标路径长度
st_blksizeblksize_t最优 I/O 块大小,不是文件系统块大小
st_blocksblkcnt_t已分配的 512 字节块数
st_atim/st_mtim/st_ctimstruct timespec访问/修改/状态变更时间(纳秒)

(st_dev, st_ino) 是文件在当前命名空间里的身份;跨挂载点/绑定挂载时,同一个 inode 可能有不同的 st_dev,而 statx 的 stx_mnt_id 才能区分「哪一次挂载」。判断「两个路径是不是同一个文件」用 stat 比较 (dev, ino),不要比较路径字符串。

32 位与大文件

32 位程序默认的 off_t 可能是 32 位;读大于 2 GiB 的文件时,stat 可能返回 EOVERFLOW。解决方式是编译时定义 _FILE_OFFSET_BITS=64,让 stat/fstat/lseek/mmap 的声明重定向到 64 位版本。这不仅是「宏」,还会改变 struct stat 的布局;混用不同宏编译的目标文件会破坏 ABI。

可以在编译期锁住假设:

_Static_assert(sizeof(off_t) == 8, "需要 64 位 off_t");
_Static_assert(sizeof(time_t) >= 8, "需要 64 位 time_t");

st_mode 的位布局

Linux 用高 4 位表示类型(S_IFMT 0xF000),低 12 位表示权限与特殊位。SUSv3 没有规定类型位的具体值,所以只用 S_IS*() 宏,不要写 (st.st_mode & 0170000) == 0100000。st_mode 的类型位与权限位是同一字段,chmod 只改低 12 位,类型位被忽略。

`struct stat` 的五个坑
  1. 「st_ino 全局唯一」——只在同一设备内唯一;不同文件系统的 inode 号可能重复。
  2. 「st_size 就是磁盘占用」——稀疏文件、压缩、块对齐都会让 st_blocks*512 不同。
  3. 「st_blksize 是文件系统块大小」——是「最优 I/O 大小」,ext4 常为 4096,但语义不同。
  4. 「st_nlink==0 表示文件不存在」——已删除但被进程打开的文件仍是 0,lsof +L1 能看到。
  5. 「32 位 stat 和 64 位 stat 可以混用」——结构体布局不同,跨库传递会读错字段。

2.5.9 statx 全字段与属性位

这一节回答:stat 缺了创建时间、挂载 ID、直接 I/O 对齐,statx 怎么补?stx_mask 为什么必须检查?

statx(Linux 4.11,glibc 2.28)用一个可扩展结构返回更多字段,并允许调用者只请求需要的部分:

int statx(int dirfd, const char *path, int flags,
          unsigned int mask, struct statx *buf);

flags:AT_SYMLINK_NOFOLLOW、AT_EMPTY_PATH、AT_NO_AUTOMOUNT、AT_STATX_SYNC_AS_STAT(默认)、AT_STATX_FORCE_SYNC、AT_STATX_DONT_SYNC。mask 是 STATX_* 请求位;内核在 stx_mask 里告诉哪些字段真的有效。

stx_mask 位字段说明
STATX_TYPEstx_mode类型
STATX_MODEstx_mode权限
STATX_NLINKstx_nlink链接数
STATX_UID/STATX_GIDstx_uid/stx_gid属主/属组
STATX_ATIME/MTIME/CTIMEstx_atime/…三个时间
STATX_INOstx_inoinode 号
STATX_SIZEstx_size逻辑大小
STATX_BLOCKSstx_blocks512 字节块数
STATX_BTIMEstx_btime创建时间(不一定支持)
STATX_MNT_IDstx_mnt_id挂载 ID(4.11+)
STATX_DIOALIGNstx_dio_mem_align/stx_dio_offset_align直接 I/O 对齐(6.1+)

STATX_BASIC_STATS 是常用基础字段的集合;STATX_ALL 是「全部」的旧别名(新字段增加后它不会自动包含,所以不要依赖)。

stx_attributes 是 inode 属性位,和 chattr 的 flags 对应:

位含义对应 chattr
STATX_ATTR_COMPRESSED压缩c
STATX_ATTR_IMMUTABLE不可变i
STATX_ATTR_APPEND只追加a
STATX_ATTR_NODUMP不备份d
STATX_ATTR_ENCRYPTED加密—
STATX_ATTR_VERITYfs-verity—
STATX_ATTR_DAXDAX—
STATX_ATTR_AUTOMOUNT自动挂载点—
STATX_ATTR_MOUNT_ROOT挂载根—

旧内核返回 ENOSYS;文件系统不支持 btime 时 STATX_BTIME 位不置,stx_btime 可能是 0。先看 stx_mask,再读字段,这是 statx 与 stat 最大的用法差异。

/* statx_full.c — 打印 statx 的 mask、btime、mnt_id 与属性位 */
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>

static void show_ts(const char *name, const struct statx_timestamp *ts)
{
    struct tm tm;
    char buf[64];

    if (!localtime_r(&(time_t){ ts->tv_sec }, &tm)) return;
    if (!strftime(buf, sizeof buf, "%Y-%m-%d %H:%M:%S", &tm)) return;
    printf("  %-6s %s.%09u\n", name, buf, ts->tv_nsec);
}

static void show_attrs(unsigned long long a)
{
    struct { unsigned long long bit; const char *name; } tab[] = {
        { STATX_ATTR_COMPRESSED, "compressed" },
        { STATX_ATTR_IMMUTABLE,  "immutable"  },
        { STATX_ATTR_APPEND,     "append"     },
        { STATX_ATTR_NODUMP,     "nodump"     },
        { STATX_ATTR_ENCRYPTED,  "encrypted"  },
        { STATX_ATTR_VERITY,     "verity"     },
        { STATX_ATTR_DAX,        "dax"        },
    };
    printf("  attrs  %#llx", a);
    for (size_t i = 0; i < sizeof tab / sizeof tab[0]; i++)
        if (a & tab[i].bit) printf(" %s", tab[i].name);
    putchar('\n');
}

int main(int argc, char *argv[])
{
    if (argc != 2) { fprintf(stderr, "usage: %s PATH\n", argv[0]); return 1; }

    struct statx stx;
    unsigned int mask = STATX_BASIC_STATS | STATX_BTIME;
#ifdef STATX_MNT_ID
    mask |= STATX_MNT_ID;
#endif
#ifdef STATX_DIOALIGN
    mask |= STATX_DIOALIGN;
#endif

    if (statx(AT_FDCWD, argv[1], AT_STATX_SYNC_AS_STAT, mask, &stx) == -1) {
        perror("statx");
        return 1;
    }

    printf("%s\n", argv[1]);
    printf("  mask   %#x\n", stx.stx_mask);
    printf("  ino=%llu size=%llu blocks=%llu mode=%#o nlink=%u\n",
           (unsigned long long)stx.stx_ino,
           (unsigned long long)stx.stx_size,
           (unsigned long long)stx.stx_blocks,
           stx.stx_mode, stx.stx_nlink);
    printf("  uid=%u gid=%u blksize=%u\n", stx.stx_uid, stx.stx_gid, stx.stx_blksize);

    if (stx.stx_mask & STATX_ATIME) show_ts("atime", &stx.stx_atime);
    if (stx.stx_mask & STATX_MTIME) show_ts("mtime", &stx.stx_mtime);
    if (stx.stx_mask & STATX_CTIME) show_ts("ctime", &stx.stx_ctime);
    if (stx.stx_mask & STATX_BTIME) show_ts("btime", &stx.stx_btime);
    else                            printf("  btime  不支持\n");

#ifdef STATX_MNT_ID
    if (stx.stx_mask & STATX_MNT_ID)
        printf("  mnt_id %llu\n", (unsigned long long)stx.stx_mnt_id);
#endif
#ifdef STATX_DIOALIGN
    if (stx.stx_mask & STATX_DIOALIGN)
        printf("  dio    mem=%u off=%u\n",
               stx.stx_dio_mem_align, stx.stx_dio_offset_align);
#endif
    show_attrs(stx.stx_attributes);
    return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 statx_full.c -o statx_full
./statx_full /etc/hosts
./statx_full /tmp        # 目录:看 mnt_id

stx_mask 里没有 STATX_BTIME 时,btime 打印「不支持」,不要用 0 当「创建于 1970 年」。mnt_id 可以和 /proc/self/mountinfo 的第一列对上,用来区分绑定挂载。

`statx` 的四个坑
  1. 「statx 一定返回 btime」——ext4/XFS 通常支持,tmpfs/部分网络文件系统可能不填;看 mask。
  2. 「STATX_ALL 包含所有字段」——它是历史别名,新字段(如 STATX_DIOALIGN)不在其中。
  3. 「stx_btime 为 0 表示创建时间未知」——先看 STATX_BTIME 位;位没置时字段无意义。
  4. 「旧内核会自动降级」——statx 返回 ENOSYS,要回退到 stat/fstatat。

2.5.10 时间戳的完整语义:relatime、lazytime 与 utimensat

这一节回答:为什么 cat 一个文件后 atime 没变?ctime 为什么改不了?touch -d 到底改了哪几个时间?

三个时间戳的分工(Kerrisk 15.2,p236):

时间更新时机能否手动设置
atimeread、exec、readdir、mmap 读utimensat/futimens
mtimewrite、truncate、mmap 共享写utimensat/futimens
ctimeinode 变更:chmod/chown/link/unlink/rename/setxattr不能

ctime 不是创建时间;Linux 传统 struct stat 没有创建时间,statx 的 btime 才是。ctime 会随着「元数据变化」更新,mtime 只在内容变化时更新;rename 通常改文件的 ctime 和父目录的 mtime/ctime(Kerrisk 表 15-2)。

relatime 是默认行为

挂载选项决定 atime 的更新频率:

选项行为
strictatime每次读都更新
relatime(默认)仅当 atime 早于 mtime/ctime,或超过 24 小时才更新
noatime完全不更新
lazytime只更新内存中的时间,回写时才落盘

relatime 是「性能与语义的折中」:它保证「至少一天一次」和「mtime 变了就更新」,但不保证每次读都更新。noatime 进一步省掉 inode 写;lazytime 减少频繁写 inode,但崩溃时可能丢时间更新。用 findmnt -T /path -o OPTIONS 看当前挂载选项。

utimensat/futimens

int utimensat(int dirfd, const char *path, const struct timespec times[2], int flags);
int futimens(int fd, const struct timespec times[2]);

times[0] 是 atime,times[1] 是 mtime。特殊值 UTIME_NOW(现在)和 UTIME_OMIT(不改)。flags 可带 AT_SYMLINK_NOFOLLOW;futimens 用 fd,避免路径竞态。失败:EACCES、EPERM(非属主且无 CAP_FOWNER)、EROFS、EINVAL(tv_nsec 非法)。

struct timespec ts[2] = {
    { .tv_nsec = UTIME_OMIT },              /* 不动 atime */
    { .tv_sec = 1700000000, .tv_nsec = 0 }, /* 固定 mtime */
};
if (utimensat(AT_FDCWD, path, ts, 0) == -1) errExit("utimensat");

touch -a 只改 atime,touch -m 只改 mtime,touch -d 两个都改;它们都改不了 ctime(ctime 会变成「现在」,因为 inode 被修改了)。

时间戳的精度

struct stat 的 timespec 是纳秒字段,但文件系统未必支持纳秒。ext4 通常支持纳秒(取决于 inode 大小与挂载选项),XFS 支持;FAT 只有 2 秒精度。statx 也返回纳秒,但值可能被截断。做「文件是否变化」判断时,比较 mtime + size 比比较 ctime 可靠;要更强保证用内容哈希。

老接口:utime()、utimes()、futimes()

老代码里还会遇到秒级/微秒级接口:

#include <utime.h>
int utime(const char *path, const struct utimbuf *times);   /* 秒级 */
#include <sys/time.h>
int utimes(const char *path, const struct timeval times[2]); /* 微秒级 */
int futimes(int fd, const struct timeval times[2]);
int lutimes(const char *path, const struct timeval times[2]); /* 不跟随符号链接 */

struct utimbuf { time_t actime, modtime; } 只有秒;times == NULL 表示都设为当前时间。utimes 的 timeval 带微秒,精度比 utime 高,但仍低于 utimensat 的纳秒。新代码统一用 utimensat/futimens,需要「不改 atime」时用 UTIME_OMIT;老接口没有这个语义。

2.5.11 inode 标志:chattr 与 FS_IOC_*FLAGS

这一节回答:chattr +i 为什么连 root 都改不了文件?程序里怎么读写这些标志?

inode flags(ext2 扩展属性)由 ioctl 读写:

int flags;
if (ioctl(fd, FS_IOC_GETFLAGS, &flags) == -1) errExit("FS_IOC_GETFLAGS");
flags |= FS_NODUMP_FL;
if (ioctl(fd, FS_IOC_SETFLAGS, &flags) == -1) errExit("FS_IOC_SETFLAGS");
常量chattr含义权限
FS_IMMUTABLE_FLi不可修改、不可删除/改名/改元数据CAP_LINUX_IMMUTABLE
FS_APPEND_FLa只能以 O_APPEND 打开写CAP_LINUX_IMMUTABLE
FS_NODUMP_FLddump 跳过属主通常可设
FS_NOATIME_FLA不更新 atime属主
FS_SYNC_FLS同步更新属主
FS_DIRSYNC_FLD目录更新同步(仅目录)属主
FS_JOURNAL_DATA_FLj数据也写日志(ext3/4)CAP_SYS_RESOURCE
FS_TOPDIR_FLTOrlov 顶层目录策略属主
FS_COMPR_FL/FS_SECRM_FL/FS_UNRM_FLc/s/u历史/未实现—

FS_IMMUTABLE_FL 一旦设置,write、truncate、chmod、chown、unlink、link、rename、setxattr 全部返回 EPERM,包括 root;要改必须先 chattr -i(仍需要 CAP_LINUX_IMMUTABLE)。FS_APPEND_FL 下,不以 O_APPEND 打开写会 EPERM,truncate 也会失败。

lsattr 显示的字母与 chattr 选项对应;lsattr -d dir 看目录自身。FS_IOC_FSGETXATTR/FS_IOC_FSSETXATTR 还能读写 project ID、extent 大小等(XFS/ext4)。

/* inode_flags.c — 读 inode flags,尝试加/清 NODUMP,演示 EPERM */
#include <errno.h>
#include <fcntl.h>
#include <linux/fs.h>
#include <stdio.h>
#include <string.h>
#include <sys/ioctl.h>
#include <unistd.h>

static void show(int fd, const char *tag)
{
    int flags = 0;
    if (ioctl(fd, FS_IOC_GETFLAGS, &flags) == -1) {
        printf("%s: GETFLAGS: %s\n", tag, strerror(errno));
        return;
    }
    printf("%s: flags=%#x%s%s%s\n", tag, flags,
           (flags & FS_IMMUTABLE_FL) ? " immutable" : "",
           (flags & FS_APPEND_FL) ? " append" : "",
           (flags & FS_NODUMP_FL) ? " nodump" : "");
}

int main(int argc, char *argv[])
{
    if (argc != 2) { fprintf(stderr, "usage: %s FILE\n", argv[0]); return 1; }

    int fd = open(argv[1], O_RDONLY | O_CLOEXEC);
    if (fd == -1) { perror("open"); return 1; }
    show(fd, "before");

    int flags = 0;
    if (ioctl(fd, FS_IOC_GETFLAGS, &flags) == -1) { perror("GETFLAGS"); return 1; }

    int want = flags | FS_NODUMP_FL;
    errno = 0;
    if (ioctl(fd, FS_IOC_SETFLAGS, &want) == -1)
        printf("set NODUMP: %s(普通用户可能无权限)\n", strerror(errno));
    else
        show(fd, "after set");

    /* 清掉刚加的位;如果前面失败,这里也可能失败 */
    want = flags & ~FS_NODUMP_FL;
    if (ioctl(fd, FS_IOC_SETFLAGS, &want) == -1)
        printf("clear NODUMP: %s\n", strerror(errno));
    else
        show(fd, "after clear");

    close(fd);
    return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 inode_flags.c -o inode_flags
touch /tmp/flags.txt && ./inode_flags /tmp/flags.txt
chattr +i /tmp/flags.txt 2>/dev/null && lsattr /tmp/flags.txt

在 ext4 上,普通用户对 FS_NODUMP_FL 的设置通常成功;chattr +i 需要 root 或 CAP_LINUX_IMMUTABLE。不要在脚本里假设「属主一定能设所有 flags」。

inode flags 的五个坑
  1. 「chattr +i 只防删除」——它同时禁止写、截断、改权限、改属主、改名、加 xattr。
  2. 「root 可以绕过 immutable」——不能;必须先清 flag(仍需 capability)。
  3. 「所有文件系统都支持 flags」——tmpfs 支持一部分,VFAT/NFS 可能返回 EOPNOTSUPP。
  4. 「FS_APPEND_FL 等于 O_APPEND」——前者是 inode 级强制,后者是每次打开的 flag;FS_APPEND_FL 下不用 O_APPEND 打开写会失败。
  5. 「lsattr 的字母和 ioctl 位一一对应」——大多数对应,但 e(extent)等是只读展示,不能通过 chattr 设置。

2.5.12 xattr 的命名空间与实现限制

这一节回答:为什么 setfattr -n trusted.foo 返回 EPERM?一个文件最多能放多少 xattr?

xattr 的名字是 namespace.name,命名空间决定权限与用途(Kerrisk 16.1,p258):

命名空间谁可读写典型用途
user.*文件属主/有读写权限者应用元数据
trusted.*CAP_SYS_ADMIN系统级标记
system.*内核(部分只读)POSIX ACL(system.posix_acl_access)
security.*LSM/特权SELinux label、security.capability

user.* 只能用于普通文件和目录:符号链接的权限对所有用户开放,无法用权限阻止别人加 xattr;设备/FIFO/socket 的权限用于控制 I/O,不应被 xattr 权限干扰。对 sticky 目录(如 /tmp),非属主不能加 user.*。

限制(VFS 层,所有文件系统):

  • 名字 ≤ 255 字节;
  • 值 ≤ 64 KiB;
  • ext4 上「所有 xattr 名字 + 值」总和必须放进一个 inode 块(1/2/4 KiB),所以实际能放的 ACL 条目约 500 条;XFS 的 ACL 上限是 25 条,Reiserfs/JFS 约 8191 条(Kerrisk 17.7)。

API 的 l/f 变体与 stat 家族一致:setxattr 跟随符号链接,lsetxattr 不跟随,fsetxattr 用 fd。flags:

  • XATTR_CREATE:已存在则 EEXIST;
  • XATTR_REPLACE:不存在则 ENODATA;
  • 0:创建或替换。

getxattr 返回值的三种情况:>=0 是长度;-1/ENODATA 不存在;-1/ERANGE 缓冲区太小。标准两步法仍有竞态:第一次问长度,第二次取值时属性可能变大/被删;要循环处理 ERANGE,或直接按 64 KiB 上限分配。

listxattr 返回 name\0name\0...,总长度;size=0 只问长度。出于安全,listxattr 可能省略调用者无权读的属性(如 trusted.*),所以「list 里有名字,get 却失败」是正常的。

xattr 的五个坑
  1. 「ENOTSUP 表示名字写错」——表示文件系统不支持 xattr;名字错通常是 EINVAL/ENODATA。
  2. 「空值等于不存在」——空字符串是合法值;getxattr 返回 0,ENODATA 才是不存在。
  3. 「listxattr 列出的一定能 get」——可能因权限被过滤,或在两次调用间被删。
  4. 「cp -a 会保留 xattr」——GNU cp -a 保留部分 xattr,ACL 需要 -a/--preserve=all;tar 要 --xattrs,rsync 要 -X。
  5. 「security.capability 可以随便写」——它是文件 capability 的二进制格式,写错会破坏可执行文件权限;用 setcap。

2.5.13 ACL 的二进制格式与检查算法

这一节回答:getfacl 的输出在磁盘上长什么样?内核按什么顺序判断「这个用户能不能读」?

存储:一个 system xattr

扩展 ACL 存在 system.posix_acl_access 里;最小 ACL(只有 owner/group/other 三条)不占 xattr,直接存在 st_mode 的 9 位里。目录的默认 ACL 存在 system.posix_acl_default。

二进制格式(Linux include/uapi/linux/posix_acl_xattr.h):

struct posix_acl_xattr_header {
    __le32 a_version;          /* 2 */
};
struct posix_acl_xattr_entry {
    __le16 e_tag;
    __le16 e_perm;
    __le32 e_id;               /* 0xffffffff 表示无 qualifier */
};

每条 ACE 8 字节。e_tag 的取值:

tag值含义
ACL_USER_OBJ0x01属主
ACL_USER0x02命名用户
ACL_GROUP_OBJ0x04属组
ACL_GROUP0x08命名组
ACL_MASK0x10掩码
ACL_OTHER0x20其他

e_perm 是 4=读、2=写、1=执行 的位或。e_id 对 ACL_USER/ACL_GROUP 是 UID/GID;其余为 0xffffffff。

检查算法

内核的 posix_acl_permission() 按固定顺序匹配,第一个匹配的条目决定结果(Kerrisk 17.2,p267):

  1. euid == 0:读/写直接放行;执行要求至少一个执行位。
  2. euid == st_uid:用 ACL_USER_OBJ 的权限。
  3. euid 命中某个 ACL_USER:用该条目权限 & ACL_MASK。
  4. 进程的 egid 或辅助组命中 ACL_GROUP_OBJ 或某个 ACL_GROUP:用该条目权限 & ACL_MASK;只要有一个匹配的组条目授予了请求的权限就通过,否则拒绝(不会继续落到 ACL_OTHER)。
  5. 否则用 ACL_OTHER。

关键点:ACL_MASK 是所有命名用户、命名组和 ACL_GROUP_OBJ 的上限;chmod 改的是 mask,不是 ACL_GROUP_OBJ。stat 的 group 权限位显示的也是 mask。所以 chmod 640 会把 mask 设成 r--,命名用户的 rw- 有效权限只剩读。

/* acl_raw.c — 直接解码 system.posix_acl_access,不依赖 libacl */
#include <endian.h>
#include <errno.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/xattr.h>

struct acl_hdr {
    uint32_t version;
} __attribute__((packed));

struct acl_ent {
    uint16_t tag;
    uint16_t perm;
    uint32_t id;
} __attribute__((packed));

static const char *tag_name(uint16_t tag)
{
    switch (tag) {
    case 0x01: return "USER_OBJ";
    case 0x02: return "USER";
    case 0x04: return "GROUP_OBJ";
    case 0x08: return "GROUP";
    case 0x10: return "MASK";
    case 0x20: return "OTHER";
    default:   return "?";
    }
}

static void perm_str(uint16_t p, char out[4])
{
    out[0] = (p & 4) ? 'r' : '-';
    out[1] = (p & 2) ? 'w' : '-';
    out[2] = (p & 1) ? 'x' : '-';
    out[3] = '\0';
}

int main(int argc, char *argv[])
{
    if (argc != 2) { fprintf(stderr, "usage: %s FILE\n", argv[0]); return 1; }

    ssize_t n = getxattr(argv[1], "system.posix_acl_access", NULL, 0);
    if (n == -1) {
        if (errno == ENODATA)
            printf("%s: 无扩展 ACL(最小 ACL 在 mode 位中)\n", argv[1]);
        else
            perror("getxattr");
        return 0;
    }

    unsigned char *buf = malloc((size_t)n);
    if (!buf) { perror("malloc"); return 1; }
    n = getxattr(argv[1], "system.posix_acl_access", buf, (size_t)n);
    if (n == -1) { perror("getxattr"); free(buf); return 1; }

    if ((size_t)n < sizeof(struct acl_hdr)) {
        fprintf(stderr, "ACL 太短: %zd\n", n);
        free(buf);
        return 1;
    }
    const struct acl_hdr *h = (const struct acl_hdr *)buf;
    printf("version=%u bytes=%zd\n", le32toh(h->version), n);

    size_t off = sizeof *h;
    while (off + sizeof(struct acl_ent) <= (size_t)n) {
        const struct acl_ent *e = (const struct acl_ent *)(buf + off);
        uint16_t tag = le16toh(e->tag);
        uint16_t perm = le16toh(e->perm);
        uint32_t id = le32toh(e->id);
        char p[4];
        perm_str(perm, p);
        if (id == 0xffffffffU)
            printf("  %-10s %s\n", tag_name(tag), p);
        else
            printf("  %-10s %s id=%u\n", tag_name(tag), p, id);
        off += sizeof *e;
    }
    free(buf);
    return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 acl_raw.c -o acl_raw
touch /tmp/acl.txt && chmod 640 /tmp/acl.txt
setfacl -m u:"$USER":rw /tmp/acl.txt
./acl_raw /tmp/acl.txt
# version=2 bytes=44
#   USER_OBJ   rw-
#   USER       rw- id=1000
#   GROUP_OBJ  r--
#   MASK       rw-
#   OTHER      r--

44 字节 = 4 字节头 + 5 条 8 字节 ACE。chmod 640 后 mask 变 r--,但命名用户的条目仍是 rw-;getfacl 会显示 effective: r--,acl_raw 只显示原始条目,所以要看 mask 才能算有效权限。

ACL 的五个坑
  1. 「chmod 640 会删掉命名用户」——不会,它改 mask,条目还在,只是有效权限变小。
  2. 「ACL_MASK 只是给命名组的」——它同时限制 ACL_GROUP_OBJ 和所有命名用户/组。
  3. 「组匹配失败会落到 OTHER」——不会;有扩展 ACL 时,组类匹配失败直接拒绝。
  4. 「ACL 条目可以无限多」——ext4 受一个 inode 块限制(约 500 条),XFS 上限 25 条。
  5. 「cp -a 一定保留 ACL」——GNU cp -a 会尽量保留,但跨文件系统/不支持 ACL 的目标会退化;用 getfacl/setfacl 校验。

2.5.14 默认 ACL、umask 与创建权限的联合算法

这一节回答:目录上设了默认 ACL 后,新建文件的权限到底由谁决定——mode、umask 还是 ACL?

默认 ACL(setfacl -d)只对目录有效,作用是给新建的子项提供模板。新建普通文件时:

  1. 若父目录没有默认 ACL:mode & ~umask 决定权限,最小 ACL 写在 mode 位里。
  2. 若父目录有默认 ACL:新文件继承默认 ACL 的条目;mode 中未请求的权限会从继承条目的 ACL_USER_OBJ、ACL_MASK、ACL_OTHER 中清掉;umask 不再单独决定结果(POSIX 语义是「默认 ACL 存在时忽略 umask」)。

新建子目录时,除了继承访问 ACL,还会继承默认 ACL 作为自己的默认 ACL(如果父目录有默认 ACL)。setfacl -d -m u:alice:rwx /srv/shared 后,/srv/shared 下新建的文件/目录都会带 alice 的条目。

mkdir -p /tmp/shared && chmod 2775 /tmp/shared
setfacl -d -m u:"$USER":rwx /tmp/shared      # 默认 ACL
setfacl -m u:"$USER":rwx /tmp/shared         # 目录自身的访问 ACL
umask 077
touch /tmp/shared/newfile
getfacl /tmp/shared/newfile

umask 077 下,新文件的 ACL_USER_OBJ 仍是 rw-(创建者需要能读写),ACL_MASK 可能被限制为 ---,命名用户的 rwx 有效权限变成 ---。这正是「默认 ACL 与 umask 共同作用」的可见结果:条目在,但 mask 让它们无效。用 setfacl -m m::rwx 恢复 mask,或创建时用 umask 002。

删除:setfacl -k dir 删默认 ACL;setfacl -b dir 删所有扩展 ACL(保留最小 ACL);setfacl -x u:alice file 只删该条目。

排查「新建文件权限不对」
  1. getfacl -p DIR 看父目录是否有默认 ACL(default: 行)。
  2. umask 看当前 shell 的掩码;程序里是否改过 umask。
  3. getfacl FILE 看 mask:: 与 effective:;mask 可能把命名条目全禁了。
  4. stat -c '%a' FILE 看 mode 位;有扩展 ACL 时 group 位显示的是 mask。
  5. 跨文件系统/容器:目标 FS 是否挂载了 acl 选项;ENOTSUP 表示不支持。

2.5.15 内核路径:statx、xattr 与 ACL 落到 ext4

这一节回答:statx 的 btime、getxattr 的值、ACL 的检查,分别由内核哪些函数完成?

statx

层次位置动作
系统调用fs/stat.c__do_sys_statx → do_statx
公共层fs/stat.cvfs_statx → vfs_getattr
安全security/security.csecurity_inode_getattr(LSM 钩子)
文件系统fs/ext4/inode.cext4_getattr → generic_fillattr
btimefs/ext4/inode.cext4_getattr 填 stat->btime = ei->i_crtime
返回fs/stat.ccp_statx 把内核 struct kstat 转成用户 struct statx

statx 的 mask 是「请求」,stx_mask 是「实际提供」;vfs_getattr 会把文件系统不支持的位清掉。AT_STATX_DONT_SYNC 允许只读缓存,FORCE_SYNC 强制回源(网络文件系统有意义)。

xattr

层次位置动作
系统调用fs/xattr.c__do_sys_getxattr → getxattr
权限fs/xattr.cxattr_permission 检查命名空间/权限
文件系统fs/ext4/xattr.cext4_xattr_get
存储fs/ext4/xattr.cext4_xattr_ibody_get(inode 内)/ext4_xattr_block_get(外部块)

ext4 的 xattr 优先存在 inode 的额外空间(i_file_acl 之外),放不下才分配外部块。设置时走 ext4_xattr_set → ext4_xattr_set_handle,在日志事务里修改,所以 setxattr 的持久化也受 fsync/日志提交影响。

ACL

层次位置动作
读取fs/posix_acl.cget_acl → ext4_get_acl
解码fs/posix_acl.cposix_acl_from_xattr
检查fs/namei.cgeneric_permission → acl_permission_check
匹配fs/posix_acl.cposix_acl_permission

acl_permission_check 先看是否为属主、是否有 CAP_DAC_OVERRIDE,再调用 posix_acl_permission 按 2.5.13 的顺序匹配。默认 ACL 的继承在创建路径上:posix_acl_create → posix_acl_create_masq,由文件系统在 ->create/->mkdir 里调用。

VFS 是「中间层」,不是「文件系统」

statx、xattr、ACL 都先经过 VFS 的通用检查(权限、命名空间、锁),再调用具体文件系统的 inode_operations/xattr_handler。所以同一个调用在 ext4 与 NFS 上的错误码可能不同:VFS 返回 EOPNOTSUPP,NFS 可能返回 ENOTSUP;ext4 的 ACL 受 inode 块限制,XFS 受 25 条限制。排障时先确认「VFS 层拒绝」还是「文件系统层拒绝」。

2.5.16 两个实验:strace 看元数据调用与 getfacl/getfattr 验证

这一节回答:怎样确认程序真的调用了 statx/getxattr?怎样把 ACL 的原始字节 dump 出来?

实验一:strace 跟踪元数据调用

strace -yy -e trace=%stat,getxattr,listxattr,setxattr ./statx_full /etc/hosts 2>&1 | head -12

典型输出:

$ strace -e trace=%stat,getxattr ./statx_full /etc/hosts 2>&1 | head -6
statx(AT_FDCWD, "/etc/hosts", AT_STATX_SYNC_AS_STAT, STATX_BASIC_STATS|STATX_BTIME, {stx_mask=STATX_BASIC_STATS|STATX_BTIME, ...}) = 0
newfstatat(AT_FDCWD, "/etc/hosts", {st_mode=S_IFREG|0644, st_size=221, ...}, 0) = 0

读法:

  • statx 的第四个参数是请求 mask,返回结构里的 stx_mask 是实际提供的位;两者可能不同。
  • glibc 的 stat() 在 64 位 Linux 上通常走 newfstatat(AT_SYMLINK_NOFOLLOW 或 0),不是 statx。
  • getxattr("file", "user.release", ...) 失败会显示 = -1 ENODATA;ENOTSUP 表示文件系统不支持。
  • %stat 是 strace 的调用类,包含 stat/lstat/fstat/newfstatat/statx。

实验二:getfattr/getfacl 与原始字节

# 1. 看所有 xattr(默认只看 user.*,-m - 看全部)
setfattr -n user.release -v 2026.10 /tmp/x.txt
getfattr -d -m - /tmp/x.txt

# 2. 看 ACL 文本
setfacl -m u:"$USER":rw /tmp/x.txt
getfacl -p /tmp/x.txt

# 3. dump ACL 的原始二进制(4 字节头 + 8 字节/条)
getfattr --only-values -n system.posix_acl_access /tmp/x.txt | od -An -tx1

# 4. 看 inode flags
lsattr /tmp/x.txt

getfattr -d -m - 会显示 trusted.*/security.*(如果有权限);-m - 是「匹配所有名字」的正则。--only-values 只输出值,适合接 od/xxd。getfacl -p 保留绝对路径,便于把输出喂回 setfacl --set-file。

stat -c '%d %i %h %s %b %B %a %n' file 一次给出设备号、inode、链接数、大小、块数、块单位、八进制权限;find /path -printf '%i %n %s %b\n' 可以批量核对。

元数据排障顺序
  1. stat/statx 看基础字段;statx 的 stx_mask 确认 btime/mnt_id 是否有效。
  2. getfacl 看 ACL 与 mask;lsattr 看 inode flags;getfattr -d -m - 看 xattr。
  3. findmnt -T /path -o TARGET,SOURCE,FSTYPE,OPTIONS 看挂载选项(noatime、acl、ro)。
  4. strace -e trace=%stat,getxattr,setxattr 看程序实际调用了什么、返回什么 errno。
  5. 跨容器/命名空间:/proc/PID/mountinfo 的 mnt_id 与 statx.stx_mnt_id 对照。

2.5.17 本章陷阱清单

文件属性的十二个坑
  1. 现象:stat 的 st_size 是 1 GiB,du 是 0。原因:稀疏文件。修复:看 st_blocks*512。
  2. 现象:cat 后 atime 没变。原因:relatime/noatime。修复:findmnt 看挂载选项;不要用 atime 做访问审计。
  3. 现象:chmod 640 后命名用户突然不能写。原因:chmod 改了 ACL mask。修复:setfacl -m m::rw 或调整 chmod。
  4. 现象:getxattr 返回 ERANGE。原因:缓冲区太小。修复:先 getxattr(..., NULL, 0) 取长度,再分配重试。
  5. 现象:setxattr 返回 EPERM。原因:trusted.* 需要 CAP_SYS_ADMIN,或 sticky 目录限制。修复:换 user.* 或提权。
  6. 现象:stat 大文件返回 EOVERFLOW。原因:32 位 off_t。修复:-D_FILE_OFFSET_BITS=64。
  7. 现象:statx 的 btime 是 0。原因:文件系统不支持或 STATX_BTIME 未置位。修复:检查 stx_mask。
  8. 现象:chattr +i 后连 root 都删不掉。原因:immutable 是 inode 级强制。修复:chattr -i(需 capability)。
  9. 现象:cp -a 后 ACL 丢了。原因:目标文件系统不支持 ACL 或未保留。修复:getfacl | setfacl --set-file=- 显式复制。
  10. 现象:st_ino 相同的两个路径却不是同一文件。原因:不同设备/挂载的 inode 号重复。修复:比较 (st_dev, st_ino) 或 statx.stx_mnt_id。
  11. 现象:rename 后 ctime 变了但 mtime 没变。原因:rename 改元数据不改内容。修复:这是正常语义;用 statx 的 btime/mtime 组合判断。
  12. 现象:getfacl 显示 effective: r--,但条目写的是 rw-。原因:mask 限制。修复:改 mask 或调整条目。

2.5.18 本章出口检查

检查项达标线没达标去哪
区分 stat/lstat/fstatat能说出符号链接与 cwd 的差别2.5.1
解释 st_size 与 st_blocks 不同能举稀疏文件2.5.3
说出三个时间戳各在何时更新至少说对 ctime 不是创建时间2.5.3
解释 access 的 TOCTOU能给出 open + EACCES 的替代2.5.2
用 xattr 存一个值并处理 ERANGE代码能编译2.5.5
解释 ACL mask 与 chmod 的交互能说出 chmod g= 改 mask2.5.6、2.5.13
用 statx 取 btime/mnt_id先检查 stx_mask2.5.9
读/写 inode flags知道 immutable 需要 capability2.5.11
解码 system.posix_acl_access能说出 4 字节头 + 8 字节/条2.5.13
即时自测 · 2.5

Q1 lstat 一个指向 /etc/hosts 的符号链接,st_size 是什么?st_mode 的类型是什么?

Q2 一个文件 ls -l 显示 1 GiB,du 显示 0。解释原因,并说出 st_blocks 的单位。

Q3 chmod 640 file 之后,getfacl 里 user:alice:rw- 仍在,但 alice 只能读。为什么?

Q4 access(path, W_OK) 返回 0,随后 open(path, O_WRONLY) 却返回 EACCES。列出至少两种原因。

Q5 getxattr(path, name, buf, size) 返回 -1、errno=ERANGE。怎么正确重试?

答案

A1 st_size 是链接目标字符串 /etc/hosts 的长度(10 字节),不是目标文件大小;st_mode 的类型位是 S_IFLNK(l)。

A2 稀疏文件:truncate/ftruncate 扩展出的部分是空洞,逻辑大小 1 GiB,但没有分配数据块。st_blocks 的单位是 512 字节,st_blocks * 512 才是实际占用。

A3 ACL 的 mask:: 被 chmod 640 设成了 r--。mask 是所有命名用户/组和 group:: 的有效权限上限,所以 user:alice:rw- 的有效权限只剩读。要恢复,setfacl -m m::rw file。

A4 ① access 用真实 UID,open 用有效 UID(setuid 程序);② 检查与打开之间权限被改(TOCTOU);③ 目录缺 x 导致路径解析失败;④ 只读文件系统或 LSM 拒绝。

A5 ERANGE 表示缓冲区太小。先 getxattr(path, name, NULL, 0) 取所需长度,malloc(n) 后重试;不要盲目把 size 翻倍循环。

母本对应

母本章节本书落点程度
K第 14 章(inode/VFS)、第 15 章(文件属性、时间戳、inode flags)、第 16 章(扩展属性)、第 17 章(ACL)2.5.1–2.5.17完整(ACL 二进制/检查算法、xattr 限制、inode flags 为重点)
Linux 4.11+statx(2)、STATX_*(母本成书时尚未提供)2.5.9按 man 2 statx 与内核 6.8 源码补充
C第 10 章 10.6(文件元数据)2.5.1、2.5.2对照(stat 与目录项)
O第 39–40 章(文件与目录、文件系统实现)2.5.1、2.5.3背景(inode、块分配)
G第 5 章(文件权限与目录配置)、第 13 章(账号管理与 ACL)2.5.2、2.5.6背景(ls -l、getfacl/setfacl)
N——未用:本章不涉及网络栈

下一站:2.6 目录、链接与文件事件 把「文件」放回目录树:硬链接与符号链接的区别、rename 的原子性、inotify 如何把文件变化变成可读事件。