源 inode 同时带有内容字节与 user.review。文章样本中 copyfile 只复制内容,不带该属性;copy2 保留这个受支持属性。内容相同不能证明元数据相同。

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

扩展属性在普通文件内容流之外,为文件系统对象附加有名字的字节值。应用可以使用 user 命名空间存放小型元数据,其他命名空间则具有内核、安全模块或特权相关语义。它是否可靠,取决于文件系统、访问规则和传输工具。只有工作流程按明确契约保留并解释标签,元数据才能成为下游判断的有效依据。 多个名字可以共享 inode 及其属性。复制会产生独立对象,元数据是否保留取决于工具、目标端和权限;内容相同并不足以验证所需属性。 坏掉的样子 属性值不一定是文本,可以包含空字符或任意二进制数据。把它当作以零结尾的字符串,或者假定固定编码,可能破坏原值。空属性和缺失属性也不是同一状态;如果都返回空字符串,就会隐藏元数据到底被刻意设为空,还是在传输中丢失。使用前需要明确编码与缺失语义,而不是依赖显示工具碰巧如何呈现。 同一文件系统内重命名保留对象及属性,复制内容通常却创建另一个 inode。复制工具决定尝试保留哪些元数据,因此文件哈希相同不能证明扩展属性相同。流程可能成功传递每一个内容字节,却遗漏下游需要的分类、来源或应用版本标签。内容校验通过,只能证明它实际覆盖的内容范围。 命名空间名称不会自动建立应用授权规则。设置 user.classification 为 private,不会让本来可读的文件变得保密。user 属性服从适用的文件访问权限,安全属性则有另外的文档语义与策略。把应用标签当作已经强制执行的访问控制,可能在标签看起来完好的同时暴露内容,因此需要检查实际读写行为。 ...

十二月 14, 2027 · 1 分钟 · XB Field Notes
同一文件系统内,快照根与源根指向共享未改 extent,并在分岔后分别指向旧数据与新数据。

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

Btrfs 快照是带有源初始内容的新子卷,通过共享已有文件 extent 保留视图,而不是逐块复制全部数据。创建会复制根部元数据,也可能等待源中的脏数据刷出,因此不能承诺无论大小都在毫秒内完成。同一 extent 还可能同时被多个快照或 reflink 引用,不存在固定只能引用两次的规则。共享数据与完全没有创建成本,是不同的判断。 快照让本地恢复点较为经济,但成本包含元数据以及后续分岔保留下来的旧块。它与源共用整个文件系统的存储池,而文件系统可能跨多块设备,并不一定只在一块磁盘上。快照数量持续增加时,已删除或覆盖的数据仍可能被保留。即使设置为只读,同一文件系统内的快照也不是独立备份,不能把版本数量当作故障隔离程度。 共享 extent,而不是复制数据块 快照是有独立根的子卷。示例 btrfs subvolume snapshot -r "$src" "$snap" 请求只读快照,普通快照则可以写入。只读标志限制通过这个子卷进行的普通修改,不防管理员删除或底层损坏。快照不是递归操作:嵌套子卷在其中只是空的占位目录,其内部文件不会自动包含进来。恢复计划必须另行处理这些子卷,不能只检查顶层目录是否存在。 共享 extent 被修改时,写时复制为其余引用保留旧内容。实际分配受到 extent 布局、压缩、元数据和后续快照影响,不能简单等同于应用写了多少字节。原文中的 APFS 克隆数字不是 Btrfs 测量,本次也没有找到其原始轨迹;不能用它们证明创建零成本,或同一范围第二次覆盖时 Btrfs 一定不再分配。跨文件系统类比只能解释思路,不能替代本系统验证。 空间到底去了哪里 btrfs filesystem du 通过 FIEMAP 报告文件 extent 的 Total、Exclusive 和 Set shared。每个参数各自定义递归扫描的文件集合,Set shared 统计该集合引用的共享范围并处理重叠,并非只统计与其他命令行参数之间的共享。compsize 显示面向压缩的 Disk Usage、Uncompressed 与 Referenced,不是相同三列。qgroup 则要求先启用配额并保持统计一致,不能把缺失统计解释成零占用。 ...

十一月 14, 2027 · 1 分钟 · XB Field Notes
连续三次相同且未读的写入可以只产生一条 modify 通知。溢出信号表示事件历史已丢失;重扫恢复当前状态,不能补回缺失顺序。

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

inotify 通过一个带注册监视项和可读事件队列的实例,投递 Linux 文件系统通知。每条记录包含监视编号、事件掩码,也可能包含名字和移动 cookie。这能减少对未变化路径的反复检查,但事件流不是无限审计日志。正确消费者应把通知理解为相关状态可能需要重新检查的证据,而不能默认它保存了文件发生过的每一次操作。 通知是有边界的变化证据。覆盖范围、对象替换与队列丢失,都要求重新核对文件系统当前状态;重扫不能补回缺失的事件历史。 坏掉的样子 监视目录不会递归监视后代。它报告直接目录项上的相关活动,因此需要观察更深变化时,新子目录也需要独立监视。目录树监视器必须处理初次枚举和之后加入的目录。假定只注册根目录就覆盖整棵树,会悄悄漏掉更深层变化;能收到子目录被创建,并不代表能收到它内部之后的全部操作。 文件监视跟随具体对象,不会永久绑定路径。编辑器可以先写完并关闭临时文件,再用 rename 替换目标。父目录会报告替换,旧文件监视却不会转移到新 inode。如果消费者等待目标名字之后再出现 close-write,可能永远等不到,因为替代文件在发布前已经关闭。不能把一种编辑器保存顺序当成所有更新方式的固定流程。 连续产生且尚未读取的相同事件可以合并,队列满后会丢弃事件并报告 IN_Q_OVERFLOW。因此,事件数量不是可靠写入计数。溢出后,消费者无法仅靠剩余记录重建全部缺失操作,必须重新核对当前监视状态,不能继续假定根据事件维护的模型完整。历史丢失与当前状态仍可读取,是需要分别理解的两件事。 快速验证 把脚本保存为 inotify-events.py,在一次性 Linux 环境中运行 python3 inotify-events.py。此前记录使用 Linux 6.12.76 和 ext4;2026-10-15 的复核记录了两种语言样本在 Linux 6.18.44、Python 3.12.14 上通过。该记录属于历史执行,此次未重跑。样本创建 ASCII 名称的临时条目,并有意填满自己的队列。配置的队列上限必须不超过三万二千七百六十八;脚本只读取上限,不修改任何 sysctl: ...

十月 13, 2027 · 2 分钟 · XB Field Notes
文章 ext4 历史样本逻辑长度为十六 MiB,在四与十二 MiB 处有 A、B,分配的两个小区域合计八 KiB。空洞读为零;这些不是此次新运行的结果。

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

稀疏普通文件可以具有很大的逻辑长度,却只为其中部分地址范围分配存储。读取长度范围内的空洞会得到零字节,因此文件长度、字节内容和文件系统分配,是三项不同测量。为虚拟磁盘预算容量、复制备份,或者判断零区域是否已为将来写入保留空间时,都需要理解这种关系,不能用其中一个数字替代另外两个。 分别测量长度、字节与分配,再验证真实复制或预留空间的契约。 坏掉的样子 seek 越过文件结尾,只改变描述符偏移,不改变文件长度。后续写入或显式扩展,才可能创建中间读为零的范围。只调用 seek,就声称已经创建大型稀疏文件,是错误的;同样,逻辑大小很大,也不表示文件系统已经预留足够块,允许以后填满它。偏移位置、逻辑范围与实际容量承诺必须分开。 如果复制读取每个字节并普通写出,就可能把空洞变成分配了块的零区域。最终校验值完全相同,分配空间却大得多。传输或恢复流程若只依据源文件当前分配量做预算,即使正确保留逻辑内容,也可能耗尽目标容量。因此成功复制字节与保持稀疏表示,是需要分别验证的目标。 打洞是有意改变数据的操作。配合 KEEP_SIZE 对已分配范围打洞,会保持长度,却让对应字节读为零,并不是可以随手清理任意文件区域的无害命令。预分配解决另一类问题:它保留文件系统分配,即使未写入的已分配范围也可能读为零。内容都是零,不能证明这两种表示具有相同空间行为。 ...

十月 11, 2027 · 2 分钟 · XB Field Notes
source 与 alias 两个目录项共同指向一个 inode。通过任一名字原地写入,都会改变共享字节;两个名字都没有天然的原件地位。

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

硬链接为已有文件增加另一个目录项。两个名字都指向同一文件系统里的同一个 inode,其中没有哪个天然具有“原件”地位。内容、所有者、权限和链接数属于这个共享对象,文件名只是到达它的入口。因此,需要把目录项的变化与文件对象本身的变化分开理解,才能判断修改、删除和替换到底影响了哪些访问路径。 名字与打开引用的寿命不同。应跟踪 inode,并检查实际更新方式。 坏掉的样子 把硬链接当成独立备份,会产生错误的恢复边界。通过任意名字写入或截断共享 inode,另一名字读到的内容也会改变;修改模式位,同样改变的是一个对象的权限。第二个路径可以在第一个路径被删除后继续存在,却不能抵御针对共享文件的普通原地修改,也不会自动保留先前历史版本。 许多编辑器和部署工具采用另一种更新方式:先创建替代文件,再通过 rename 覆盖某个路径。该目录项此时指向新 inode,其他硬链接仍然到达旧对象。两个名字是否一起显示新内容,取决于实际更新方法。因此不能仅凭它们昨天建立过硬链接,就断言今天仍共享身份;应检查当前对象以及写入工具的真实行为。 硬链接还受文件系统挂载边界约束。即使两个目录在统一路径树里看起来相邻,link 也不能随意把不同挂载中的对象连接起来。Linux 还禁止普通目录硬链接。这些限制意味着,硬链接不能作为跨目录层次、跨挂载符号链接的通用替代品;使用前必须先确认目标对象和边界,而不是只看路径字符串。 ...

十月 10, 2027 · 2 分钟 · XB Field Notes
保存相对文本 data 的链接位于 first 时解析到 first/data,移入 second 后解析到 second/data;解析基准是链接所在目录。

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

符号链接是保存目标路径的文件系统对象。跟随链接时,系统按操作发生时的状态解析这段路径;链接并没有永久绑定目标 inode。这个区别解释了为什么符号链接可以跨文件系统、为什么会变成悬空链接,以及为什么保存的目标文本完全没变,最终访问对象却可能改变。理解链接对象与当前解析目标,是正确使用和诊断链接的基础。 符号链接保存的是路径。应说明解析基准、观察对象与遍历策略。 坏掉的样子 相对目标以符号链接所在目录为基准,而不是以调用进程的当前工作目录为基准。因此移动链接可能改变其含义;移动或重命名目标,则可能让链接悬空。部署过程如果调整目录结构,却没有核对这些解析基准,熟悉的链接就可能悄悄访问另一个文件。仅看 readlink 打印的文本没有变化,无法证明对象仍相同。 不同存在性检查回答不同问题。跟随链接的操作可能报告目标缺失,而检查目录项本身的操作仍能找到链接。同样,宽松路径解析函数可以在最终目标不存在时返回绝对字符串。规范化后的路径不是可用文件当前存在的证明,更不是文件身份的永久凭据;诊断必须说明自己检查了链接对象,还是跟随之后的目标。 当其他参与者能够修改相关目录项时,先检查路径再打开,会留下检查与使用之间的时间窗口。给路径加引号可以保护 shell 参数解析,却不能冻结文件系统解析。普通 open 的 O_NOFOLLOW 拒绝最后一个组件是符号链接,不会自动禁止前面目录组件中的链接。把参数安全、路径安全和对象稳定性混为一谈,会造成错误边界。 快速验证 把脚本保存为 symlinks.py,在一次性 Linux 环境中运行 python3 symlinks.py。实测使用 Linux 6.12.76 和 Python 3.11.2。脚本创建无害临时文件,并主动改变自己的链接,因此结果是确定性的,不需要并发攻击者,也不访问无关路径: ...

八月 3, 2027 · 2 分钟 · XB Field Notes
服务器目录通过访问策略关口连接两台客户端。

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

NFS 导出把服务器文件系统路径与客户端及访问策略关联起来。在 Linux 上,exportfs 根据 /etc/exports、/etc/exports.d 下合格文件或命令行更新,维护 /var/lib/nfs/etab。rpc.mountd 是守护进程,不是第三份配置文件;其 MOUNT 服务与旧版协议流程有关,NFSv4 挂载并不要求这个独立服务。应先明确版本和服务职责,再解释客户端的结果。 配置文本可能与有效导出不同,但这只是拒绝访问的一种原因,不能从文件看起来正确就断定导出表过期。还要核对目标服务器、协议、导出策略、身份及文件权限。服务器的持久性和客户端缓存也要分开:应用看到一次写入成功,并不单独证明服务器已把相应数据提交到稳定存储。目录可见、访问获准和写入可恢复,是不同层次的事实。 文件与表 /etc/exports 的条目把目录与客户端及选项关联。/srv/data 10.0.0.0/24(rw,sync,no_subtree_check,root_squash) 展示一个网络,后面还能追加有自己选项的客户端组。主机名、网段和 netgroup 有各自匹配规则。exportfs -ra 会协调配置与导出,但其他 exportfs 操作或服务管理也能应用改变;它不是唯一生效途径,不能把所有差异都归因于忘记运行这一条。 showmount -e 向服务器 MNT 服务询问公布的导出。信息完整性取决于实现,只有 NFSv4 的服务器甚至可能不暴露此服务。因此它既不是完整有效策略转储,也不证明某客户端一定能访问共享。原文四条配置对三条导出以及 NOENT 的故事没有找回原始运行记录;缺失导出也不能保证所有客户端总收到同一个错误码。诊断应保留实际返回信息。 选项真正决定了什么 导出默认只读,rw 才允许写入,但文件权限及其他控制仍然生效。sync 约束服务器按稳定存储要求回复,async 则允许改动提交前回复,崩溃可能丢失已确认的数据。两者都不会消除客户端缓存,也不让每次应用写入自动变成同步持久提交。nfs-utils 在 1.0.0 版本之后把默认从 async 改为 sync;理解版本历史时要与实际客户端操作分开。 ...

六月 23, 2027 · 2 分钟 · XB Field Notes
只读根树分叉到 /usr、/etc,以及明确可写的 /tmp、/work 挂载。

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

只读根文件系统限制对服务打包代码和配置所在文件系统层的普通写入,明确挂载仍可提供运行数据的可写位置。其边界是文件系统变更本身。它不会单独移除进程权限、阻止读取可访问数据,也不会禁止与其他服务通信,需要按实际作用范围理解其价值。 坏掉的样子 应用经常在主要数据目录之外写入临时文件、缓存、锁文件、生成配置或运行状态。只读根会让这些假设在启动或不常见操作中以 EROFS 暴露。只测健康检查端点,可能漏掉生成报表、刷新缓存或关停清理时才经过的写入路径。因此必须沿真实工作流程检查需求,不能把进程成功启动当成全部功能可用。 即使容器根层只读,可写挂载仍然可写,其中状态仍可能被故障或受损进程改坏。把大块可写目录覆盖到打包路径,也可能抵消这些路径原本需要的保护。应检查最终挂载布局,而不是根据一个根层选项推断整棵路径树的行为。路径看起来位于只读根之下,并不证明中间没有另一个可写挂载。 只读与保密也是不同属性。进程仍可能读取打包文件、执行已有程序,并通过允许的网络路径发送数据。该设置也不保证容器逃逸之后仍受相同保护,更不约束能够改变挂载安排的权限。应结合凭据、能力、隔离和应用行为理解它的贡献,避免把一项文件系统限制描述成完整安全边界。 只读根限制自身层的修改;可写挂载、读取、执行与本地通信仍需分别考虑。 快速验证 在一次性 Linux 容器内使用 root 身份,根层只读,/tmp 挂可写 tmpfs,/work 挂独立可写卷。镜像中必须已有 Python 3.7 或更新版本和 /etc/os-release,且断言保持启用。早期记录报告 Linux 6.12.76、Python 3.11.2,没有宿主机绑定挂载、特权模式或外部网络。这里没有复现该次运行;这些启动条件是前提,并非全部由脚本检查: from pathlib import Path from tempfile import mkdtemp import errno,os,socket,subprocess,sys,time,uuid def cleanup_steps(steps, primary): failures=[] for label, action in steps: try:action() except BaseException as error: failures.append(label+': '+type(error).__name__+': '+str(error)) if failures: message='cleanup incomplete or uncertain: '+'; '.join(failures) try:print(message,file=sys.stderr) except (OSError,ValueError):pass if primary is None:raise RuntimeError(message) return failures def remove_file(path): try:path.unlink() except FileNotFoundError:pass def require_empty(value): if value:raise RuntimeError('residual resources: '+str(value)) 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 print('fixture root-tree probe:',name,flush=True) for base in [Path('/'),Path('/usr'),Path('/etc')]: path=base/name fd=None try: 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:raise AssertionError('root filesystem accepted a new file') finally: primary=sys.exc_info()[1] if fd is not None: cleanup_steps([('probe descriptor',lambda:os.close(fd)),('unexpected root-tree file '+str(path),lambda:remove_file(path))],primary) 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 directory=None try: directory=Path(mkdtemp(prefix='xbcatm-write-',dir=base)) 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' finally: primary=sys.exc_info()[1] if directory is not None: cleanup_steps([('temporary data',lambda:remove_file(directory/'data')),('temporary directory',directory.rmdir)],primary) 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,timeout=5) assert result.stdout.strip()=='executed' def remaining(deadline): seconds=deadline-time.monotonic() if seconds<=0:raise TimeoutError('loopback exchange deadline expired') return seconds def receive_exact(connection,count,deadline): chunks=[];received=0 while received<count: connection.settimeout(remaining(deadline)) chunk=connection.recv(count-received) if not chunk:raise EOFError('loopback peer closed before the complete payload') chunks.append(chunk);received+=len(chunk) return b''.join(chunks) listener=client=connection=None try: deadline=time.monotonic()+5 listener=socket.socket();listener.settimeout(remaining(deadline)) listener.bind(('127.0.0.1',0));listener.listen(1) client=socket.socket();client.settimeout(remaining(deadline));client.connect(listener.getsockname()) listener.settimeout(remaining(deadline));connection,_=listener.accept() client.settimeout(remaining(deadline));client.sendall(b'local') assert receive_exact(connection,5,deadline)==b'local' finally: primary=sys.exc_info()[1] cleanup_steps([(label,sock.close) for label,sock in [('connection',connection),('client',client),('listener',listener)] if sock is not None],primary) print('reading packaged files, executing an existing program, and loopback network I/O still work') 脚本先检查根文件系统报告的只读标志,再尝试在根、usr 和 etc 下排他创建唯一名字的文件。即使采用 root 身份,三次都得到 EROFS。唯一名称与排他创建保证探测不会覆盖既有配置文件。结果来自当前文件系统真正拒绝操作,而不是只看配置中出现了某个只读选项。 ...

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