复制后的字节内容可以完全一致,但应用需要的扩展属性已经缺失。

扩展属性属于 inode,也受命名空间约束

扩展属性在普通文件内容流之外,为文件系统对象附加有名字的字节值。应用可以使用 user 命名空间存放小型元数据,其他命名空间则具有内核、安全模块或特权相关语义。它是否可靠,取决于文件系统、访问规则和传输工具。只有工作流程按明确契约保留并解释标签,元数据才能成为下游判断的有效依据。 坏掉的样子 属性值不一定是文本,可以包含空字符或任意二进制数据。把它当作以零结尾的字符串,或者假定固定编码,可能破坏原值。空属性和缺失属性也不是同一状态;如果都返回空字符串,就会隐藏元数据到底被刻意设为空,还是在传输中丢失。使用前需要明确编码与缺失语义,而不是依赖显示工具碰巧如何呈现。 同一文件系统内重命名保留对象及属性,复制内容通常却创建另一个 inode。复制工具决定尝试保留哪些元数据,因此文件哈希相同不能证明扩展属性相同。流程可能成功传递每一个内容字节,却遗漏下游需要的分类、来源或应用版本标签。内容校验通过,只能证明它实际覆盖的内容范围。 命名空间名称不会自动建立应用授权规则。设置 user.classification 为 private,不会让本来可读的文件变得保密。user 属性服从适用的文件访问权限,安全属性则有另外的文档语义与策略。把应用标签当作已经强制执行的访问控制,可能在标签看起来完好的同时暴露内容,因此需要检查实际读写行为。 快速验证 把脚本保存为 xattrs.py,在临时目录支持用户扩展属性的一次性 Linux 容器中,以 root 运行 python3 xattrs.py。实测使用 Linux 6.12.76、Python 3.11.2 和 ext4 卷。root 准备样本,再通过一个普通数字身份启动子进程,不会创建账号: from pathlib import Path from tempfile import TemporaryDirectory import errno,os,shutil,subprocess,sys def fails(code,operation): try:operation() except OSError as error:assert error.errno==code,error else:raise AssertionError('expected attribute error') with TemporaryDirectory(prefix='xbcatm-xattr-') as directory: root=Path(directory);root.chmod(0o755);path=root/'data';path.write_bytes(b'public fixture');path.chmod(0o644) os.setxattr(path,'user.review',b'binary\0value\xff',os.XATTR_CREATE) assert os.getxattr(path,'user.review')==b'binary\0value\xff' fails(errno.EEXIST,lambda:os.setxattr(path,'user.review',b'other',os.XATTR_CREATE)) fails(errno.ENODATA,lambda:os.setxattr(path,'user.missing',b'value',os.XATTR_REPLACE)) os.setxattr(path,'user.empty',b'');assert os.getxattr(path,'user.empty')==b'' os.removexattr(path,'user.empty');fails(errno.ENODATA,lambda:os.getxattr(path,'user.empty')) print('attribute values are bytes; empty differs from missing; CREATE and REPLACE enforce their preconditions') alias=root/'alias';os.link(path,alias) os.setxattr(alias,'user.review',b'shared',os.XATTR_REPLACE) assert os.getxattr(path,'user.review')==b'shared' moved=root/'moved';path.rename(moved);assert os.getxattr(moved,'user.review')==b'shared' plain=root/'plain';preserved=root/'preserved';shutil.copyfile(moved,plain);shutil.copy2(moved,preserved) fails(errno.ENODATA,lambda:os.getxattr(plain,'user.review')) assert os.getxattr(preserved,'user.review')==b'shared' assert plain.read_bytes()==preserved.read_bytes()==moved.read_bytes() print('hard links and same-filesystem rename retain inode attributes; copyfile omits them, copy2 preserves this supported user attribute') os.setxattr(moved,'user.classification',b'private') probe=r''' from pathlib import Path import errno,os,sys path=Path(sys.argv[1]);assert path.read_bytes()==b'public fixture' assert os.getxattr(path,'user.classification')==b'private' try:os.setxattr(path,'user.classification',b'changed') except OSError as error:assert error.errno==errno.EACCES else:raise AssertionError('another identity changed a read-only user attribute') ''' result=subprocess.run([sys.executable,'-c',probe,str(moved)],user=61001,group=61001,extra_groups=[],capture_output=True,text=True,timeout=5) assert result.returncode==0,result.stderr fails(errno.E2BIG,lambda:os.setxattr(moved,'user.too_large',b'x'*65537)) print('a user.classification label does not enforce confidentiality; file permissions block the other identity from changing it; oversized value returns E2BIG') 样本保存同时包含空字符和非 UTF-8 字节的值,并取回完全相同的字节。用 XATTR_CREATE 再次创建相同键,得到 EEXIST;用 XATTR_REPLACE 修改缺失键,得到 ENODATA。这些标志明确表达前提,而不是把所有写入都变成不检查原状态的覆盖。错误结果也是契约的一部分,需要由调用方实际处理。 ...

十二月 14, 2027 · 1 分钟 · XB Field Notes
一个与源共享未改动数据、改动后才分离的时间点视图。

Btrfs 快照是写时复制,不是复制

btrfs 快照不是任何意义上的复制。btrfs subvolume snapshot 建立的新子卷,其根指向与源完全相同的 extent 树,两边共享的每个 extent 被引用两次、只存一份。创建是一次元数据操作:子卷里装的是 1 GiB 还是 10 TiB,耗时都在毫秒级。 这让快照成为机器上最便宜的回滚原语,也正是它最常被过度信任的原因。空间随分岔增长,而不随数据量增长;代价来得晚,落在别人的写操作上,而且落在保存原始数据的那块设备上。从不清理的快照是一场慢动作的磁盘写满事故,而同盘快照无论留多少份都不是备份。 共享 extent,而不是复制数据块 快照就是子卷,而子卷是拥有自己根的子树,所以快照机制与让 reflink 生效的是同一套引用计数。用 btrfs subvolume snapshot -r /data /snap/data-2026-09-16 打一个只读快照;-r 很关键,因为可写快照在任何一次写入发生的那一刻就开始分岔,而备份目标本来就不该可写。快照与源是平级的子卷,清理用 btrfs subvolume delete /snap/data-2026-09-16;正在被挂载使用的子卷删不掉,btrfs subvolume list -t 能把快照按父子关系列成表,按时间做保留时用得上。 真正花钱的是快照之后的那次写入。任意一侧覆盖一个共享 extent 时,文件系统给写入方分配新 extent,把旧 extent 留给另一侧,所以快照的价格由随后的写入支付,计量单位是这次写入触及的 extent 数,而不是快照命令本身。APFS 的 clonefile 遵循同一规则,fixture 量到的形状是:克隆 64 MiB 花费 0 KiB,普通复制 64 MiB 花费 65536 KiB,覆盖克隆文件前 8 MiB 花费 8192 KiB;而把这 8 MiB 再改写一次花费 0 KiB,因为这些 extent 已经分岔过了。 ...

十一月 14, 2027 · 2 分钟 · XB Field Notes
父目录监视能看到替换后的名字,旧 inode 监视不会自动跟踪当前文件。

inotify 报告变化,但不是完整历史记录

inotify 通过一个带注册监视项和可读事件队列的实例,投递 Linux 文件系统通知。每条记录包含监视编号、事件掩码,也可能包含名字和移动 cookie。这能减少对未变化路径的反复检查,但事件流不是无限审计日志。正确消费者应把通知理解为相关状态可能需要重新检查的证据,而不能默认它保存了文件发生过的每一次操作。 坏掉的样子 监视目录不会递归监视后代。它报告直接目录项上的相关活动,因此需要观察更深变化时,新子目录也需要独立监视。目录树监视器必须处理初次枚举和之后加入的目录。假定只注册根目录就覆盖整棵树,会悄悄漏掉更深层变化;能收到子目录被创建,并不代表能收到它内部之后的全部操作。 文件监视跟随具体对象,不会永久绑定路径。编辑器可以先写完并关闭临时文件,再用 rename 替换目标。父目录会报告替换,旧文件监视却不会转移到新 inode。如果消费者等待目标名字之后再出现 close-write,可能永远等不到,因为替代文件在发布前已经关闭。不能把一种编辑器保存顺序当成所有更新方式的固定流程。 尚未读取的相同事件可以合并,队列满后会丢弃事件并报告 IN_Q_OVERFLOW。因此,事件数量不是可靠写入计数。溢出后,消费者无法仅靠剩余记录重建全部缺失操作,必须重新核对当前监视状态,不能继续假定根据事件维护的模型完整。历史丢失与当前状态仍可读取,是需要分别理解的两件事。 快速验证 把脚本保存为 inotify-events.py,在一次性 Linux 环境中运行 python3 inotify-events.py。实测使用 Linux 6.12.76、Python 3.11.2 和 ext4。样本创建 ASCII 名称的临时条目,并有意填满自己的队列。配置的队列上限必须不超过三万二千七百六十八;脚本只读取上限,不修改任何 sysctl: from pathlib import Path from tempfile import TemporaryDirectory import ctypes,os,struct MODIFY=0x2;ATTRIB=0x4;CLOSE_WRITE=0x8;MOVED_FROM=0x40;MOVED_TO=0x80 CREATE=0x100;DELETE=0x200;DELETE_SELF=0x400;MOVE_SELF=0x800;OVERFLOW=0x4000;IGNORED=0x8000 libc=ctypes.CDLL(None,use_errno=True) libc.inotify_init1.argtypes=[ctypes.c_int];libc.inotify_init1.restype=ctypes.c_int libc.inotify_add_watch.argtypes=[ctypes.c_int,ctypes.c_char_p,ctypes.c_uint32];libc.inotify_add_watch.restype=ctypes.c_int header=struct.Struct('iIII') with TemporaryDirectory(prefix='xbcatm-inotify-') as directory: root=Path(directory);fd=libc.inotify_init1(os.O_NONBLOCK|os.O_CLOEXEC);assert fd>=0,os.strerror(ctypes.get_errno()) def watch(path,mask): wd=libc.inotify_add_watch(fd,os.fsencode(path),mask);assert wd>=0,os.strerror(ctypes.get_errno());return wd def drain(): events=[] while True: try:data=os.read(fd,65536) except BlockingIOError:return events offset=0 while offset<len(data): wd,mask,cookie,length=header.unpack_from(data,offset);offset+=header.size name=data[offset:offset+length].split(b'\0',1)[0].decode();offset+=length events.append((wd,mask,cookie,name)) try: parent=watch(root,MODIFY|CLOSE_WRITE|CREATE|DELETE|MOVED_FROM|MOVED_TO) sub=root/'sub';sub.mkdir();drain();(sub/'unwatched').touch();assert drain()==[] nested=watch(sub,CREATE);(sub/'watched').touch();assert any(wd==nested and mask&CREATE and name=='watched' for wd,mask,_,name in drain()) print('a directory watch is not recursive; adding a child-directory watch observes its new entries') counter=root/'counter';stream=os.open(counter,os.O_CREAT|os.O_WRONLY,0o600);drain() try: for _ in range(3):os.write(stream,b'x') finally:os.close(stream) events=drain();assert sum(wd==parent and bool(mask&MODIFY) and name=='counter' for wd,mask,_,name in events)==1 moved=root/'moved';counter.rename(moved);events=drain() before=next(e for e in events if e[1]&MOVED_FROM and e[3]=='counter');after=next(e for e in events if e[1]&MOVED_TO and e[3]=='moved') assert before[2]==after[2]!=0 print('three unread identical modifications coalesce; rename FROM/TO events share a nonzero cookie') target=root/'target';target.write_bytes(b'old');file_watch=watch(target,MODIFY|ATTRIB|DELETE_SELF|MOVE_SELF);drain() replacement=root/'replacement';replacement.write_bytes(b'new');drain();replacement.replace(target);events=drain() assert any(wd==file_watch and mask&IGNORED for wd,mask,_,_ in events) assert any(wd==parent and mask&MOVED_TO and name=='target' for wd,mask,_,name in events) assert not any(wd==parent and mask&CLOSE_WRITE and name=='target' for wd,mask,_,name in events) target.write_bytes(b'newer');events=drain() assert any(wd==parent and mask&MODIFY and name=='target' for wd,mask,_,name in events) assert all(wd!=file_watch for wd,_,_,_ in events) print('replacement invalidates the old file watch; the parent-directory watch continues to observe the pathname') burst=root/'burst';burst.mkdir();drain();watch(burst,CREATE) limit=int(Path('/proc/sys/fs/inotify/max_queued_events').read_text());assert 0<limit<=32768,'use a disposable environment with a bounded queue limit' for number in range(limit+8):(burst/str(number)).touch() events=drain();assert any(wd==-1 and mask&OVERFLOW for wd,mask,_,_ in events) current=len(list(burst.iterdir()));assert current==limit+8 print('actual queue overflow:',len(events),'events retained for',current,'creates; rescan sees the current entries') finally:os.close(fd) 第一组监视父目录,创建子目录,并读走父级事件。随后在子目录内部创建文件,不会向父监视项产生事件;显式增加子目录监视后,下一次创建才被看到。这在同一个 inotify 实例中区分直接目录项覆盖和递归监视,避免只检查注册调用成功就以为整棵树都已进入观察范围。 ...

十月 13, 2027 · 2 分钟 · XB Field Notes
相同逻辑字节可以占用不同分配空间,打洞则会有意把对应数据变成零。

稀疏文件把逻辑长度与分配块分开

稀疏普通文件可以具有很大的逻辑长度,却只为其中部分地址范围分配存储。读取长度范围内的空洞会得到零字节,因此文件长度、字节内容和文件系统分配,是三项不同测量。为虚拟磁盘预算容量、复制备份,或者判断零区域是否已为将来写入保留空间时,都需要理解这种关系,不能用其中一个数字替代另外两个。 坏掉的样子 seek 越过文件结尾,只改变描述符偏移,不改变文件长度。后续写入或显式扩展,才可能创建中间读为零的范围。只调用 seek,就声称已经创建大型稀疏文件,是错误的;同样,逻辑大小很大,也不表示文件系统已经预留足够块,允许以后填满它。偏移位置、逻辑范围与实际容量承诺必须分开。 如果复制读取每个字节并普通写出,就可能把空洞变成分配了块的零区域。最终校验值完全相同,分配空间却大得多。传输或恢复流程若只依据源文件当前分配量做预算,即使正确保留逻辑内容,也可能耗尽目标容量。因此成功复制字节与保持稀疏表示,是需要分别验证的目标。 打洞是有意改变数据的操作。配合 KEEP_SIZE 对已分配范围打洞,会保持长度,却让对应字节读为零,并不是可以随手清理任意文件区域的无害命令。预分配解决另一类问题:它保留文件系统分配,即使未写入的已分配范围也可能读为零。内容都是零,不能证明这两种表示具有相同空间行为。 快速验证 把脚本保存为 sparse.py,在一次性 Linux 系统中使用 Python 3.11 或更新版本运行 python3 sparse.py,临时目录需位于 ext4。实测使用 Linux 6.12.76 和 Python 3.11.2。样本创建三个临时文件,使用约三十二 MiB 完整分配数据,以及少量稀疏分配: from pathlib import Path from tempfile import TemporaryDirectory import ctypes,hashlib,os size=16*1024*1024;first=4*1024*1024;second=12*1024*1024 libc=ctypes.CDLL(None,use_errno=True);libc.fallocate.argtypes=[ctypes.c_int,ctypes.c_int,ctypes.c_longlong,ctypes.c_longlong];libc.fallocate.restype=ctypes.c_int def digest(path): with path.open('rb') as handle:return hashlib.file_digest(handle,'sha256').hexdigest() with TemporaryDirectory(prefix='xbcatm-sparse-') as directory: root=Path(directory);path=root/'sparse';fd=os.open(path,os.O_RDWR|os.O_CREAT|os.O_EXCL,0o600) try: os.lseek(fd,size,os.SEEK_SET);assert os.fstat(fd).st_size==0 os.ftruncate(fd,size);assert os.fstat(fd).st_size==size and os.fstat(fd).st_blocks==0 assert os.pread(fd,16,0)==b'\0'*16 os.pwrite(fd,b'A',first);os.pwrite(fd,b'B',second);os.fsync(fd) allocated=os.fstat(fd).st_blocks*512;assert allocated<size data_start=os.lseek(fd,0,os.SEEK_DATA);hole_start=os.lseek(fd,data_start,os.SEEK_HOLE) assert data_start==first and first<hole_start<=second print('seek alone leaves size zero; truncate creates zero-reading holes; sparse size',size,'allocated',allocated) print('first SEEK_DATA',data_start,'following SEEK_HOLE',hole_start) dense=root/'dense' with path.open('rb') as source,dense.open('wb') as target: while chunk:=source.read(65536):target.write(chunk) target.flush();os.fsync(target.fileno()) assert digest(path)==digest(dense) assert dense.stat().st_blocks*512>=size print('explicitly writing all copied bytes preserves content but allocates the zero-filled ranges') result=libc.fallocate(fd,0x01|0x02,first,4096) # KEEP_SIZE | PUNCH_HOLE assert result==0,os.strerror(ctypes.get_errno()) os.fsync(fd) assert os.fstat(fd).st_size==size and os.pread(fd,1,first)==b'\0' assert os.pread(fd,1,second)==b'B' and os.fstat(fd).st_blocks*512<allocated print('punching the aligned data range releases its blocks and makes it read zero without changing file length') reserved=root/'reserved' with reserved.open('w+b') as handle: os.posix_fallocate(handle.fileno(),0,size);os.fsync(handle.fileno()) info=os.fstat(handle.fileno());assert info.st_size==size and info.st_blocks*512>=size assert handle.read(16)==b'\0'*16 print('preallocation reserves blocks even while the newly allocated range reads as zeros') finally:os.close(fd) 样本先在空文件中 seek 到十六 MiB,确认大小仍为零。随后 truncate 到这个长度,在本次 ext4 环境中得到逻辑十六 MiB、尚无已分配数据块的文件,读取开头为零。这个对照直接区分移动偏移与扩展文件,而不是依据 seek 返回了一个大数字,就推测文件内容范围已经改变。 ...

十月 11, 2027 · 2 分钟 · XB Field Notes
两个目录项共享同一个 inode,但替换其中一个目录项会让内容分开。

硬链接共享 inode,替换名字后关系会改变

硬链接为已有文件增加另一个目录项。两个名字都指向同一文件系统里的同一个 inode,其中没有哪个天然具有“原件”地位。内容、所有者、权限和链接数属于这个共享对象,文件名只是到达它的入口。因此,需要把目录项的变化与文件对象本身的变化分开理解,才能判断修改、删除和替换到底影响了哪些访问路径。 坏掉的样子 把硬链接当成独立备份,会产生错误的恢复边界。通过任意名字写入或截断共享 inode,另一名字读到的内容也会改变;修改模式位,同样改变的是一个对象的权限。第二个路径可以在第一个路径被删除后继续存在,却不能抵御针对共享文件的普通原地修改,也不会自动保留先前历史版本。 许多编辑器和部署工具采用另一种更新方式:先创建替代文件,再通过 rename 覆盖某个路径。该目录项此时指向新 inode,其他硬链接仍然到达旧对象。两个名字是否一起显示新内容,取决于实际更新方法。因此不能仅凭它们昨天建立过硬链接,就断言今天仍共享身份;应检查当前对象以及写入工具的真实行为。 硬链接还受文件系统挂载边界约束。即使两个目录在统一路径树里看起来相邻,link 也不能随意把不同挂载中的对象连接起来。Linux 还禁止普通目录硬链接。这些限制意味着,硬链接不能作为跨目录层次、跨挂载符号链接的通用替代品;使用前必须先确认目标对象和边界,而不是只看路径字符串。 快速验证 把脚本保存为 hardlinks.py,在一次性 Linux 容器中以 root 运行 python3 hardlinks.py。它要求 /tmp 与 /run 可写、属于不同文件系统,并在执行时检查这一条件。实测使用两个独立 tmpfs 挂载、Linux 6.12.76 和 Python 3.11.2,所有新建条目都位于临时目录中: from pathlib import Path from tempfile import TemporaryDirectory import errno,os with TemporaryDirectory(prefix='xbcatm-hardlinks-',dir='/tmp') as directory, TemporaryDirectory(prefix='xbcatm-crosslink-',dir='/run') as other: root=Path(directory);source=root/'source';alias=root/'alias' source.write_bytes(b'original');os.link(source,alias) before=source.stat();linked=alias.stat() assert (before.st_dev,before.st_ino)==(linked.st_dev,linked.st_ino) assert before.st_nlink==linked.st_nlink==2 and before.st_size==linked.st_size==8 alias.write_bytes(b'changed') assert source.read_bytes()==b'changed' print('two names share device/inode, size, and link count; in-place write changes both views') replacement=root/'replacement';replacement.write_bytes(b'new file');replacement.replace(source) assert source.stat().st_ino!=alias.stat().st_ino assert source.read_bytes()==b'new file' and alias.read_bytes()==b'changed' assert source.stat().st_nlink==alias.stat().st_nlink==1 print('atomic replacement changes one name to a new inode; the other name retains the old file') with alias.open('rb') as opened: alias.unlink() assert os.fstat(opened.fileno()).st_nlink==0 and opened.read()==b'changed' print('last unlink removes the final name but an existing open descriptor still reads the old data') assert root.stat().st_dev!=Path(other).stat().st_dev,'exercise requires two filesystem mounts' try:os.link(source,Path(other)/'cross-filesystem') except OSError as error:assert error.errno==errno.EXDEV,error else:raise AssertionError('cross-filesystem hard link unexpectedly succeeded') child=root/'directory';child.mkdir() try:os.link(child,root/'directory-link') except OSError as error:assert error.errno==errno.EPERM,error else:raise AssertionError('directory hard link unexpectedly succeeded') print('cross-filesystem link fails with EXDEV; directory hard link fails with EPERM on Linux') 样本先创建 source,写入八字节内容,再通过 os.link 添加 alias。两个名字报告相同的设备与 inode 组合、八字节大小,以及值为二的链接数。比较时必须包含设备身份,因为 inode 编号并不跨文件系统全局唯一;仅仅比较内容相同,也无法证明两个入口共享对象,独立副本同样可能有相同字节。 ...

十月 10, 2027 · 2 分钟 · XB Field Notes
同一段相对目标文本,在链接移动到另一个目录后会指向不同文件。

符号链接保存路径,不永久绑定目标身份

符号链接是保存目标路径的文件系统对象。跟随链接时,系统按操作发生时的状态解析这段路径;链接并没有永久绑定目标 inode。这个区别解释了为什么符号链接可以跨文件系统、为什么会变成悬空链接,以及为什么保存的目标文本完全没变,最终访问对象却可能改变。理解链接对象与当前解析目标,是正确使用和诊断链接的基础。 坏掉的样子 相对目标以符号链接所在目录为基准,而不是以调用进程的当前工作目录为基准。因此移动链接可能改变其含义;移动或重命名目标,则可能让链接悬空。部署过程如果调整目录结构,却没有核对这些解析基准,熟悉的链接就可能悄悄访问另一个文件。仅看 readlink 打印的文本没有变化,无法证明对象仍相同。 不同存在性检查回答不同问题。跟随链接的操作可能报告目标缺失,而检查目录项本身的操作仍能找到链接。同样,宽松路径解析函数可以在最终目标不存在时返回绝对字符串。规范化后的路径不是可用文件当前存在的证明,更不是文件身份的永久凭据;诊断必须说明自己检查了链接对象,还是跟随之后的目标。 当其他参与者能够修改相关目录项时,先检查路径再打开,会留下检查与使用之间的时间窗口。给路径加引号可以保护 shell 参数解析,却不能冻结文件系统解析。普通 open 的 O_NOFOLLOW 拒绝最后一个组件是符号链接,不会自动禁止前面目录组件中的链接。把参数安全、路径安全和对象稳定性混为一谈,会造成错误边界。 快速验证 把脚本保存为 symlinks.py,在一次性 Linux 环境中运行 python3 symlinks.py。实测使用 Linux 6.12.76 和 Python 3.11.2。脚本创建无害临时文件,并主动改变自己的链接,因此结果是确定性的,不需要并发攻击者,也不访问无关路径: from pathlib import Path from tempfile import TemporaryDirectory import errno,os def read_no_follow(path): fd=os.open(path,os.O_RDONLY|os.O_NOFOLLOW) try:return os.read(fd,100) finally:os.close(fd) with TemporaryDirectory(prefix='xbcatm-symlinks-') as directory: root=Path(directory);first=root/'first';second=root/'second';first.mkdir();second.mkdir() (first/'data').write_text('first');(second/'data').write_text('second') link=first/'link';link.symlink_to('data') assert os.readlink(link)=='data' and link.read_text()=='first' assert link.lstat().st_ino!=link.stat().st_ino moved=second/'link';link.rename(moved) assert os.readlink(moved)=='data' and moved.read_text()=='second' print('moving a relative symlink changes its target because resolution starts at its containing directory') (second/'data').rename(second/'renamed') assert moved.is_symlink() and os.path.lexists(moved) and not moved.exists() assert moved.resolve(strict=False)==second/'data' try:moved.resolve(strict=True) except FileNotFoundError:pass else:raise AssertionError('strict resolution unexpectedly found missing target') moved.unlink();assert (second/'renamed').read_text()=='second' print('dangling link exists as a link; permissive resolution can name a missing target; unlink removes only the link') safe=root/'safe';outside=root/'outside';safe.write_bytes(b'approved');outside.write_bytes(b'replaced') current=root/'current';current.symlink_to('safe');checked=current.resolve(strict=True) assert checked==safe opened=os.open(current,os.O_RDONLY) try: replacement=root/'next';replacement.symlink_to('outside');replacement.replace(current) assert current.read_bytes()==b'replaced' and os.read(opened,100)==b'approved' finally:os.close(opened) print('a checked path string can resolve differently later; an already opened descriptor keeps the prior object') try:read_no_follow(current) except OSError as error:assert error.errno==errno.ELOOP,error else:raise AssertionError('O_NOFOLLOW accepted a final-component symlink') alias=root/'parent-alias';alias.symlink_to(first.name,target_is_directory=True) assert read_no_follow(alias/'data')==b'first' print('O_NOFOLLOW rejects the final symlink but still allows symlinks in earlier path components') 第一个目录中有名为 data 的文件,以及目标文本仅为 data 的链接。通过链接读取,得到 first。lstat 检查链接本身,stat 则跟随链接检查目标,样本确认两者具有不同 inode 身份。这两个结果用于说明它们确实是两个对象,而不是同一对象的两种显示名称。 ...

八月 3, 2027 · 2 分钟 · XB Field Notes
把一台主机的目录挂载到别处的网络文件系统。

NFS 把远程目录通过网络共享出去

一个 NFS 导出,是服务器上的一个目录加上一份允许访问它的客户端名单;而描述它的那个文件,并不是真正对外服务的东西。/etc/exports 由 exportfs 读取,exportfs 把主导出表写进 /var/lib/nfs/etab,客户端发来 MOUNT 请求时,是 rpc.mountd 去查这张表。三个文件:一个由人编辑,另外两个才决定实际发生什么。 这层间接就是最常见的 NFS 事故的来源:导出列表看着没问题,客户端却被拒,因为文件变了而表没变。剩下的配置归结为两个更难的问题——服务器认为一个请求带着什么身份,以及一个回答“已写入”的回复能不能挺过服务器崩溃。 文件与表 /etc/exports 的一行是 <目录> <客户端>[(<选项>)] ...,所以 /srv/data 10.0.0.0/24(rw,sync,no_subtree_check,root_squash) 把某个目录以四个选项导出给一个网段,同一行后面还能再接一组客户端、带自己的一套选项。客户端可以写成主机名、通配符、CIDR 网段或 netgroup;# 之后到行尾是注释,行尾反斜杠表示续行。文件里的内容在 exportfs -ra 依据它重建表之前一律不生效,而 exportfs -v 打印的是真正被加载的那张表,所以它既能用来核对文件里的每一条,也能用来发现表里多出来的东西。 showmount -e 从客户端一侧展示同一张表:它问的是 mount 守护进程而不是文件,所以它回答的是“服务器认为自己导出了什么”,而不是“我刚才写了什么”。fixture 精确复现了这个陷阱:把 /srv/newshare 追加进导出文件却没有重新导出之后,文件里有四条,表里只有三条,客户端请求这个新共享得到的是 NOENT。 ...

六月 23, 2027 · 1 分钟 · XB Field Notes
根文件系统拒绝新建文件,指定临时目录和数据挂载仍然接受写入。

只读根在其范围内限制文件系统修改

只读根文件系统限制对服务打包代码和配置所在文件系统层的普通写入,明确挂载仍可提供运行数据的可写位置。这能减少意外修改和部分持久化机会,但边界是文件系统变更本身。它不会单独移除进程权限、阻止读取可访问数据,也不会禁止与其他服务通信,需要按实际作用范围理解其价值。 坏掉的样子 应用经常在主要数据目录之外写入临时文件、缓存、锁文件、生成配置或运行状态。只读根会让这些假设在启动或不常见操作中以 EROFS 暴露。只测健康检查端点,可能漏掉生成报表、刷新缓存或关停清理时才经过的写入路径。因此必须沿真实工作流程检查需求,不能把进程成功启动当成全部功能可用。 即使容器根层只读,可写挂载仍然可写,其中状态仍可能被故障或受损进程改坏。把大块可写目录覆盖到打包路径,也可能抵消这些路径原本需要的保护。应检查最终挂载布局,而不是根据一个根层选项推断整棵路径树的行为。路径看起来位于只读根之下,并不证明中间没有另一个可写挂载。 只读与保密也是不同属性。进程仍可能读取打包文件、执行已有程序,并通过允许的网络路径发送数据。该设置也不保证容器逃逸之后仍受相同保护,更不约束能够改变挂载安排的权限。应结合凭据、能力、隔离和应用行为理解它的贡献,避免把一项文件系统限制描述成完整安全边界。 快速验证 在一次性 Linux 容器内以 root 运行下面 Python 脚本。容器应使用只读根,在 /tmp 挂可写 tmpfs,在 /work 挂独立可写卷,镜像内预先安装 Python 3。实测使用 Linux 6.12.76 和 Python 3.11.2,没有宿主机绑定挂载、特权模式或外部网络: from pathlib import Path from tempfile import TemporaryDirectory import errno,os,socket,subprocess,sys,uuid assert os.geteuid()==0,'run inside a disposable read-only-root container as root' assert os.statvfs('/').f_flag&os.ST_RDONLY name='xbcatm-readonly-'+uuid.uuid4().hex for base in [Path('/'),Path('/usr'),Path('/etc')]: path=base/name try:fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600) except OSError as error:assert error.errno==errno.EROFS,error else: os.close(fd);path.unlink();raise AssertionError('root filesystem accepted a new file') print('even this root process receives EROFS on paths in the read-only root, usr and etc tree') for base in ['/tmp','/work']: assert not os.statvfs(base).f_flag&os.ST_RDONLY with TemporaryDirectory(prefix='xbcatm-write-',dir=base) as directory: path=Path(directory)/'data';path.write_bytes(b'allowed');assert path.read_bytes()==b'allowed' path.write_bytes(b'changed');assert path.read_bytes()==b'changed' print('explicit tmpfs and data-volume mounts remain writable, including overwriting their contents') assert Path('/etc/os-release').read_text() result=subprocess.run([sys.executable,'-c','print("executed")'],capture_output=True,text=True,check=True) assert result.stdout.strip()=='executed' with socket.socket() as listener,socket.socket() as client: listener.bind(('127.0.0.1',0));listener.listen(1);client.connect(listener.getsockname()) connection,_=listener.accept() with connection: client.sendall(b'local');assert connection.recv(5)==b'local' print('reading packaged files, executing an existing program, and loopback network I/O still work') 脚本先检查根文件系统报告的只读标志,再尝试在根、usr 和 etc 下排他创建唯一名字的文件。即使采用 root 身份,三次都得到 EROFS。唯一名称与排他创建保证探测不会覆盖既有配置文件。结果来自当前文件系统真正拒绝操作,而不是只看配置中出现了某个只读选项。 ...

十二月 23, 2026 · 1 分钟 · XB Field Notes