diff --git a/.agents/docs/2026-10-05-subos-architecture-design.md b/.agents/docs/2026-10-05-subos-architecture-design.md new file mode 100644 index 000000000..46a75ed32 --- /dev/null +++ b/.agents/docs/2026-10-05-subos-architecture-design.md @@ -0,0 +1,838 @@ +# SubOS 总体架构设计:部署与运行、控制与配置、使用面、隔离、可观测性、模块化 + +- 起因:#640(SubOS 隐私隔离)及其评论(Ubuntu 24.04 bwrap 探针失败的根因) +- 基线:`v2026.10.4.1` +- 整合并取代以下设计中与 SubOS 相关的部分: + - `docs/design/subos-isolation.md` + - `old/agents-docs/sandbox-cross-platform-design.md`(L0–L6) + - `old/docs/plans/2026-05-05-subos-tiered-mode-architecture.md`(light / medium / heavy / full) + - `old/agents-docs/sandbox-v5-dual-backend-design.md`、`sandbox-v6-storage-isolation-design.md` + - `subos-as-xpkg-design-2026-05-16.md`(`--from`、keeper、D8 一次性实例) + - `2026-05-22-subos-sandbox-gpu-passthrough.md` + - 相关:`2026-09-06-issue-583-relocation-design.md`、`src/core/home_identity.cppm`、`src/core/uimode.cppm` +- 状态:设计草案第七轮,一份完整方案,不拆分。附录 B 是自我 review(R1–R16 是第六轮的,R17 起是第七轮的)。本文不含代码改动 + +> **隐私规则**:本文所有实测只记录计数、是/否和变量**名**,不记录任何值。issue、PR、CI 日志、doctor 输出、审计日志的默认脱敏都按这个规则。 + +--- + +## 0. 一句话与文档地图 + +**核心功能与交互面分离;控制完全由声明驱动;用户面多平台一致,技术面可以替换。** + +| 问题 | 唯一回答者 | 章节 | +|---|---|---| +| xlings 装在哪、谁拥有、怎么更新 | `.xlings-home` 标记 | §3–§4 | +| 这次运行是谁、用哪些 home、作用在哪 | xlings 核心的 `HomeContext` | §5 | +| 允许做什么(获取、授权、网络……) | **策略**:预设 → 覆盖 → 规则 → 策略包;由 `policy.decide()` 判定 | §7–§9 | +| 人和 agent 怎样使用 | 交互面:受众契约(human / agent)× 渲染(cli / tui / json / ndjson) | §12–§13 | +| 实际给了什么隔离 | `Policy + Caps → SandboxSpec`,由 provider 执行,由 supervisor 托管 | §15–§21 | +| 发生了什么 | 统一事件模型;审计由 supervisor 写到沙箱够不到的地方 | §22 | + +``` +Part I 现状 ............ §1 实测 §2 缺口 +Part II 部署与运行 ...... §3 概念 §4 部署形态 §5 运行形态与层叠 home §6 目录规范 +Part III 控制与配置 ...... §7 策略模型 §8 SubOS 内的 xlings §9 权限执行 +Part IV 使用面 .......... §10 隔离的渐进式披露 §11 目录映射 §12 进入与外部执行 §13 人与 agent 两套语义 §14 平台一致性 +Part V 技术架构 ........ §15 轴 §16 编译器与会话模型 §17 平台抽象层 §18 能力探测 §19 网络与身份 §20 特权组件与 rootfs §21 性能与能力不缺失 +Part VI 可观测性 ........ §22 +Part VII 代码架构 ........ §23 +Part VIII 质量与交付 ...... §24 测试、CI 与开发工具(testkit / xdev) §25 稳定性规范 §26 交付 checkpoint §27 决策 +附录 A 其他平台的技术路线参考  附录 B 自我 review +``` + +--- + +# Part I 现状 + +## 1. 实测(在真实的 xlings bwrap 沙箱里) + +环境:Ubuntu 24.04、kernel 6.8、bwrap 0.11.2(xim 包)。测量直接在一个真实的 `subos use --sandbox bwrap` 会话里进行,并按 `build_bwrap_argv_()` 嵌套复现了一次,结论一致。 + +| # | 维度 | 实测 | 根因 | 严重度 | +|---|---|---|---|---| +| F1 | **宿主代码执行面可写** | `config/shell/xlings-profile.sh`、`bin/xlings`、`data/xpkgs/`、`.xlings.json` 全部可写 | `$XLINGS_HOME` 以 `--bind`(读写)挂入 | **S0 逃逸** | +| F2 | **X11** | 抽象 socket 握手返回 Success(完全访问):可截屏、记录键盘、注入输入 | 没有 `--unshare-net`;加上桌面默认的 `si:localuser` 授权 | **S0 隐私** | +| F3 | 环境变量 | 继承了 103 个,其中有 `*_TOKEN`、`SSH_AUTH_SOCK`、`DBUS_SESSION_BUS_ADDRESS`、`XAUTHORITY` | 没有 clearenv | S1 | +| F4 | 进程 | 宿主约 830 个进程可见,374 个的 `cmdline` 可读(`/proc//root` 被拒是 userns 的副作用) | 没有 `--unshare-pid` | S1 | +| F5 | 网络身份 | 出口、DNS、IPv6 与宿主相同;能看到 6 条 ARP 邻居(网关 MAC 可以作为位置指纹) | 没有 `--unshare-net` | S1 | +| F6 | 跨实例 | 能读写其他 22 个 SubOS 的 `home/` | 同 F1 | S1 | +| F7 | 主机标识 | hostname、localtime、TZ、LANG 都继承宿主 | 没有 `--unshare-uts` | S2 | +| F8 | 终端注入 | 没有 `--new-session`,也没有 seccomp;本机 `legacy_tiocsti=0` 挡住了 | — | S2 | +| F9 | D-Bus / 设备 | 按路径都不可达,最小 /dev,`CapEff=0`(**issue 这部分判断正确**) | — | 已隔离 | +| F10 | backend | recipe 和文档都写 setuid,实测属主是用户、0755,setuid 静默失败,所以撞上 AppArmor | install hook 里执行 sudo | 文档与事实不符 | +| F11 | keeper | `--keep` / `--ttl` 没有任何作用;原设计只 `nsenter --mount` | — | 潜在旁路 | +| F12 | 探针文案 | 原因判断错误,并建议全局 `sysctl …=0` | `classify_bwrap_probe_error_` | 误导 | +| F13 | CI | 拿不到 backend 时 e2e 直接 skip | — | 从来没有被正向验证 | +| F14 | AUR 包 | 停在 0.4.14;`/usr/share/xlings/.xlings.json` 是 766 | 系统安装形态没有定义 | 安全隐患 | +| F15 | 可观测性 | 进入沙箱用 `execvp` 替换了 xlings 进程,之后没有任何进程在沙箱外观察它;仅有的记录都在沙箱可写的范围内 | 没有 supervisor | 不可审查 | +| F16 | 外部执行 | 只有 `subos use --cmd ''`:需要 shell 转义,每次都冷启动;interface 里**没有**在 SubOS 中执行命令的 capability | — | 不方便 agent 使用 | + +## 2. 架构缺口 + +部署形态没有定义;主体和权限没有定义;隔离模型混杂;没有能力探测;没有可观测性;**没有面向 agent 的执行面**;**交互语义(人 / agent)没有正式的契约**;模块之间双向依赖;平台差异没有抽象层。 + +--- + +# Part II 部署与运行(已确认) + +## 3. 核心概念 + +| 概念 | 定义 | +|---|---| +| Entry | xlings 可执行文件;所有 shim 都指向它 | +| Home | 数据根(payload 池、索引、配置、实例),由 `.xlings-home` 声明 | +| Layer | home 在层叠关系中的位置,每层只有一个所有者 | +| Principal | 这次运行的主体:某一层的 owner,或者某个实例 | +| Scope | 激活和配置作用在哪:SubOS / 项目 | +| Instance | 具名环境:工具视图 + 策略 + 可选的 rootfs | +| **Policy** | 实例的控制声明(§7) | +| **Session** | 一个实例当前正在运行的环境(一组 namespace + 一个 supervisor);多次进入和外部执行可以共享同一个会话(§16) | +| **Supervisor** | 宿主侧托管会话的进程:负责 broker、审计、网络桥接、清理 | +| **Broker** | supervisor 上的服务:替沙箱里的 xlings 执行它无权执行的操作 | +| **Surface(交互面)** | CLI(human / agent)、`--json`、interface(NDJSON)、GUI;核心功能与交互面分离(§13) | + +## 4. 部署形态 + +| 形态 | entry | home | 更新渠道 | +|---|---|---|---| +| **U 用户模式(默认)** | `~/.xlings/bin/xlings` | `~/.xlings` | `self update` | +| **C 自定义目录** | `/bin/xlings` | 任意目录(路径里可以不出现登录名) | `self update` | +| **P 自包含 / 便携** | `/bin/xlings` | entry 所在的目录树 | `self update`;移动后 `self relocate` | +| **S 系统安装** | `/usr/bin/xlings` | 每个用户仍是 U 或 C;系统只提供 root 拥有的组件 | 系统包管理器 | +| **M 系统包管理器(多用户)** | 同 S | S + root 拥有的系统层 `/opt/xlings` | 同 S;管理员 `sudo xlings install --system` | + +- 形态写在 `.xlings-home` 里:`{ "layout": 2, "mode": "user|portable|system-layer", "upper": "..." }`。推断只作为老 home 的迁移路径。 +- 系统组件(S / M,或 U / C 用户执行 `self doctor --isolation --fix` 时安装): + +| 组件 | Linux | macOS | Windows | +|---|---|---|---| +| 特权辅助程序 | `/usr/lib/xlings/bwrap` | Seatbelt profile 目录 | AppContainer 辅助程序 | +| 安全策略 | `/etc/apparmor.d/xlings-bwrap` | — | — | +| 系统配置 | `/etc/xlings/config.json` | `/etc/xlings/` | `%ProgramData%\xlings\` | +| 系统层(M) | `/opt/xlings` | `/opt/xlings` | `%ProgramData%\xlings\home` | + +- S / M 下 `self update` 不碰 root 拥有的 entry,只提示系统包管理器的命令;用户可以用 `self install --user` 装一个用户层的 entry;`self doctor` 报告实际生效的 entry。 + +## 5. 运行形态与层叠 home + +- **HomeContext**(属于 xlings 核心):保留现有的解析顺序,结果收敛为 `{ entry, mode, layers[], principal, scope }`,所有读者都从它取值。SubOS 模块只接收纯数据视图 `HomeView`。 +- 沙箱里的 `principal = instance` 只用于给出友好提示;**安全判定在 broker(宿主侧)和内核**。 +- **层叠 home**:系统层(root)→ 用户层(用户)→ 实例私有层(实例)。三条规则:读和激活可以跨层;获取只写自己的层,没有可写层的交给 broker;上层永远不引用下层。 +- 项目模式是作用域,与部署形态正交;一个项目可以绑定一个实例。 + +## 6. 目录规范 + +### 6.1 用户层 home + +| 路径 | 唯一写者 | 类别 | 沙箱内可见性 | +|---|---|---|---| +| `.xlings-home` | `self init` / migrate | 派生 | 只读 | +| `.xlings.json` | config | 用户配置 | 只读 | +| `bin/xlings` | `entry_binary` | 派生 | 只读(始终挂入) | +| `config/shell/` | `self init` | 派生 | 只读 | +| **`config/subos//policy.json`(新)** | **owner(subos 模块)** | **声明** | **只读**(沙箱能读到自己的策略,但改不了) | +| `data/xpkgs/`、`data/xim-*`、`data/runtimedir/` | owner 侧的 xim / broker | 派生 | 只读 | +| `state/`(新:caps 缓存、会话登记) | 各模块 | 派生 | 不可见 | +| `logs/`(hooks、destructive、ops) | 日志模块 | 审计 | 不可见 | +| `logs/subos//`(新) | **supervisor** | 审计 | **不可见** | +| `subos//` | subos | 见 6.2 | 只挂入自己 | + +**策略放在实例目录之外**(`config/subos//policy.json`)。这回答了上一轮留下的问题:实例的 `.xlings.json`(`workspace` / `configured` / `storage`)在沙箱里保持可写,版本切换照常在本地执行,不需要经过 broker;策略文件位于只读的 `config/` 下,沙箱从结构上就改不了它。fork(`--from`)、导出、删除时,subos 模块会一起处理这两个文件。 + +### 6.2 实例目录 + +| 路径 | 唯一写者 | 类别 | +|---|---|---| +| `.xlings.json` | subos manifest(沙箱内可写) | 声明(工作区) | +| `bin/`、`lib/`、`usr/`、`.shim-view/`、`generations/` | xvm / installer | 派生 | +| `home//`、`home.img`、`tmp/` | 实例内的进程 | **用户数据** | +| `etc/` | spec 编译器 | 派生 | +| `sandbox-root/`、`subos/`(空标记) | sandbox 初始化 | 派生 | +| `.xlings-layer/`(新,`fetch=layer`) | 实例内的 xlings | 派生 | +| `rootfs/`(新) | 实例内的包管理器 | **用户数据** | +| `run/`(新:`broker.sock`、`exec.sock`) | supervisor | 派生 | +| `logs/`(新:沙箱内的调试日志) | 实例内的 xlings | 派生(**不作为审计依据**) | + +### 6.3 规范 + +每个文件只有一个写者,上表就是清单;数据类别沿用 AGENTS.md "SubOS user data";**审计与被审计的对象物理分离**;**策略与被约束的对象物理分离**。 + +--- + +# Part III 控制与配置 + +## 7. 策略模型:预设 → 覆盖 → 规则 → 策略包 + +### 7.1 四个层次(与使用上的分级一一对应) + +| 层次 | 给谁 | 写法 | 例子 | +|---|---|---|---| +| ① 预设 | 所有人 | 一个词 | `--sandbox=private` | +| ② 覆盖 | 常用调整 | 命令行参数或 `subos config` | `--fetch ask --net proxy=… --allow gpu` | +| ③ 规则 | 细粒度控制 | 策略文件里的有序规则 | 按包、按索引、按大小、配额、超时 | +| ④ 策略包 | 组织、团队、复用 | **xpkg** | `--sandbox=xim:policy-ci-strict@1` | + +内置的 `dev` / `private` / `locked` **在概念上就是内置的策略包**,所以四个层次是同一套数据模型,只是来源不同。 + +### 7.2 策略文件的结构 + +```jsonc +// /config/subos//policy.json +{ + "extends": "private", // 内置预设或策略包:xim:policy-ci-strict@1 + "resolved": { "from": "xim:policy-ci-strict@1.2.0", "sha256": "…" }, // 锁定:包更新时不会悄悄改变策略 + "isolation": { "net": "proxy", "proxy": "socks5h://127.0.0.1:1080", + "identity": { "tz": "UTC" }, "grants_allowed": ["gpu", "display"] }, + "mounts": [ { "src": "~/proj", "dst": "/work", "mode": "rw" } ], + "permissions": { + "fetch": { + "default": "ask", + "rules": [ // 有序,第一条匹配的生效 + { "match": "xim:*", "index": "official", "action": "auto" }, + { "match": "*", "size_gt": "2GB", "action": "deny" }, + { "match": "community:*", "action": "ask" } + ], + "quota": { "requests_per_session": 50, "bytes": "20GB" }, + "ask": { "timeout": "10m", "on_timeout": "deny" } + }, + "index_update": "auto", // auto | ask | deny + "exec_from_outside": "owner" // 谁可以在外部往这个实例里执行命令(§12) + }, + "observe": { "level": "standard", "retention": "30d", "redact": true } +} +``` + +### 7.3 规则 + +- **合并顺序**:内置默认值 → `extends` 链 → 本文件 → 单次调用。**单次调用只能收紧,或者在 `grants_allowed` 列出的范围内授权**,不能放宽。 +- **谁能修改**:只有 owner。每次修改都写一条 `lifecycle` 审计事件,附带修改前后的差异。 +- **策略包**: + - 是一种 xpkg 类型(`type = "subos-policy"`); + - 由 owner 选择,选择时显示**与内置预设相比放宽了哪些项**; + - 安装后锁定版本和 sha256,升级要显式执行 `subos config policy upgrade`; + - S / M 形态下,系统配置可以限制允许的策略包来源。 + + 环境模板(`--from subos:xxx`,已有的 subos-as-xpkg)可以携带一份策略,规则相同。 +- **校验(fail closed)**:策略里出现**本版本不认识的、与安全相关的字段**(例如 `net: "vpn"`)时,**拒绝进入**,而不是忽略。这与 manifest 写入时"保留不认识的键"不矛盾:写入时保留,是为了不破坏数据;执行时拒绝,是为了不悄悄放宽。 + +## 8. SubOS 内的 xlings:一定存在,按权限提供全部功能(已确认) + +### 8.1 操作分类 + +| 类别 | 例子 | 沙箱内的执行方式 | +|---|---|---| +| 只读查询 | `list` / `info` / `search` / `which` / `status` / `self doctor` | 始终本地执行 | +| 作用域内的状态 | `use`(版本切换)、激活已有 payload、`remove`(只从本实例摘掉)、实例 `.xlings.json` | 始终本地执行 | +| 获取 payload、同步索引 | `install` 一个还没装的包、`update` | 按 `permissions.fetch` / `index_update` 的规则 | +| home 级操作、其他实例、策略 | `self update`、xlings 自身版本、全局配置、`subos new/remove`、`subos config` | 只有 owner 能做:返回 `E_PERMISSION` 并给出宿主侧命令 | + +### 8.2 获取动作 + +| 动作 | 行为 | 写到哪里 | 默认用于 | +|---|---|---|---| +| `auto` | broker 自动批准:校验 sha256;hook 在受限的 hook 沙箱里运行 | 用户层(共享) | dev | +| `ask` | 生成待审批请求(human:等待并提示;agent:见 §13.3) | 用户层 | private | +| `layer` | 沙箱里的 xlings 自己获取 | 实例私有层 | rootfs | +| `deny` | `E_PERMISSION` + `xlings install X --subos ` | — | locked | + +审批只走沙箱无法伪造的通道(沙箱外的命令或 interface 事件),不在沙箱共享的终端里提示。broker 复用 interface 的 NDJSON 协议,通过 `run/broker.sock` 通信。 + +## 9. 权限执行:三层,一个判定函数 + +1. **客户端**:沙箱里的 xlings 读取只读的 `policy.json`,调用 `policy.decide(op)` 提前给出友好提示。 +2. **broker**:在宿主侧对同一个请求**用同一个 `policy.decide(op)` 再判定一次**,这是可信的判定。 +3. **内核**:只读挂载 / Landlock 兜底。macOS / Windows 目前没有这一层,标为 `advisory`。 + +新增 `xlings install ... --subos `(与 `remove --subos` 对称),以及 `subos requests / approve / deny`。**实现前必须先盘点**沙箱里激活时实际会写哪些文件(inotify e2e),用来核对 §6 的写者表。 + +--- + +# Part IV 使用面 + +## 10. 隔离的渐进式披露(已确认) + +| 层 | 用户需要了解的 | +|---|---| +| 0 | 什么都不用了解:`subos new work && subos use work` | +| 1 | `--sandbox[=dev\|private\|locked]`,默认 dev | +| 2 | `--mount`、`--allow`、`--net`、`--fetch` | +| 3 | `subos config` 设置细项 / 编辑 policy.json 的规则 | +| 4 | 策略包、`--rootfs`、`--storage` | + +| 档位 | 一句话 | fetch | net | 观测 | +|---|---|---|---|---| +| `dev` | 看不到宿主文件和其他 SubOS,不能篡改 xlings,网络照常;做不到的项只提示 | auto | host | basic | +| `private` | + 网络、桌面、身份隔离;必须项做不到就不进入 | ask | nat / proxy | standard | +| `locked` | + 断网,映射默认只读 | deny | none | full | + +两种提示:`✗ 无法进入:缺少必须项…`,`! 已进入:X 未生效…`;CI 用 `--no-degrade`。安全默认落在实例上:声明过的实例以任何方式进入都执行策略。命令行兼容:`--sandbox=<档位>` 没有歧义;有歧义时报错。 + +## 11. 目录映射(已确认) + +`--mount <宿主>[:<沙箱内>][:ro|rw]`,与 docker `-v` 一致;省略目标时映射到相同路径(第二段恰好是 `ro` 或 `rw` 时按模式解析,例如 `~/.gitconfig:ro`);默认 rw,locked 下默认 ro;宿主路径不存在就报错;不能覆盖系统路径;不能映射 `$XLINGS_HOME` 或它的上层目录;rw 映射标为"宿主代码执行面"。各平台:bwrap 用 bind;Landlock 用访问规则 + 链接;macOS / Windows 用链接或 junction(advisory)。 + +## 12. 进入与外部执行 + +### 12.1 两种使用方式,一个会话模型 + +| 方式 | 命令 | 适合 | +|---|---|---| +| **进入**(交互) | `xlings subos use ` | 人在里面工作 | +| **外部执行**(一条命令) | `xlings subos exec [选项] -- ` | 脚本、agent、CI | +| **启动 / 停止会话** | `xlings subos start [--ttl 30m]` / `subos stop ` | agent 需要连续执行很多条命令时(热会话) | +| **一次性实例** | `xlings subos exec --temp [--from ] [--sandbox=locked] -- ` | agent 试跑、测试;结束后自动删除,**审计和报告保留** | +| 传文件 | `xlings subos cp :` / `: ` | 不想设置映射时 | +| 端口转发 | `--publish 8080:80`(nat / none 模式) | 在隔离网络里测试服务 | + +- **同一个实例同一时刻只有一个会话**。`use` 和 `exec` 在会话已经运行时都**加入**这个会话(共享 /tmp、进程空间、网络),没有运行时就新建。所以人在一个终端里 `use`,agent 在另一处 `exec`,看到的是**同一个环境**。 +- 单次调用如果改变了 spec(例如运行中的会话是 `nat`,这次要求 `--net none`),不能加入,而是**另开一个临时会话**并说明原因。不会悄悄地以不一致的隔离执行。 +- `exec` 使用 **argv 形式**(`--` 之后原样传递),不需要 shell 转义;`--cmd '<字符串>'` 保留为兼容写法(等价于 `-- sh -c '<字符串>'`)。 +- `exec` 的选项:`--cwd`、`--env K=V`(只能在策略的 `env_pass` 范围内)、`--mount`(单次)、`--timeout`、`--stdin`、`--json`(结束时输出结构化结果)。 + +### 12.2 退出码(参考 docker / timeout 的约定) + +| 退出码 | 含义 | +|---|---| +| 命令自己的退出码 | 命令已经启动并结束 | +| 125 | **命令启动之前**失败(隔离必须项不满足、实例不存在、策略校验失败……);`--json` 里有 `phase: "setup"` 和具体的 code | +| 126 / 127 | 命令无法执行 / 找不到 | +| 124 | `--timeout` 超时 | +| 128 + n | 命令被信号 n 终止 | + +非 exec 命令的退出码沿用现有规则:`E_PERMISSION` 用 13,等待审批用 75(EX_TEMPFAIL),在 §13 的契约里统一列出。 + +### 12.3 agent 的典型用法 + +```bash +xlings --agent subos exec --temp --from subos:py-ds@1 --sandbox=locked --json -- pytest -q +# 或者需要连续多步时: +xlings --agent subos start t1 --sandbox=private --ttl 20m +xlings --agent subos exec t1 -- pip install -e . # 获取由 fetch 规则管 +xlings --agent subos exec t1 --json -- pytest -q +xlings --agent subos report t1 --json # 行为报告 +xlings --agent subos stop t1 +``` + +interface 新增 capability:`subos_exec`(流式输出 stdout / stderr 片段和最终结果)、`subos_start` / `subos_stop`、`subos_status`、`subos_events`、`subos_requests` / `subos_approve`、`subos_report`。 + +## 13. 人与 agent:两套交互语义 + +### 13.1 两个正交的轴 + +- **受众(语义契约)**:`human` | `agent`。决定问不问、怎么报错、文案稳定性、等待时的行为。 +- **渲染**:`cli` | `tui`(`uimode` 已有),加上 `--json`(单条命令的结构化结果)、`interface`(NDJSON 会话)。 + +现有的 `uimode` 刻意只有两种渲染模式,并写明"交互方式是配置,不是模式"。本方案沿着这个方向,把**受众**也做成一项声明配置,而不是新增一种模式。 + +### 13.2 怎样声明(不推断) + +| 方式 | 作用范围 | +|---|---| +| `--agent`(已有) | 单条命令 | +| `XLINGS_AGENT_MODE=1`(新增) | 整个进程树。**会被允许传入沙箱**,所以 agent 在 SubOS 里调用的 xlings 也遵守 agent 契约 | +| `--ui-mode` / `--json` / `interface` | 渲染,与受众正交 | + +不根据 tty 推断受众(`uimode` 的注释已经说明:有 pty 不代表有人会按键)。没有声明时就是 `human`。 + +### 13.3 契约 + +| 方面 | human | agent | +|---|---|---| +| 确认(有默认值) | 有终端就问;`-y` 跳过 | **从不提问**:没有 `-y` / `"yes": true` 就不执行,退出码 2,并说明需要什么 | +| 选择(没有默认值) | 交互式选择 | 报错 + **结构化候选列表** | +| 等待审批(`fetch=ask`) | 提示并等待(有超时) | 默认**不阻塞**:返回请求 id,退出码 75;`--wait <时长>` 才阻塞 | +| 错误 | 解释 + 建议,本地化 | 稳定的英文文案 + **错误码** + 机器可读的 `fix`(命令数组) | +| 降级提示 | `! X 未生效 …` | 结构化的 `missing[]{dimension, reason, fix}` | +| 进度 | 进度条 / TUI | 没有重绘;需要时输出 NDJSON 事件 | +| 帮助 | `--help` | `--help --json`(已有 `help_json`)、`xlings agent skills`(已有) | +| 退出码 | 同一张表 | 同一张表(§12.2),写进文档并加测试固定下来 | + +### 13.4 核心与交互面分离(适用于整个 xlings,不只是 SubOS) + +- **核心只产出结果和事件**(`EventStream` 已经存在),**不提问、不判断受众、不拼文案**。 +- **提问通过端口**:核心需要确认时调用交互面提供的 `Asker`(已有 `confirm::ask()` 两层提问的设计,迁到 `modules/guard`)。human 交互面问用户;agent 交互面按契约直接给出答案(有 `-y` 就是 yes,否则拒绝)。 +- 行为上的差异(例如审批时阻塞还是不阻塞)由交互面以**显式参数**传给核心(`wait: none|until(t)`),核心里**不出现** `if (agent)` 这种分支。 +- 文案和 i18n 只在交互面里;错误码、`fix` 和事件 schema 是核心的契约,两种受众共用。 +- 这条规则在 SubOS 模块里已经落地(§23:模块只返回结构化诊断),并逐步推广到 xim / xvm / xself(不在本 PR 的范围内,作为方向写进 AGENTS.md)。 + +## 14. 平台一致性(已确认) + +✓ 内核强制 ◐ 部分支持或仅提示 ○ 暂未实现(计划中,路线见附录 A) — 不适用 + +| 功能 | Linux | macOS | Windows | +|---|---|---|---| +| 切换工具链、SubOS 内 xlings 全功能、进入 / exec / start / temp / cp | ✓ | ✓ | ✓ | +| 私有 home / tmp | ✓ | ◐ 环境变量 | ◐ 环境变量 | +| 宿主文件不可见 | ✓ | ○ Seatbelt | ○ AppContainer | +| xlings 写保护 / 权限 | ✓ | ◐ 客户端 + broker | ◐ 客户端 + broker | +| 目录映射 | ✓ | ◐ 链接 | ◐ junction | +| 进程隔离 / 会话内进程管理 | ✓ | ◐ 进程组 | ◐ Job Object | +| 网络:断网 / 只走代理 | ✓ | ○ Seatbelt(断网) | ○ AppContainer(断网) | +| 桌面、设备控制 | ✓ | ○ | ○ | +| 身份 | ✓ | ◐ 环境变量 | ◐ 环境变量 | +| 观测:ops / lifecycle / perm / fs | ✓ | ✓ | ✓ | +| 观测:exec | ✓ seccomp | ○ | ○ Job Object 通知 | +| 观测:net | ✓(nat / proxy) | ○ | ○ | +| 独立 rootfs | ○ bwrap | ○ VM | ○ WSL2 | +| 部署形态 U / C / P | ✓ | ✓ | ✓ | +| 部署形态 S / M | ○ | ○ | ○ | + +提示规范:**暂未实现**(`—— <平台>上暂未实现(计划中)` + 能换用的做法)/ **环境限制**(原因 + 修复命令)/ **权限**(`E_PERMISSION` + 宿主侧命令或待审批 id)/ **用户配置**(只在 `status` 里列出)。由同一个函数判定,交互输出、`--json`、interface 共用。 + +--- + +# Part V 技术架构 + +## 15. 三条轴 + 一个输出 + +| 轴 | 取值 | 对应已有设计 | +|---|---|---| +| 隔离档 | 无 / dev / private / locked + 覆盖 + 规则 | 取代 light / medium | +| 系统根 | host / rootfs | 四档设计里的 full;heavy 不再单独做 | +| 存储 | shared / tmpfs / image | 已有;修正"image = L3" | +| 输出:实际达到的等级 | L1–L6 | 只出现在技术报告里 | + +## 16. 编译器与会话模型 + +``` +Policy + HomeView + Caps → SandboxSpec(纯数据)→ Provider(平台抽象层,§17)→ Session(由 Supervisor 托管) +``` + +- **spec**:挂载、unshare、env 白名单、设备、身份、网络、Landlock 规则、seccomp(隔离用的规则 + 可选的 exec 通知)。单元测试只测 spec;探针 = 同一份 spec + `/bin/true`。 +- **`$XLINGS_HOME` 的挂载拓扑**:`--ro-bind $H $H ; --tmpfs $H/subos ; --bind $H/subos/ … ; --tmpfs $H/logs ; --tmpfs $H/state`。`config/`(含 policy)保持只读可见。 +- **会话的结构**: + +``` +宿主侧:supervisor(xlings)── broker.sock / exec.sock ── 审计写入 / 网络桥接 / seccomp 监听 fd + │ fork + 托管(不再 execvp) +沙箱内:session-init(xlings 的一个内部模式,作为沙箱里的第一个进程) + ├── 用户的交互 shell(use) + └── exec 请求派生出的进程(exec / 再次 use) +``` + +- **加入会话**:后来的 `use` / `exec` 连接 `exec.sock`,**通过 `SCM_RIGHTS` 把自己的 stdin / stdout / stderr(或终端)fd 直接交给 session-init**,由它在沙箱里派生进程。进程直接拿到真实的终端,**不经过任何中继**(零拷贝,作业控制和窗口大小都正常)。退出码通过 socket 返回。 +- **信号**:supervisor 在等待期间忽略 SIGINT / SIGQUIT(与 `system()` 的做法相同,Ctrl-C 直接送给前台进程组),把 SIGTERM / SIGHUP 转发进会话,退出码按 §12.2 返回。 +- **终端注入**:非交互(`exec`)时加 `--new-session`;交互时 `--new-session` 会破坏作业控制,所以改用 seccomp 按参数过滤 `ioctl(TIOCSTI / TIOCLINUX)`。 +- **keeper** 被会话模型取代:`--keep` / `--ttl` / `start --ttl` 的含义是"会话空闲多久后结束"。 +- **hook 沙箱**:broker 代为安装时,recipe 的 hook 在同一个编译器生成的受限 spec 里运行。 +- **没有沙箱的实例**(shell 模式):没有 supervisor;`exec` 直接执行;`ops` 事件由 xlings 自己写入。 + +## 17. 平台抽象层 + +`modules/subos` 里定义与平台无关的接口;每个平台提供实现,或者明确返回"暂未实现": + +| 接口 | 职责 | Linux(现在) | macOS / Windows(现在 → 路线见附录 A) | +|---|---|---|---| +| `FsGate` | 宿主文件不可见、写保护、映射 | bwrap mount ns / Landlock / proot | home-redirect + 链接 → Seatbelt / AppContainer | +| `ProcessScope` | 会话内的进程分组、清理、资源限制 | pid ns;以后加 cgroup | 进程组 → Job Object | +| `NetGate` | host / nat / none / proxy | netns + pasta + 内置转发器 | 未实现 → Seatbelt / AppContainer 断网;proxy 走系统级过滤 | +| `DeviceGate` | 显示、音频、摄像头、GPU | /dev 白名单 + socket bind | 未实现 → Seatbelt 设备操作 / AppContainer capability | +| `IdentityShim` | 时区、语言、主机名、用户 | uts ns + passwd 模板 + env | env 级 | +| `ExecTracer` | exec 审计 | seccomp 用户态通知 | 未实现 → Windows Job Object 新进程通知;macOS Endpoint Security | +| `SessionHost` | supervisor 的托管、socket、fd 传递 | fork + unix socket + SCM_RIGHTS | Windows:CreateProcess + Job Object + AF_UNIX / 命名管道 + `DuplicateHandle` | +| `RootfsRuntime` | 独立用户态 | bwrap userns | 未实现 → Lima 类 VM / WSL2 `--import` | + +- 每个接口都有 `probe()`,返回 `{supported, enforced: kernel|advisory, reason}`。§14 的矩阵**就是这些 probe 的结果**,不是手写的表。 +- 用户面(命令、配置、提示格式)只依赖接口,不依赖实现。 + +## 18. 能力探测 + +运行时实测,不预设;缓存键是 `kernel release + boot_id + provider 的 hash + xlings 版本`,存在 `state/isolation-caps.json`;在满足必须项的前提下,选能满足最多尽量项的组合;探针失败时输出原始 stderr、AppArmor label、相关 sysctl 的值、按最小权限排序的修复办法。 + +## 19. 网络与身份 + +| 模式 | 实现 | 本地边界 | 出口 | 默认用于 | +|---|---|---|---|---| +| `host` | 现状(kernel ≥ 6.12 叠加 Landlock 抽象 socket 作用域) | ✗ | 宿主 | dev | +| `nat` | netns + pasta(支持 `--publish`;`--allow host-loopback` 可以访问宿主本机的服务) | ✓ | 宿主 IP | private | +| `none` | netns,只有 lo | ✓ | 无 | locked | +| `proxy` | netns + "无路由 + 一扇门":内置转发器 → supervisor 里的桥接 → 声明的代理;DNS 只走代理 | ✓ | 代理 | 设置了代理的 private | + +身份:private / locked 默认中性化(`user`、`/home/user`、hostname = 实例名、UTC、C.UTF-8、每个实例一个 machine-id)。路径中出现登录名,由部署形态决定(C 形态可以避免)。 + +## 20. 特权组件与 rootfs(已确认) + +- root 拥有的 `/usr/lib/xlings/bwrap` + `/etc/apparmor.d/xlings-bwrap`(**profile 不能指向用户可写的路径**);由系统包提供,或者 `self doctor --isolation --fix` 安装(一次 sudo)。查找顺序:root 拥有的路径 → 系统 bwrap(探测通过就用,用了就报告)→ xim payload。不用 sysctl,不自动使用 busybox,不再创建 setuid;private / locked 加 `--disable-userns`。 +- rootfs:userns + uid 0 映射(setuid 做不到);多 uid 映射用 `newuidmap` + `/etc/subuid`;xlings 挂在 `/usr/local/bin/xlings`;默认 `fetch=layer`;rootfs 来源是 subos 类型的 xpkg。与隔离用的是同一个前提,一次解决。 + +## 21. 性能与能力不缺失 + +### 21.1 性能预算(作为 CI 基准测试的断言) + +| 路径 | 预算 | 做法 | +|---|---|---| +| **shim 分发**(gcc、clang 这类,每次构建可能上千次) | **与现在相同**(2026.10.4.1 已经降到个位数毫秒) | 沙箱里的 shim 分发**不经过 supervisor 和 broker**,不读策略文件;只有需要提权的 xlings 管理命令才走 socket | +| `subos exec`,热会话 | 目标 ≤ 5 ms 的额外开销 | socket + fd 传递,不启动 bwrap | +| `subos exec`,冷启动 / `use` | 目标比现在多 ≤ 10 ms | caps 缓存命中时不跑探针;supervisor 只是 fork + wait | +| 交互 I/O | 零开销 | 终端 fd 直接交给会话,没有中继 | +| `observe=full` 的 exec 审计 | 每次 execve 一次往返(微秒级) | 只有 locked 默认开启;过滤器只拦 execve / execveat,其他系统调用只多一次 BPF 判断 | +| `net=nat` | 接近原生 | pasta 对 TCP 使用 splice | +| `fs` 变更清单 | 与映射目录的大小成正比 | 按 mtime 扫描;超过阈值时只记录摘要(可以配置) | +| supervisor 常驻 | 空闲时 0 CPU,几 MB 内存 | — | + +基准测试放在 `tests/perf/subos_*`,与 #639 的 shim 分发基准放在一起。超出预算就算回归。 + +### 21.2 能力不缺失:每一项默认受限的能力,都有一个具名的授权 + +| 默认受限的 | 授权方式 | 实现 | +|---|---|---| +| 显示(X11 / Wayland) | `--allow display` | bind socket 文件 + xauth cookie(不暴露抽象 socket) | +| 音频 | `--allow audio` | PipeWire / Pulse socket | +| 摄像头 | `--allow camera` | `/dev/video*` | +| GPU | `--allow gpu`(取代 `--gpu`,旧参数保留为别名) | 已有的 passthrough | +| SSH agent | `--allow ssh-agent` | bind `SSH_AUTH_SOCK` | +| D-Bus | `--allow dbus`(以后用过滤代理) | — | +| 宿主本机服务(数据库等) | `--allow host-loopback` | pasta 的映射选项 | +| git 配置、凭据 | `--mount ~/.gitconfig:ro`;`env_pass` | — | +| 环境变量 | `env_pass` 白名单 | — | +| 服务端口 | `--publish` | pasta | + +只要策略的 `grants_allowed` 允许,任何一项都可以单次授权,或者写进策略。**不存在"隔离之后就做不到"的死路**,只有"需要显式授权"。 + +--- + +# Part VI 可观测性(已确认) + +## 22. 可观测性 + +- **原则**:只有一种事件模型(与 interface 同源);审计由 supervisor 写入 `logs/subos//`,沙箱不可见;只在本地;默认脱敏;审计写不进去时 locked 结束会话,其他档位警告。 +- **事件类别**: + +| 类别 | 内容 | 平台 | +|---|---|---| +| `ops` | 每次 xlings 命令 | 全部 | +| `lifecycle` | 实例、会话、策略的变化(附带隔离快照) | 全部 | +| `perm` | 权限判定、请求、审批 | 全部 | +| `exec` | 沙箱里每次执行程序 | Linux(seccomp 通知) | +| `net` | 每个出站连接 | Linux(nat / proxy) | +| `fs` | rw 映射目录里改动过的文件 | 全部 | +| `destructive` | 现有的破坏性操作记录 | 全部 | +| `trace` | 开发调试 | 全部 | + +- **观测级别**:`off`(只保留 destructive)/ `basic` / `standard` / `full`,默认值跟随档位,可以在策略里配置。 +- **用户面命令**:`subos ps`、`subos log [-f] [--kind] [--session] [--json]`、`subos sessions`、`subos report`、`subos requests / approve / deny`、`interface subos_events`、`XLINGS_TRACE=home,caps,spec,provider,broker,session`、`subos status --verbose`。 +- **存储**:`events.ndjson` + `sessions/.ndjson`;按大小和天数轮转;实例删除时**默认保留**审计(`--purge-logs` 才删除)。 +- **exec 审计的实现链路**:bwrap 的 `--seccomp` 拿不到监听 fd,所以由 supervisor fork 出的子进程用 `SECCOMP_FILTER_FLAG_NEW_LISTENER` 安装过滤器,通过 `SCM_RIGHTS` 把 fd 传回 supervisor,再 exec provider。过滤器跨 exec 继承,覆盖沙箱里的所有进程。 + +--- + +# Part VII 代码架构(已确认,本轮补充) + +## 23. 模块化 + +### 23.1 归属 + +| 东西 | 归属 | +|---|---| +| home、部署形态、层、HomeContext | **xlings 核心**(`src/core/home/`) | +| 实例、策略、编译器、provider、平台抽象层、会话 / supervisor / broker 服务端、userdata、status | **SubOS 核心**(`modules/subos`) | +| `UserConfirmed` 令牌 + `Asker` 端口(`ask()`)、路径守卫 | 公共:`modules/guard` | +| 事件 schema、journal(含 destructive)、sink、脱敏、trace 类别 | 公共:`modules/observe` | +| 各自的安全策略(删除 home、GC / `delete_subos`、用户数据分类) | 各自的核心模块 | +| 交互面(CLI human / agent、`--json`、interface、文案、i18n) | `src/`(cli、interface、ui、agent),以及 `src/core/subos/` 适配层 | + +### 23.2 目录结构 + +``` +modules/guard/ xlings.guard UserConfirmed、Asker 端口、路径守卫 +modules/observe/ xlings.observe 事件、journal、sink、脱敏、trace +modules/subos/ xlings.subos + model / view / layout / manifest / policy(含 decide、规则、策略包解析)/ caps / spec + platform/ fs_gate · process_scope · net_gate · device_gate · identity · exec_tracer · session_host · rootfs ← §17 的接口 + provider/ linux_bwrap · linux_landlock · linux_proot · home_redirect (以后 macos_seatbelt · win_appcontainer) + session/ supervisor · session_init · broker(服务端)· exec 服务 + net/ forwarder · bridge + userdata / status / ports / gpu / graphics +src/core/home/ HomeContext、.xlings-home、层(xlings 核心) +src/core/subos/ 适配层:CLI 参数解析、Ports 的实现(xim / xvm)、broker 请求的执行、渲染 +modules/testkit/ xlings.testkit e2e 测试库(§24.3),只作为 dev-dependency +apps/xdev/ 开发工具:test / ci plan / report / lint / release / doctor(§24.3),不随产品发布 +``` + +### 23.3 依赖规则 + +``` +platform, json, cancellation, sha256, i18n ← guard, observe ← modules/subos ← src/core(含 home)← src/core/subos 适配层 ← cli / interface / ui / agent +guard, observe, platform, json ← testkit ← tests/**、apps/xdev (测试侧:只依赖公共模块,通过产物二进制驱动 xlings) +``` + +`modules/subos` 是独立包,import `xlings.core.*` 编译就会失败。反向依赖通过 `Ports` 注入(`ensure_backend`、`is_foreign_shim`、`execute(BrokerRequest)`)。模块只返回结构化结果,不产生文案。将来可以作为 `xlings-subos` mcpp 包单独发布(有自己的 semver),可选再做一个 `apps/xsubos`。 + +--- + +# Part VIII 质量与交付 + +## 24. 测试、CI 与开发工具架构 + +### 24.1 现状(实测:最近成功运行的中位数,样本不超过 9 次) + +| workflow | 中位 | 最长 | 主要耗时 | +|---|---|---|---| +| xlings-ci-linux | 28.5 min | 52.3 min | asan 单元测试 26.6 min;单元测试 9.3 min(大部分是编译);release 构建 7.0 min | +| xlings-ci-linux-e2e | 16.9 min | 39.5 min | release 构建 6.2 min;E2E-00 从源码**再构建一次** 4.2 min;**e2e 套件本身只有 3.6 min** | +| xlings-ci-linux-root | 17.6 min | 40.2 min | 同样包含一次独立构建 | +| xlings-ci-windows | 16.3 min | 43.0 min | 单元测试 9.3 min;release 构建 5.3 min | +| xlings-ci-macos | 10.3 min | 36.0 min | — | +| xlings-ci-aarch64 | 9.6 min | 33.8 min | — | + +结论与问题: + +1. **时间几乎都花在构建上**,而且同一个 commit 在多个 workflow 里**重复构建**。测试本身并不慢。 +2. **测试用了四种语言**:C++ gtest(`tests/unit`,57 个文件)、bash(`tests/e2e`,161 个脚本)、PowerShell(Windows 的 e2e,和 bash 版本部分重复)、Python(`tests/scripts` 12 个,加上 `tools/` 里的若干脚本)。 +3. e2e 由 `run_all.sh` **串行**执行;拿不到 backend 时 **skip**(F13);依赖网络镜像(AGENTS.md 里专门写了 `XLINGS_TEST_MIRROR=CN` 的坑)。 +4. 没有统一的报告:哪些需求被覆盖了、哪些测试不稳定、耗时趋势,都无法回答。`tests/README.md` 已经过时(还写着"51 个 gtest")。 + +### 24.2 目标 + +| 目标 | 指标 | +|---|---| +| PR 必过车道 | 墙钟时间 **≤ 15 min**(现在 Linux 中位 28.5 min) | +| 主干合并车道 | ≤ 30 min,全量 L0–L5 | +| nightly | ≤ 90 min,全部内容(asan、覆盖率、性能趋势、完整环境矩阵) | +| 覆盖 | 每一个必须项、每一个退出码、每一条 agent 契约、每一个 F 发现(F1–F16 作为回归测试)、每一个平台 probe,**都有至少一个测试声明覆盖它**;没有覆盖的 CI 直接失败 | +| 统一 | 一种语言(C++)、一个工具链(mcpp)、一个入口(`xdev`);本地和 CI 跑的是同样的命令 | + +### 24.3 工具:`modules/testkit` + `apps/xdev`(C++ / mcpp) + +**为什么用 C++ + mcpp**: +- 与项目使用同一种语言、同一个工具链; +- 一份 e2e 代码就能跑三个平台,取代 bash 和 PowerShell 的重复实现; +- 可以直接复用 xlings 自己的模块(`observe` 的事件 schema、`platform`、`json`); +- mcpp 已经提供了测试发现、按模式过滤、`--message-format json`(每个测试一条 NDJSON)、超时控制、按 workspace 成员运行,这些能力可以直接使用。 + +**`modules/testkit`(`xlings.testkit`,e2e 测试库,构建在已有的 gtest dev-dependency 之上)** + +```cpp +import xlings.testkit; +using namespace xlings::testkit; + +XTEST(subos_isolation, home_is_read_only, + meta{ .area = "subos.isolation", .cost = Cost::Fast, + .covers = {"ISO-HOME-RO", "F1"}, .requires = {cap::userns} }) { + auto home = Home::isolated({ .index = Fixture::minimal }); // 临时 XLINGS_HOME + 进程内的 fixture 镜像 + home.xlings({"subos", "new", "t", "--sandbox=dev"}).expect_ok(); + auto r = home.xlings({"subos", "exec", "t", "--", "sh", "-c", + "test -w \"$XLINGS_HOME/config/shell/xlings-profile.sh\""}); + r.expect_exit(1); + home.audit("t").expect_event(kind("lifecycle").has("effective", "xlings_home_ro")); +} +``` + +| 能力 | 说明 | +|---|---| +| `Home::isolated()` | 临时 home,测试结束后清理;默认使用**进程内的 fixture HTTP 镜像**(从 `tests/fixtures` 提供索引和 payload),**不访问网络**,与镜像所在地区无关 | +| 运行 xlings | 支持 argv、env、stdin、可选 pty、超时;结果对象提供 exit / stdout / stderr / json / ndjson 的解析 | +| 断言 | `expect_exit`、`expect_json`、`expect_event`(审计事件)、`expect_no_prompt`(agent 契约)、`expect_missing(dimension, reason)` | +| 元数据 | `area`、`cost`(Fast < 2s / Medium < 30s / Slow)、`covers`(需求 ID)、`requires`(能力)、`resources`(需要独占的资源,例如端口、真实沙箱),`network` 标记 | +| 能力要求 | 车道**声明**自己具备哪些能力(`XDEV_LANE_CAPS=userns,landlock`)。车道声明了但实际缺失 → **失败**;开发机上缺失 → skip 并写明原因(修复 F13) | +| 失败现场 | 失败时把临时 home 里的 hook 日志、审计、`destructive.ndjson` **脱敏后**保存为 artifact | +| 事件 | 每个测试的耗时、结果、skip 原因、重试次数都按 `modules/observe` 的 schema 输出 | + +**`apps/xdev`(开发工具,不随产品发布)** + +| 子命令 | 作用 | +|---|---| +| `xdev test [unit\|contract\|e2e\|perf] [--area] [--tag] [--changed ] [--shard i/n] [--lane pr\|main\|nightly] [-j N]` | 统一入口:底层调用 `mcpp test --message-format json`,加上选择、分片、并行和资源锁 | +| `xdev ci plan [--changed ..]` | 输出 GitHub Actions 的动态矩阵 JSON(车道、分片),**测试选择逻辑只写在 C++ 里,不写在 YAML 里** | +| `xdev report [--format md\|json] [--coverage-map]` | 汇总 NDJSON:结果、最慢的测试、不稳定的测试、**未被覆盖的需求 ID**、平台能力矩阵(来自 `subos doctor --json`,同时校验 §14 的矩阵)、性能预算、覆盖率 | +| `xdev lint` | 结构性检查:`lint_subos_remove_all`、禁止固定 xlings 版本、Windows 头文件卫生、文档示例、CLI 与文档一致、i18n 覆盖(**移植现有的 Python 脚本**) | +| `xdev release build\|verify\|finish` | 统一 `linux_release.sh` / `macos_release.sh` / `windows_release.ps1` / `verify-release.sh`。`finish` 把 AGENTS.md 里"release.yml 变绿之后还要做的两步"(补 CN 镜像、用 **GET 而不是 HEAD** 验证、更新 xim-pkgindex)做成带检查的命令。先包装现有脚本,再逐步移植 | +| `xdev doctor` | 检查开发环境:工具链、镜像(是否需要 `XLINGS_TEST_MIRROR=CN`)、bwrap profile、本机的隔离能力 | + +**迁移不需要一次重写**:现有的 sh / ps1 / py 测试通过 `xdev` 的**兼容适配器**照常运行(xdev 知道它们的清单,负责计时、捕获结果、写入同一份 NDJSON),**从第一天起报告就是统一的**。之后按优先级逐步移植成 C++:先移植 sh + ps1 成对重复的,再移植不稳定的、慢的;**新测试只用 C++ 写**。 + +### 24.4 测试分层与覆盖矩阵 + +| 层 | 内容 | 单个耗时 | 工具 | +|---|---|---|---| +| L0 静态 | lint、结构检查、schema 校验 | 秒 | `xdev lint` | +| L1 单元 | `policy.decide()` 和规则匹配;spec 编译器(**golden 快照**:档位 × 覆盖 × caps 组合,表驱动);manifest 往返(保留不认识的键);HomeContext 解析矩阵;退出码映射;脱敏;受众渲染器 | 毫秒 | gtest | +| L2 契约 | CLI spec ↔ 文档;interface schema;退出码表;**agent 契约扫描**:从 `CommandSpec` 遍历每一条命令,在 `XLINGS_AGENT_MODE=1`、挂 pty、不给输入的条件下运行,断言不会阻塞等待输入;事件 schema 兼容 | 秒 | testkit | +| L3 组件 e2e(fake provider) | 用 `fake` provider(记录 spec,直接执行命令,不做隔离)跑 CLI、策略、broker、审批、exec、会话的流程;**三个平台都能跑,速度快** | 秒 | testkit | +| L4 真实隔离 e2e | 真实的 bwrap / Landlock:每个档位的每个必须项;**F1–F16 的安全回归探针**;`subos doctor` 本身 | 十秒级 | testkit,隔离车道 | +| L5 部署与升级 | U / C / P、relocate;**N-1 版本兼容**:上一个 release 的客户端操作新布局,新客户端操作老布局,都不能损坏数据;release 产物测试 | 十秒到分钟 | testkit + 产物 | +| L6 环境矩阵 | 24.04(装 profile 前后)、22.04、`container:` job、aarch64、archlinux、macOS、Windows、fresh-install | 分钟 | CI | +| L7 性能 | §21.1 的预算;shim 分发(#639) | — | `xdev test perf` | +| L8 故障注入 | 磁盘满、supervisor 被杀、broker 不可用、caps 缓存过期、AppArmor 拒绝(由 fake 模拟)、审计写入失败 | 秒 | testkit 的故障点 | + +- **fake provider 只证明流程,不证明隔离**。覆盖图里区分 `proves: flow` 和 `proves: isolation`,**隔离类的需求 ID 只有 L4 的测试才算覆盖**。 +- **故障点只在测试构建里存在**:用 mcpp 的 `testing` feature 编译进去;release 构建的检查会断言故障点不存在。 +- **需求可追溯**:本文的必须项、F 发现、退出码、契约规则、平台 probe 都有 ID(例如 `ISO-HOME-RO`、`F2`、`EXIT-125`、`AGENT-NO-PROMPT`)。`xdev report --coverage-map` 列出没有被覆盖的 ID,**有就让 CI 失败**。代码覆盖率(llvm-cov)在 nightly 运行,新模块(`modules/subos` 的 policy / spec)设门槛(例如 90%)。 + +### 24.5 CI 架构 + +``` +plan ──► build(每个 OS × profile 只构建一次;mcpp 全局缓存)──► 上传产物(dev 二进制、测试二进制、release 包) + │ + ┌─────────────────────────┼──────────────────────────────┐ + ▼ ▼ ▼ + L0 + L1 + L2 L3 分片 × N(3 个平台) L4 隔离车道(Linux:装 profile / 容器 / 22.04) + └────────────── 汇总:xdev report → step summary + report.json ─────────────┘ +``` + +| 手段 | 说明 | 节省 | +|---|---|---| +| **每个 commit 每个平台只构建一次** | 可复用的 `build.yml` 产出所有产物,测试 job 只下载。现在 release 构建在 linux / e2e / root 三个 workflow 里各做一次;E2E-00"mcpp 从源码构建"本身就是 build job,不再单独重复 | 每个 PR 节省 10+ 分钟的机器时间,以及关键路径上的若干分钟 | +| **mcpp 缓存** | 用 `mcpp --print-fingerprint` + `mcpp.lock` 作为缓存键,缓存 BMI 和目标文件;PR 做增量编译 | 构建从 6–9 min 降到增量的几分钟(需要实测) | +| **动态矩阵与分片** | `xdev ci plan` 根据主干的历史耗时(`ci-timings.json`)装箱分片 | 关键路径由最慢的分片决定 | +| **按影响选择** | `tests/areas.toml` 把路径映射到 area,测试声明自己的 area。PR 车道:L0–L3 **全部**(本来就快)+ 冒烟集 + 受影响 area 的 L4 / L5;主干:L0–L5 全部;nightly:全部 | 避免每个 PR 都跑全量的慢测试 | +| **作业内并行** | 每个测试用自己的临时 home,天然可以并行(`-j`);用到真实沙箱、端口、挂载的测试通过资源锁串行 | — | +| **不依赖网络** | PR 车道使用 fixture 镜像;带 `network` 标记的测试只在主干和 nightly 跑,失败时附带镜像归因 | 消除镜像导致的不稳定 | +| **asan** | 从每个 PR 移到:主干合并后 + nightly + 打了 `ci:asan` 标签或触及指定 area 的 PR | PR 关键路径减少约 27 min | +| **不稳定测试** | CI 里自动重试一次;重试才通过的记为 flaky;隔离清单(`tests/quarantine.toml`)要写明负责人和到期时间 | — | + +预计 PR 关键路径:构建(缓存增量)3–6 min + 测试分片 ≤ 5 min + 汇总 ≤ 1 min,**目标 ≤ 15 min**。这个数字需要在 T3 完成后实测。 + +### 24.6 报告与可观测性 + +- 每个测试的事件都使用 `modules/observe` 的 schema(与产品的审计事件是同一套),`xdev report` 合并所有分片和平台的结果。 +- **GitHub step summary**:车道结果、最慢的 10 个测试、flaky 列表、**未被覆盖的需求 ID**、平台能力矩阵(实测)、性能预算表、覆盖率变化。 +- artifact:`report.json`;失败测试的脱敏现场。 +- 趋势:主干和 nightly 把耗时、flaky、覆盖率追加到一个数据分支(或长期保留的 artifact),`xdev report --trend` 可以查看。 +- 本地执行 `xdev test` / `xdev report` 得到的输出与 CI 相同。 + +### 24.7 测试基础设施自己的 checkpoint(放在 C0 之后、C1 之前,后面所有功能都用它写测试) + +| # | checkpoint | 验证 | +|---|---|---| +| T1 | `modules/testkit` 骨架:`XTEST` + 元数据、`Home::isolated`、进程内的 fixture 镜像、断言、失败现场。用一个 sh + ps1 成对重复的用例证明它能跨平台 | testkit 自己的单元测试 + 3 个平台 | +| T2 | `apps/xdev test / report`:调用 mcpp 测试的 JSON 输出 + 现有 sh / ps1 / py 测试的兼容适配器 → **统一报告** | 本地和 CI 输出一致 | +| T3 | CI 重构:`plan` + 只构建一次的 `build.yml` + 分片 + mcpp 缓存 + asan 调整 + step summary | 实测 PR 关键路径的耗时 | +| T4 | 契约扫描:agent 不提问扫描、退出码表、CLI spec 一致性(把 Python 脚本移植过来) | L2 | +| T5 | 需求 ID 覆盖图 + "有未覆盖的 ID 就失败";车道能力声明(修复 F13) | `xdev report --coverage-map` | +| T6 | `xdev lint` 移植 Python 检查;`xdev release` 包装现有脚本(可以放在本 PR 之后) | L0 | + +## 25. 稳定性规范 + +| 规范 | 内容 | +|---|---| +| 一个问题只有一个回答者 | 部署:`.xlings-home`;运行:`HomeContext`;能不能做:`policy.decide()`(客户端和 broker 共用);实际拿到什么:`SandboxSpec + Caps`;平台能做什么:各接口的 `probe()`;测试选择:`xdev ci plan` | +| 声明而不是推断 | 形态、策略、受众、车道能力都是声明;推断只作为老数据的迁移路径 | +| 核心与交互面分离 | 核心里不出现受众分支,不拼文案;提问只通过 `Asker` | +| schema | 写入时保留不认识的键;**执行时遇到不认识的安全相关字段就拒绝进入** | +| 布局版本 | `.xlings-home.layout` 单向迁移;从引入这条规则的版本开始,遇到更高的 layout 只读不写(layout 2 只增加内容,对现有客户端无害) | +| 物理分离 | 策略与被约束的对象分离(`config/subos/`);审计与被审计的对象分离(`logs/subos/`) | +| 写者唯一 | §6 的表就是清单 | +| 用户数据 | 沿用 AGENTS.md;`rootfs/`、映射目录、审计日志都按用户数据处理 | +| 协议 | 事件只增加字段;broker 复用 interface;退出码表写进文档并加测试固定 | +| 性能 | §21.1 的预算是 CI 断言 | +| 可验证 | 每个需求 ID 至少有一个测试;隔离类的 ID 只有真实 provider 的测试才算覆盖 | +| 无感升级 | 没有声明的实例行为不变;单次 `--sandbox` 变为 dev 档(`fetch=auto`);`--gpu`、`--cmd` 保留为别名;N-1 兼容测试守住升级和降级 | + +## 26. 交付:单 PR,多个 commit checkpoint(已确认) + +每个 commit 都是一个 checkpoint:能编译,已有测试全部通过,本 commit 的新测试通过,可以单独 revert。顺序:**C0 → T1–T5 → C1–C27**(T6 可以放在本 PR 之后)。 + +| # | checkpoint | 验证 | +|---|---|---| +| C0 | 本设计文档;修正现有文档(setuid、image = L3、隔离矩阵);AGENTS.md 补充"核心与交互面分离"和 `xdev` 的使用 | 文档 | +| T1–T6 | 测试基础设施(§24.7) | 见 §24.7 | +| C1 | `modules/guard`、`modules/observe`;`confirm`、`destructive_log` 迁入(行为不变) | 现有测试 | +| C2 | `modules/subos` 骨架;`gpu`、`graphics`、`manifest` 迁入(行为不变) | 现有测试 | +| C3 | `Ports`、`HomeView`;`userdata`、`sandbox`、`keeper` 迁入(行为不变) | 现有测试 | +| C4 | 拆分 `subos.cpp`:核心与适配层(行为不变) | 现有测试 | +| C5 | `src/core/home/`:HomeContext、带形态的 `.xlings-home`、layout 规则 | L1 + L5 | +| C6 | 写者规范:保留不认识的键;策略文件放在 `config/subos/` | L1 | +| C7 | 写入面盘点(inotify) | L4 基线 | +| C8 | 平台抽象层接口 + `probe()`;bwrap / proot / home-redirect 移植成 provider;**fake provider** | L1 golden | +| C9 | 会话模型:supervisor + session-init + `exec.sock` 和 fd 传递;`lifecycle` / `ops` 事件;`subos ps / log` | L3 + L4 + L7 | +| C10 | S0 修复①:挂载拓扑 | L4(F1、F6) | +| C11 | S0 修复②:env 清理、pid / ipc / uts、`--die-with-parent`、`--new-session` + TIOCSTI seccomp;探针文案 | L4(F3、F4、F7、F8、F12) | +| C12 | `subos exec` / `start` / `stop` / `--temp` / `cp`;interface 的 `subos_exec` 等 | L2(退出码)+ L3 | +| C13 | 受众契约:`XLINGS_AGENT_MODE`、`Asker` 端口、agent 下的错误码 / 候选列表 / 非阻塞 | L2(agent 契约扫描) | +| C14 | 策略模型:预设、覆盖、合并、`policy.decide()`、`--sandbox=<档位>`、`subos config / status`、拒绝不认识的安全字段 | L1 + L3 | +| C15 | 权限:客户端 + broker;auto / ask / deny;`install --subos`;`requests / approve`;hook 沙箱;`perm` 事件 | L3 + L4 | +| C16 | 细粒度规则;策略包 | L1 + L3 | +| C17 | 能力探测 + 缓存 + 必须项 / 尽量项 + 提示规范 + `--no-degrade` | L3(fake 能力)+ L6 | +| C18 | Landlock provider | L4(容器车道) | +| C19 | `--mount`;`--allow` 授权族 | L3 + L4 | +| C20 | 身份中性化、`net=none`、`--disable-userns` | L4(F5、F7) | +| C21 | `self doctor --isolation --fix`;bwrap recipe 在 xim-pkgindex 另提 PR | L6(F10) | +| C22 | 观测完整版:exec、fs、脱敏、`report`、`subos_events`、`XLINGS_TRACE` | L3 + L4 + L8 | +| C23 | 网络:`nat`(pasta、`--publish`)、`proxy`;`net` 事件 | L4(F2、F5) | +| C24 | `fetch=layer` | L3 + L4 | +| C25 | `subos doctor`;完整环境矩阵;性能预算作为断言 | L6 + L7 | +| C26 | 部署形态 S;系统层(M)的读取路径 | L5 | +| C27 | rootfs(是否放进本 PR 由维护者决定) | L4 | + +## 27. 决策 + +### 已确认(全部) +权限模型;Landlock;`--mount`;渐进式披露;平台矩阵和提示规范;root 拥有的 bwrap + 窄 profile + rootfs;五种部署形态;层叠 home;目录规范;supervisor;可观测性;模块归属;单 PR 多 checkpoint;控制与配置的四个层次;策略文件放在实例目录之外;进入与外部执行(会话模型、退出码);人与 agent 两套语义(`XLINGS_AGENT_MODE`),核心与交互面分离;性能预算与具名授权;平台抽象层和附录 A;C0–C27。 + +### 本轮需要 review +1. **testkit + xdev**:用 C++ / mcpp 统一单元测试、e2e、lint、发布、报告;现有的 sh / ps1 / py 测试走兼容适配器,不需要一次重写;新测试只用 C++(§24.3)。 +2. **L0–L8 分层**;fake provider 只证明流程;**需求 ID 覆盖图,有未覆盖的就让 CI 失败**;车道能力声明取代 skip(§24.4)。 +3. **CI 架构**:每个平台只构建一次 + mcpp 缓存 + 动态矩阵 / 分片 + 按影响选择 + PR 不依赖网络 + asan 移出 PR 关键路径;PR 目标 ≤ 15 min(§24.5)。 +4. **报告**:统一事件 schema、step summary、趋势(§24.6)。 +5. T1–T6 放在 C0 之后(§24.7、§26)。 + +--- + +## 附录 A:其他平台的技术路线参考(供以后实现时参考,需要逐项验证) + +| 接口 | macOS 候选 | Windows 候选 | 备注 | +|---|---|---|---| +| FsGate | **Seatbelt**(`sandbox-exec` / `sandbox_init` 的 SBPL profile,可以 `(deny file-read* (subpath …))`)。API 已标记为废弃,但 Chromium、Bazel 的 sandboxed 策略、Nix 仍在使用;不需要特权 | **AppContainer**(低完整性 + 能力 SID;允许访问的目录需要给 AppContainer SID 加 ACL,这些目录归用户所有,所以可以做到);**Win32 app isolation**(Windows 11 的新机制,处于预览阶段) | macOS 上不能重映射路径,映射继续用链接 | +| ProcessScope | 进程组 + Seatbelt;没有 pid namespace | **Job Object**(kill-on-close、资源限制、进程计数),不需要特权 | — | +| NetGate | Seatbelt `(deny network*)` 可以做到 `none`;`proxy` 需要 Network Extension(需要签名 entitlement)或者 pf(需要 root) | AppContainer 不授予 `internetClient` 即可做到 `none`;`proxy` 需要 WFP(需要管理员)或 loopback 豁免 + 代理 | `nat` 在这两个平台上没有直接的对应 | +| DeviceGate | Seatbelt 有 `device-camera`、`device-microphone` 操作;另外还受系统 TCC 管控 | AppContainer capability(`webcam`、`microphone`),与 `--allow` 的模型天然对应 | — | +| ExecTracer | Endpoint Security(需要系统扩展和 entitlement);`eslogger`(macOS 13+,需要 root 和完全磁盘访问权限);不需要特权的退路:轮询 Seatbelt 会话内的进程表 | **Job Object 的 `JOB_OBJECT_MSG_NEW_PROCESS`**(通过 IO 完成端口,**不需要特权**),再查询映像路径和命令行;ETW 进程事件需要管理员 | Windows 这一项比较容易实现 | +| SessionHost | 与 Linux 相同(unix socket + SCM_RIGHTS) | CreateProcess + Job Object;AF_UNIX(Windows 10+)或命名管道;用 `DuplicateHandle` 传递控制台 / 管道句柄 | — | +| RootfsRuntime | 轻量 VM:Virtualization.framework(Lima 类);Apple 公开的 Linux 容器框架(需要评估版本要求) | **WSL2**:`wsl --import ` 原生支持导入 rootfs,与 subos 类型 xpkg 的分发方式契合 | 两个平台的 rootfs 都意味着"Linux 用户态跑在 VM 里";隔离和观测在 VM 内部复用 Linux 的实现 | +| 特权组件 | Seatbelt profile 随系统包安装;不需要 root 授权 | AppContainer profile 的注册不需要管理员 | — | + +## 附录 B:自我 review(本轮发现的问题与处理) + +| # | 问题 | 处理 | +|---|---|---| +| R1 | 上一轮把 `E_PERMISSION=13`、"必须项不满足 = 2"和 `--cmd` 的命令退出码放在同一个空间里,`exec` 无法区分"命令失败"和"xlings 失败" | 采用 docker 的约定:exec 在命令启动之前失败返回 125,`--json` 里有 `phase` 字段;非 exec 命令保持原有的退出码表(§12.2) | +| R2 | 实例策略放在实例的 `.xlings.json` 里:要么整个文件只读(版本切换要经过 broker,热路径变慢),要么沙箱能修改自己的策略 | 策略移到 `config/subos//policy.json`,从结构上分离;实例的 `.xlings.json` 保持可写(§6.1)。"策略被老客户端丢掉"的问题也随之消失(老客户端根本不会碰这个文件) | +| R3 | 上一轮说"老客户端遇到更高的 layout 只读不写",但**现有的老客户端并不认识这条规则** | 写明这条规则从引入它的版本开始生效;layout 2 只增加内容,现有客户端照常读写不会损坏它(§25) | +| R4 | 老客户端进入一个声明了策略的实例时,会忽略策略(没有隔离) | 无法阻止老客户端。在文档里说明;`self doctor` 检测到 entry 版本低于策略要求的最低版本时报 Error;策略文件里写 `min_client` 字段 | +| R5 | `XLINGS_SUBOS_POLICY` 如果放完整的 JSON,env 会很大,而且 env 清理时要特别放行 | 只放策略文件的路径和指纹;内容从只读的 `policy.json` 读取 | +| R6 | supervisor 从 `execvp` 改成 fork + wait 以后,Ctrl-C 会同时发给 supervisor | supervisor 在等待期间忽略 SIGINT / SIGQUIT,转发 SIGTERM / SIGHUP(§16) | +| R7 | 交互会话加 `--new-session` 会破坏作业控制,但不加又有 TIOCSTI 风险 | 交互时改用 seccomp 按参数过滤 `ioctl`;非交互时用 `--new-session`(§16) | +| R8 | 多次 `use` / `exec` 加入同一个会话时,如果 tty 要经过中继,会有性能损失和兼容问题 | 用 `SCM_RIGHTS` 直接传递 fd,由 session-init 派生进程,没有中继(§16) | +| R9 | 单次调用的覆盖参数和运行中会话的 spec 不一致 | 能改变 spec 的单次调用不加入现有会话,而是另开临时会话并说明原因(§12.1) | +| R10 | `fetch=auto` 在 macOS / Windows 上没有 hook 沙箱 | 在这两个平台上,auto 的信任等级等同于 owner 自己执行 install(dev 档在这两个平台上本来就是 advisory),并在 `status` 里标明 | +| R11 | 第三方策略包可能悄悄放宽隔离 | 只有 owner 能选择策略包;选择时显示与内置预设相比放宽的项;锁定版本和 sha256;S / M 下由系统配置限制来源(§7.3) | +| R12 | 观测里 argv 可能包含密钥 | 默认脱敏;`--observe-raw` 关闭脱敏时在事件里标明;审计只在本地 | +| R13 | `--gpu`、`--cmd`、`--keep`、`--ttl` 这些现有参数的去留 | `--gpu` → `--allow gpu` 的别名;`--cmd` → `exec -- sh -c` 的等价写法;`--keep` / `--ttl` → 会话的空闲时长。全部保留,不破坏现有脚本 | +| R14 | §14 的平台矩阵如果是手写的,会和实现脱节 | 矩阵由平台抽象层各接口的 `probe()` 生成,文档里的表是某个版本的快照(§17) | +| R15 | 本 PR 的 checkpoint 数量多,评审负担大 | 前 9 个是行为不变的重构和基础设施,可以按 commit 逐个评审;每个 commit 都可以单独 revert;评审时可以按 Part 分组 | +| R16 | 仍然待验证的事实 | 沙箱激活时的写入面(C7);setuid 模式下 `/proc//root`;Ubuntu 系统 bwrap 是否带 profile;GH runner 的 userns;容器里 Landlock 是否放行;nl80211 BSSID;附录 A 的每一项。**其中环境相关的几项由 L6 车道直接回答,结果进入 `xdev report` 的能力矩阵** | +| R17 | 新增一个 e2e 框架会不会再引入一套依赖 | testkit 构建在已有的 gtest dev-dependency(`[dev-dependencies.compat] gtest`)之上,三个平台都已经在用,不引入新依赖 | +| R18 | fake provider 测得快,但可能给人"隔离已验证"的错觉 | 覆盖图区分 `proves: flow` 和 `proves: isolation`,隔离类的需求 ID 只认 L4 的真实测试(§24.4) | +| R19 | 按影响选择测试可能漏选 | L0–L3 每个 PR 都全量跑;主干全量跑 L0–L5;主干上出现了 PR 车道没选中的失败时,`xdev report` 标记为"area 映射缺口",需要修 `tests/areas.toml` | +| R20 | asan 移出 PR 关键路径,内存错误可能先合进主干 | 主干合并后立即跑 asan + nightly;`ci:asan` 标签和指定 area 强制在 PR 里跑;主干上 asan 失败按回归处理(立即 revert 或修复) | +| R21 | 重写 161 个 shell 测试成本太高 | 不要求重写:兼容适配器先统一报告,按优先级逐步移植,新测试只用 C++ | +| R22 | 故障注入点混进 release 二进制 | 只在 mcpp 的 `testing` feature 下编译;release 构建检查会断言故障点不存在 | +| R23 | fixture 镜像与真实索引格式脱节 | fixture 用真实的 xpkg 格式(`mcpp xpkg parse` 校验),并经过现有的索引兼容检查;nightly 用真实镜像跑带 `network` 标记的测试 | +| R24 | 作业内并行时,真实沙箱、端口、挂载类的测试互相干扰 | 用 `resources` 元数据加锁串行;其他测试按临时 home 并行 | +| R25 | `xdev` 自身出问题会拖垮整个 CI | xdev 有自己的 L1 测试,并且在 build job 里构建;`mcpp test` 仍然可以直接运行(xdev 只是在它上面增加选择和报告) | +| R26 | 本节的耗时数据来自最近的少量运行(每个 workflow 最多 9 次成功运行的中位数) | 15 min 的目标需要在 T3 之后实测确认;`xdev report --trend` 持续跟踪 | +| R27 | 环境变量命名 | 统一为 `XLINGS_AGENT_MODE`(取值 `1` / `0`),与 `--agent` 等价;`XDEV_*` 只用于开发工具,不进入产品 | + +--- + +## 实施记录(2026-10-06,PR #641) + +实现与本文有出入的地方,以实现为准,并在这里说明原因: + +| 本文 | 实现 | 原因 | +|---|---|---| +| 审计按 kind 分文件 | 每个实例一个 `logs/subos//events.ndjson`,事件带 `kind`,`subos log --kind` 过滤 | 一次追加、一个轮转点;读者按 kind 过滤即可 | +| exec 审计的过滤器由 supervisor 安装 | 由 session-init 在沙箱内安装,listener 经 ctl socket 传给 supervisor | 在 bwrap 之前设置 no_new_privs 会触发 AppArmor 对 bwrap 的限制 | +| `net=nat`:pasta 接入 bwrap 的 netns | 子进程先 unshare user+net,pasta 按 pid 接入,再 exec bwrap | 非特权的 pasta 无法加入 bwrap 创建的 netns | +| interface `subos_exec` 流式输出 | 结束后返回输出与退出码 | 流式需要 interface 协议的增量事件,留到下一版 | +| broker 执行安装时 hook 也在沙箱里 | hook 在宿主侧按 owner 权限运行 | 需要一个能写 payload 的 hook 沙箱,单独做 | +| 加入会话的交互 shell 有作业控制 | 加入时提示作业控制留在第一个 shell | 终端只能属于一个会话首进程 | +| Landlock 作为 bwrap 不可用时的回退 | 只在 `--sandbox landlock` 时使用 | 它只限制写入、不隐藏文件,自动替换会悄悄降低隔离 | +| `fetch=layer`、系统层(M)解析包 | 识别系统层并报告;`layer` 在任何位置都拒绝 | 两者是同一个分层 payload 查找机制,单独实现(`PERM-FETCH-LAYER`、`HOME-LAYER-RESOLVE`) | +| `net=proxy`、独立 rootfs(C27) | 拒绝 / 不在本 PR | 见计划 §4 | +| 单次调用 `--sandbox=xim:pack` | 只在 `subos config` 上选择策略包 | 单次调用只能收紧,策略包可能放宽 | +| `self install --user` | `self update --user` | 系统包的 `/usr/bin/xlings` 不是发布包目录,`self install` 无从安装;`self update --user` 走现有的安装路径 | + +需求与测试的对应关系在 `tests/requirements.toml`,`xdev report --requirements --fail-uncovered` 在 CI 中校验。 diff --git a/.agents/docs/2026-10-06-subos-architecture-design-part2.md b/.agents/docs/2026-10-06-subos-architecture-design-part2.md new file mode 100644 index 000000000..ac9c29aaa --- /dev/null +++ b/.agents/docs/2026-10-06-subos-architecture-design-part2.md @@ -0,0 +1,728 @@ +# SubOS 总体架构设计 Part 2:宿主即 SubOS —— 根呈现、系统包管理器、Luban + +- 承接:`2026-10-05-subos-architecture-design.md`(下称 Part 1)。本文**修订** Part 1 的 §4 部署形态、§5 层叠 home、 + §6 目录规范、§8.2 `fetch=layer`、§20 rootfs 和 C26 / C27;其余章节不变。 +- 交付:与 Part 1 同一个 PR(#641),checkpoint 接着编号(C28 起,§16)。 +- 状态:设计草案第二轮(第一轮的 review 意见已并入:§1 的 D1、D3、D7、D9;§2.4 的生态实测;§4 的两种布局)。 + 本文不含代码改动。 +- 隐私规则同 Part 1:实测只记录计数、是/否和名字;本文的实测都在临时 home 里完成。 + +--- + +## 0. 一句话 + +**SubOS 是"一份环境",宿主是其中一份。** 一个 SubOS 的内容(工作区里的包和版本、home、策略)与它的 +**呈现方式**(进程怎样看到它)是两件事。现在的 shell 级进入和沙箱是两种呈现方式;本文加第三种 —— +**根呈现**:SubOS 本身就是 `/`。 + +- 在别人的发行版上(U / C / P / S / M),宿主是一个 xlings 不管理的"底座"; +- 在 xlings 自己就是系统包管理器的机器上(R),宿主就是 `default` 这个 SubOS 的根呈现; +- 一个 `--rootfs` 实例是以根呈现进入沙箱的 SubOS;它里面的 xlings 看到的又是 R 模式(嵌套); +- **Luban** = kernel + xlings + (可选)LubanOS 服务;一款 Luban 发行版就是一个可以作为宿主启动的 + SubOS,官方版本(tiny / core / desktop …)是 subos 类型的 xpkg,任何人都可以 `--from` 它定制自己的发行版。 + +rootfs(Part 1 的 C27)、发行版模式、`fetch=layer`、多用户系统层(M)是同一个模型的四个用法,一起实现。 + +--- + +## 1. 本轮确认的决策(维护者,2026-10-06) + +| # | 问题 | 决定 | +|---|---|---| +| D1 | R 模式下 xlings home 的路径(会写进所有 payload) | **两种都支持**(第二轮):`/xlings`(多用户,推荐)和 root 安装到某个用户的 `/home//.xlings`(单用户)。见 §4 | +| D2 | 系统视图用 shim 还是直接链接 | **直接链接**;shim 只给用户层和项目层 | +| D3 | 库搜索 | **两个都支持,按消费者分,不做开关**(§7,第二轮确认) | +| D4 | 缺的基础工具 | 补到 xim-pkgindex | +| D5 | Luban 的定义 | kernel + xlings + LubanOS 服务(可选);每个人都能基于 xlings 生态定制发行版,像 SubOS 一样;统称 Luban;官方出几个默认版本(tiny / core / desktop 一类的分级,名字待定) | +| D6 | R 模式下宿主 SubOS 的名字 | **`default`** | +| D7 | "把验证过的实例应用到系统" | 维护者的模型:**配置默认启动的 SubOS**(它带"宿主"标识,有特殊性),运行时修改配置,**重启生效**。本文据此把 Part 2 初稿的 `apply` 改成"启动项"(§8);第二轮确认 | +| D8 | 范围 | **所有 SubOS 相关功能一起做,在本 PR 上** | +| D9 | 第一轮 §17 的待定项 | 维护者:"按合适的建议,推荐的先做"。逐项结论见 §17.1,顺序见 §16 | +| D10 | "生态里很多包已经不依赖宿主" | 实测见 §2.4:**运行期成立,安装期不成立**;据此新增 C30a(自带的安装工具集)和 elfpatch 失败即报错 | + +--- + +## 2. 现状(实测) + +### 2.1 部署形态 S 的主路径不可用(main 上已有,#641 的测试没覆盖到) + +把编译出的二进制放在任何 home 之外(模拟 `/usr/bin/xlings`),用一个空的临时 `HOME` 当新用户: + +``` +xlings install cmake -y → 9 个包安装成功,exit 0 +cmake --version → 宿主的 3.28.3(不是刚装的 4.4.2) +xlings self init → "init ok",/bin/ 是空的 +xlings use cmake → 显示 4.4.2 当前,subos/default/bin/ 仍为空 +xlings self doctor --fix → ✗ shim table 10 missing,修不好,也不说原因 +``` + +`xself::sync_shim_tables()`(`src/core/xself/init.cpp:649`)在 `/bin/xlings` 不存在时按"还没 `self init`" +跳过,而 S 形态下没有任何流程会放这个文件。已发布的 2026.10.4.1 行为相同。 + +### 2.2 其他 + +| 项 | 现状 | +|---|---| +| AUR 包 / Arch CI | `config/aur/PKGBUILD` 停在 `pkgver=0.4.14`;workflow 只在 `config/aur/**` 改动时触发;还在用已删除的 `self config --adduser` | +| M(系统层) | `home::system_layer()` 能识别 `/opt/xlings`(`mode: multi`)并在 doctor 报告;`install --system` 不存在;从系统层解析包(HOME-LAYER-RESOLVE)推迟 | +| rootfs | 未实现;gate `RootfsRuntime` 报 "not in this release" | +| generations | `profile::commit / rollback` 只记录**选择**(包 → 版本),回滚 = 重新跑一遍 `use`;没有可原子切换的物化树 | +| subos 类型 xpkg | 骨架 + `.xlings.json` 工作区声明;`--from` 复制它(COW 文件系统上零成本);4 个 e2e 测试 | +| 实例目录 | 本来就是 FHS 形状的:`bin/`、`lib/`、`usr/include/`(sysroot 模型),gcc 用 `--sysroot=` | +| 基础包(fixture 索引) | 有 glibc、busybox(静态 musl)、util-linux、ncurses、openssl、zlib、xz、perl、python、make、ca-certificates、linux-headers;**没有 bash、coreutils、shadow / 用户生成、init、内核** | +| `default` | 已经特殊:全局作用域;不能删除(`src/core/subos.cpp:1433`) | + +### 2.3 glibc loader 的实测(临时 home,glibc 2.44.3-r1) + +| 怎样调用 loader | 默认搜索目录 | `ld.so.cache` / `ld.so.preload` | +|---|---|---| +| payload 路径(受管二进制的 `PT_INTERP`) | payload 的 `lib/` | payload 的 `etc/`(不存在 → 不读) | +| **经过一个符号链接作为 `PT_INTERP`**(`/lib64/ld-linux-x86-64.so.2 → payload`) | **`/lib64`,即调用路径的目录** | 仍是 payload 的 `etc/` | + +结论:`glibc-2.44-default-dir-follows-loader.patch` 跟随的是**调用路径**(字面),不是 realpath。所以根呈现里 +`/lib64/ld-linux-x86-64.so.2` 链接到 payload 之后,外来二进制**今天就会**在 `/lib64`(合并后的 `/usr/lib64` 视图)里找库, +不需要改 glibc。改 glibc 只为 `ld.so.conf` / cache 这一半(§7)。 + +### 2.4 "生态里的包不依赖宿主":实测(第二轮) + +样本:在临时 home 里安装 glibc、make、xz、zlib、ncurses、openssl、curl、perl、python、cmake、util-linux、busybox、gcc +(连同依赖共 27 个 payload,1.4 GB)。"空根"指一个 bwrap:只有这个 home(同一路径)、`/proc`、`/dev`,**没有 `/usr`、 +`/lib`、`/etc`**;探针用 busybox 的 `sh`、`grep`。 + +**运行期:成立。** + +| 检查 | 结果 | +|---|---| +| payload 里的 ELF 按形态分类(Part 1 之前的闭包设计:H = 宿主 loader,X = 我们的 loader) | X 可执行 88、静态 4、共享库 356、其他 22;**形态 H 的可执行文件:0** | +| 88 个形态 X 可执行文件,在空根里用 payload loader `--list` 解析闭包 | **88 / 88 全部解析在 home 内**,没有 `not found`,没有任何 home 之外的路径 | +| 在空根里运行 | python(`import ssl, ctypes, lzma, sqlite3`;OpenSSL 3.5.5)、cmake 4.4.2、make 4.3、perl 5.44.0、openssl 3.1.5、xz 压缩往返:**全部 exit 0** | +| python 进程的 `/proc/self/maps` | home 之外的映射:**0** | +| gcc / g++ 编译并运行 hello(C、C++) | 根里有 `/bin/sh` 时通过;没有时失败(见下 G2) | +| 静态 xlings 在空根里 `self init`(只多绑一个 `/etc/resolv.conf`) | 成功:索引获取、构建都不需要宿主 | +| payload 的 `RUNPATH` / `RPATH` 里有构建机路径吗 | **没有**。构建机路径 `/home/xlings/.xlings_data` 出现在 204 个文件里,都是调试字符串、`.la`、binutils 的链接脚本(`=` 前缀,按 sysroot 解析) | + +**安装期:不成立。** 用 home 在 `/xlings` 的空根执行 `xlings install xz cmake`: + +| # | 发现 | 位置 | 结论 | +|---|---|---|---| +| G1 | hook 依赖宿主工具:libxpkg 用 `sh -c` 执行命令;glibc 的 install hook 用 `io.popen("find …")`,expat / fontconfig 的 config 用 `cp -a`;303 个 recipe 里 130 个调用外部命令(`chmod`、`bash`、`ls`、`tar`、`find`、`grep`……) | libxpkg + recipe | 空根里 glibc、expat、fontconfig 安装失败 | +| G1b | elfpatch 依赖 PATH 上的 `patchelf`;找不到时 `skip patching`,**包仍报告"已安装"** | installer | 一次静默成功:装出来的 payload 没有被 patch | +| G2 | 经 shim 调用 `gcc`,根里没有 `/bin/sh` 时静默返回 255;直接调用 payload 的 gcc 则报 binutils 的 `xlings-wrappers/ld`(`#!/bin/sh` 脚本,还用到 `dirname`)无法执行 | shim / binutils recipe | R 里有 `/bin/sh`,但 shim 和受管包装脚本不应依赖根里的 shell | +| G3 | perl 5.44.0 的 29 个脚本 shebang 是 `/build/stage/bin/perl`:`perldoc`、`pod2man`、`cpan`、`prove`……**在任何机器上都无法执行**(已在宿主上复现:`required file not found`) | perl recipe | 生态缺陷,与 R 无关,单独修 | +| G4 | 没有 `/bin/xlings` 时 gcc 的 config 调 `/bin/gcc-specs-config`(shim)失败,gcc 安装失败 | §2.1 的同一个根因 | C28 的优先级再提高 | +| G5 | payload 的 `RUNPATH` 写死了 `/subos/default/lib` | elfpatch | 在嵌套的 R 视图里 `/xlings/subos/default` 就是实例自己(§3.3),这个路径正好成立;**必须保持这个映射** | +| G6 | 脚本解释器:`/bin/sh` 27、`env python3` 28、`/bin/bash` 4、`env perl` 3、`env bash` 1 | 各包 | 根里必须有 `/bin/sh`、`/bin/bash`、`/usr/bin/env`:tiny 的最低要求 | + +结论:**包本身已经可以脱离宿主运行;装包的过程还离不开宿主的工具。** 由此确定的做法(C30a,实施时第三轮修订): + +- **安装期的工具从"这个根"来,不从 xlings 自带**:在 U / S / M 机器上往一个前缀域装包时,bwrap 把宿主的 `/` 只读挂入、 + 把前缀域 home 绑到它的逻辑路径(§4)—— hook 照常用宿主的 `sh`、`find`、`cp`,装出来的路径是逻辑路径;在 R 里装包, + 工具来自 R 自己的 `/usr`。所以 tiny 必须包含 `sh`、coreutils 类工具和 patchelf(busybox + 静态 patchelf), + DOM-BOOTSTRAP 在 tiny 的容器里验证。不另做一套"安装工具集":那会让 hook 在 U 模式下换一套工具,行为与今天不同。 +- **elfpatch 找不到 patchelf 时自动安装 `xim:patchelf`**(上游预编译版本是静态的,不需要被 patch),然后继续;仍然拿不到时 + 这个包**安装失败**并说明原因,不再 `skip patching` 后报告"已安装"。 +- shim 的 alias 展开在进程内完成,不经过 `/bin/sh`;binutils 的 ld 包装脚本改成不依赖 `dirname`(跨仓库)。 + +--- + +## 3. 核心模型 + +### 3.1 SubOS = 内容 × 呈现方式 + +| 呈现方式 | 进程看到的 | 谁用 | +|---|---|---| +| **PATH 叠加**(已有) | 宿主的根;SubOS 的 `bin/` 在 PATH 前面 | U/C/P/S/M 下的 shell 级进入 | +| **沙箱视图**(已有) | 宿主的 `/usr` 只读挂入,叠加 SubOS 的工具、home、`etc-neutral` | `--sandbox` | +| **根呈现**(新) | SubOS 本身就是 `/`:`/usr` 是它的物化视图,`/lib64/ld-linux…` 是它的 loader | R 的宿主;`--rootfs` 实例;导出的镜像 | + +内容不变,呈现方式可以换:同一个 SubOS 可以 PATH 叠加地用、沙箱里用、作为 rootfs 实例进入、导出成镜像、 +作为宿主启动。**测试的核心不变量**:同一份声明,在每一种根呈现(bwrap 实例、chroot、容器、启动)下得到的树完全一致(§14)。 + +### 3.2 底座与分层 + +所有形态都有一个底座,分层规则(Part 1 §5)在所有形态下一致: + +``` +底座(宿主) → 系统层 /xlings(M、R) → 用户层 ~/.xlings → 实例层 +``` + +| 形态 | 底座 | 底座归谁管 | +|---|---|---| +| U / C / P / S / M | 别人的发行版 | 不管理;只读;就是现在的"宿主" | +| R | `default` 的根呈现 | xlings | + +三条分层规则不变:读和激活可以跨层;获取只写自己的层(没有可写层的交给 broker);上层永远不引用下层。 + +### 3.3 嵌套:`fetch=layer` 的答案 + +`--rootfs` 实例里的 xlings 看到的就是 R 模式:它是这个根的包管理器,`/xlings/subos/default` 就是这个实例本身。 +"沙箱里的 xlings 把包装进实例自己的层"(Part 1 §8.2 的 `layer`)因此不再是单独的设计:就是在这个根的系统作用域里装包。 +PERM-FETCH-LAYER 与 HOME-LAYER-RESOLVE 由同一套查找机制解决(§10)。 + +### 3.4 角色:`host` 是运行时事实,不是名字 + +哪个 SubOS 现在是 `/`,它就带 `host` 角色。`default` 是首次安装的那个、也是默认启动项,但任何根呈现的 SubOS +都可以被启动成 host(§8)。模型里实例有 `kind`(`view` / `rootfs`)和运行时的 `role`(`host` 或无), +**每个子命令对照一张允许操作表**,不在各命令里各自判断(§8.3)。 + +--- + +## 4. 前缀域(这一节决定 home 放在哪里) + +recipe 会把真实的 `install_dir()` 写进装出来的文件(gcc specs、pkg-config、rpath、glibc 的 255 字节重定位前缀; +见 `src/core/xim/payload.cppm:262`),elfpatch 把 `PT_INTERP` 写成 payload 的绝对路径。所以: + +> **payload 只能在同一个前缀下共享。** 把装 payload 时 home 的逻辑路径叫作它的**前缀域**。 + +前缀域就是 home 的逻辑路径。R 支持两种布局(D1): + +| 布局 | 系统 home | 谁拥有 | 适合 | 用户自己的包 | +|---|---|---|---|---| +| **multi**(推荐) | `/xlings`,root 拥有 | root | 多用户机器、服务器、官方 Luban 镜像 | 各自的 `~/.xlings` 叠在上面(M 的规则) | +| **single** | root 安装到 `/home//.xlings`,`` 拥有 | ``(**等价于 root**) | 个人机器、开发容器、WSL 里的个人发行版 | 就是系统 home 本身,不分层 | + +- single 布局下 `/usr` 链接进一个用户可写的目录,那个用户改得了 root 运行的程序,所以**它就是 root**(与 Homebrew 在 macOS + 上的取舍相同)。`self doctor` 在 single 布局的机器上发现别的可登录用户时报警告,建议改用 multi。 +- single 布局要求系统 home 在 stage-0 时可达:`/home` 在根分区上,或者 stage-0 按 `/etc/fstab` 先挂好它(§8.2)。 +- 布局写在 `.xlings-home`:`{ "mode": "root", "layout": "multi" | "single" }`;stage-0 从 `/etc/xlings/root.json` + (机器配置,`{ "home": "/xlings" }`,缺省就是 `/xlings`)找到它。 + +前缀域决定共享: + +| 前缀域 | 逻辑路径 | 谁在里面 | +|---|---|---| +| 用户域 | `~/.xlings` | U/C/P/S 的包;R-multi 机器上用户自己装的包;**R-single 的整个系统** | +| 系统域 | `/xlings` | M 的系统层、R-multi 的宿主、多用户的 rootfs 实例、官方镜像 | + +由此: + +- **系统层(M)也放在 `/xlings`**,不再是 Part 1 的 `/opt/xlings`。`/opt/xlings` 是本 PR 的 C26 引入的、 + 还没发布,直接改,不需要兼容。好处:同一台机器上 M 的系统层、rootfs 实例、R 宿主共用一个 store。 +- **U 机器上的 `--rootfs` 实例默认用用户自己的 home 作前缀域**:实例里 home 挂在同一路径,用户已经装过的 payload + 直接共享,不用重新下载。`--domain /xlings` 显式选择系统域(要导出成多用户镜像时);机器上没有 `/xlings` 时,系统域的 + 物理位置是 `/domains/xlings/`,只在 bwrap 里挂在 `/xlings`;有 `/xlings`(M 或 R)时只读复用它,用户私有域叠在上面。 +- 导出的镜像保留它的前缀域:single 布局的镜像里系统 home 就在 `/home//.xlings`。 +- **在根里面安装,不在外面拼路径**:往系统域装包,一律在 bwrap 里执行 —— 目标树绑成 `/`,域 home 绑在 `/xlings`, + 实际 rootfs provider 在 user namespace 里映射成 uid 0,静态 musl 的 xlings 绑到 `/xlings/bin/xlings`。 + 私有域的准备、依赖安装编排与 runtime 控制器将同一宿主 owner 映射为非零 UID/GID 1000, + 让只读来源的 metadata/config worker 和实际 root provider 都能再创建自己的 namespace; + 不保留任何 capabilities。装出来的路径天然就是根里的路径, + **所有 recipe 一行不改**。hook 需要的工具来自安装工具集(C30a,§2.4),不来自宿主。没有 user namespace 的机器 + (Ubuntu 24.04 未修复)给出 Part 1 的 `self doctor --isolation --fix`。 +- **store 在实例里只读可见**,与 Part 1 的沙箱一致("沙箱里能看到 xlings home,但只读")。只挂入闭包(看不到别的实例装了什么) + 需要逐个 payload 绑定和闭包计算,作为同一个规则的收紧放在 C35 之后(ROOT-STORE-CLOSURE,§14)。 + +为什么多用户用 `/xlings` 而不是 `/var/lib/xlings`(初稿的倾向):它在任何发行版上都不和别人的 FHS 冲突(U 机器上 +rootfs 实例也能用它),路径短(重定位前缀有 255 字节上限),并且不随发行版习惯改变。 + +--- + +## 5. 部署形态(修订 Part 1 §4) + +| 形态 | entry | home | 前缀域 | 更新渠道 | `.xlings-home` 的 `mode` | +|---|---|---|---|---|---| +| U 用户 | `~/.xlings/bin/xlings` | `~/.xlings` | 用户域 | `self update` | `user` | +| C 自定义 | `/bin/xlings` | 任意 | 用户域 | `self update` | `user` | +| P 便携 | `/bin/xlings` | entry 所在树 | 用户域 | `self update` / `self relocate` | `portable` | +| S 系统包 | `/usr/bin/xlings`(别人的包管理器) | 每个用户的 U | 用户域 | 系统包管理器 | `user` | +| M 多用户系统层 | 同 S | S + `/xlings` | 用户域 + 系统域 | 同 S;管理员 `sudo xlings install --system` | `/xlings` 上是 `system-layer` | +| **R 根(multi)** | `/usr/bin/xlings → /xlings/bin/xlings` | `/xlings`;用户另有 `~/.xlings` | 系统域 + 用户域 | **`sudo xlings self update` = 在宿主 SubOS 里装 `xim:xlings`** | `root` / `multi` | +| **R 根(single)** | `/usr/bin/xlings → /home//.xlings/bin/xlings` | `/home//.xlings` | 用户域 | `xlings self update`(`` 等价于 root) | `root` / `single` | + +**S 的修复**:从系统 entry 运行、而用户 home 没有 `bin/xlings` 时,`self init` / 首次安装 / `doctor --fix` 在用户 home +建 `bin/xlings → <系统 entry>` 的**符号链接**。包管理器升级本体后所有用户跟着升级,不会出现两个 xlings;`self update` +照样拒绝(实际执行的文件仍是系统 entry);`--user` 显式换成用户自己的副本。doctor 把"有系统 entry、home 里没有链接"报成 +可修复的错误,而不是 `10 missing`。 + +**R 下的 `self update`**:xlings 本身就是宿主 SubOS 里的 `xim:xlings` 包,`self update` 需要 root,走宿主 SubOS 的 +generation 切换(§6.3),可以回滚。`describe_entry` 识别"我是这个根的包管理器"(`/xlings/.xlings-home` 的 `mode: root`), +不再提示"交给系统包管理器"。 + +--- + +## 6. 根呈现 + +### 6.1 一棵根里什么归 SubOS,什么归机器 + +| 路径 | 归属 | 说明 | +|---|---|---| +| `/usr` | **SubOS**(只读视图) | 由宿主 SubOS 的当前 generation 物化:`/usr` → `<系统 home>/subos//root/usr` | +| `/bin` `/sbin` `/lib` `/lib64` | 固定链接(merged-usr) | `→ usr/bin`、`usr/bin`、`usr/lib`、`usr/lib64`;切换 SubOS 时不动 | +| `/etc` | **机器** | 真实目录;SubOS 只提供默认值(`/usr/share/factory/etc`)和声明(用户、组、tmpfiles) | +| `/home` `/root` `/var` `/srv` | **机器** | 用户数据;切换宿主 SubOS 时保留 | +| `/xlings`(single 布局:`/home//.xlings`) | 系统 home | store、各 SubOS、`boot.json`、审计 | +| `/etc/xlings/root.json` | 机器 | 系统 home 在哪(stage-0 的锚点) | +| `/proc` `/sys` `/dev` `/run` `/tmp` | 运行时 | stage-0 或容器运行时挂载 | + +同一份内容在 rootfs 实例里呈现时,"机器"那几项来自实例自己的目录(`subos//etc`、`home/`、`var/`),规则相同。 + +### 6.2 物化:直接链接(D2) + +- `usr/bin/`、`usr/lib/`、`usr/share/...` 是**直接指向 payload 的链接**,不经过 shim:PID 1、`/bin/sh`、每一次 + fork + exec 都不能先经过 xlings,xlings 坏了系统也要能启动。 +- 视图里有什么,按普通发行版的语义:**一个包的 `bin/` 就是它放进 `/usr/bin` 的东西,`lib/` 里的 `lib*.so*` 就是它放进 + `/usr/lib` 的东西**。先放工作区里激活的程序和库(xvm 的注册,名字以注册为准),再放各激活包 payload 的 `bin/`、`lib/`、 + `lib64/` 里其余的可执行文件和共享库(busybox 的 applet 链接就是这样进入 `/usr/bin` 的)。同名时注册的优先,其次按工作区顺序 + 的第一个声明者;冲突写进这一代的清单,不静默覆盖。 +- 带 alias 参数的程序(例如 gcc 的 `--sysroot=${XLINGS_DYNAMIC_SUBOS_DIR}`)链接到 xlings 的 shim:参数要在执行时展开。 + 启动关键路径上的程序(`sh`、init、coreutils)没有 alias,都是直接链接。 +- `usr/include` 链接到这个 SubOS 的 sysroot 头文件目录(现有的 sysroot 模型);`usr/lib64 → lib`、`usr/sbin → bin`。 +- xlings 自己是静态 musl 的,不依赖 glibc:glibc 坏了,`/xlings/bin/xlings` 仍能把系统切回上一代(这条是 R 的稳定性基础, + 有专门的测试,§14)。 + +### 6.3 generation:可原子切换的物化树 + +`profile` 现在只记录选择。根呈现需要**物化树**: + +``` +/xlings/subos//generations//usr/... 每一代一棵完整的视图(只含链接,很小) +/xlings/subos//root/usr → generations//usr 当前代:一个符号链接,rename(2) 原子替换 +``` + +- 包变动(install / remove / use)= 生成新的一代 + 原子替换指针;**宿主 SubOS 上这就是"在线升级"**,与传统发行版的 apt 一样即时生效。 +- 回滚 = 指针指回上一代(`xlings subos rollback [--to k]`,宿主上需要 root)。 +- 旧一代引用的 payload 在这一代被回收前不会被 GC;正在运行的进程映射着的文件不受影响(文件本身没被删)。 +- 现有的 `profile` 记录作为每一代的元数据保留,`rollback` 改为切指针,不再重跑 `use`。 + +### 6.4 `/etc`、用户与组 + +- 包把默认配置放在 `usr/share/factory/etc/`,用户和组声明在 `usr/lib/sysusers.d/*.conf`(systemd 的格式,不依赖 systemd)。 +- 每次切换 generation / 启动时:缺的 `/etc` 文件从 factory 复制,**已有的永不覆盖**;sysusers 只追加缺的用户和组; + 然后跑 ldconfig(§7)。这一步由 xlings 执行(静态二进制,不依赖被切换的那一代)。 +- 机器的 `/etc` 是用户数据,适用 AGENTS.md 的 "SubOS user data" 规则。 + +--- + +## 7. 库搜索(D3 的答复):两个都支持,按消费者分 + +不是一个用户可选的开关。一个进程里有两类二进制,它们需要的东西不同: + +| 消费者 | `PT_INTERP` | 搜索 | 读 `ld.so.cache` / `ld.so.conf` | +|---|---|---|---| +| **受管**(xlings 的包,elfpatch 过) | payload 的 loader | `RUNPATH` 闭包;默认目录 = payload 的 `lib/` | **永远不读**(与今天一致,hermetic) | +| **外来**(第三方预编译程序、wheel、厂商驱动、用户自己编译的) | `/lib64/ld-linux-x86-64.so.2` | 默认目录 = `/lib64` = 合并后的 `/usr/lib64` 视图(**今天已经如此**,§2.3) | **读这个根的 `/etc/ld.so.cache`**,由 `/etc/ld.so.conf(.d)` + ldconfig 生成 | + +这样受管包在 U / M / R / 实例里行为完全一样;外来程序在 R 里看到的是一个普通发行版(统一视图覆盖绝大多数情况, +`ld.so.conf` 覆盖 `/opt//lib`、NVIDIA 驱动这类装在别处的库)。 + +**需要的 glibc 改动**(xlings-res,新 revision):今天 cache 和 preload 的路径是编译进去的 payload `etc/`(§2.3)。 +改成与 `default-dir-follows-loader` 同一条规则:**sysconfdir = 调用路径的目录 `/..` `/etc`(字面,不解析链接)**。 + +- payload 路径调用:`/lib64/../etc` = `/etc`,与今天相同(恒等变换); +- `/lib64/ld-linux…` 调用:`/lib64/../etc` = `/etc`,即这个根自己的 `/etc/ld.so.cache` 和 `/etc/ld.so.preload`,与普通发行版的语义相同; +- U 机器上没有任何东西经 `/lib64` 调用我们的 loader(那是宿主的 glibc),不受影响;rootfs 实例里则是实例自己的 `/etc`。 + +ldconfig 用宿主 SubOS 当前代的 glibc 自带的 `ldconfig`,在 generation 切换和启动时运行(§6.4)。 + +--- + +## 8. 宿主 SubOS 与启动(D7) + +### 8.1 启动项 + +```jsonc +// <系统 home>/boot.json(multi 布局即 /xlings/boot.json)—— 唯一写者:subos 模块;只有 root(single 布局:)能写 +{ + "default": "default", // 默认启动的 SubOS + "once": null, // 只用于下一次启动(试启动) + "fallback": "default", // 启动失败时回到这个 + "tries": { "core-2026.11": 1 } // 启动计数:试启动没有被确认就回退 +} +``` + +```bash +sudo xlings subos boot core-2026.11 --once # 下次启动试一次;没有确认就回到 default +sudo reboot +xlings subos status # 显示 host = core-2026.11 (trial) +sudo xlings subos boot core-2026.11 # 确认:之后默认启动它 +xlings subos list # default (rootfs) · core-2026.11 (rootfs, host, boot default) +``` + +- **运行时修改启动项,重启生效**(维护者的模型)。宿主 SubOS 自己的包变动则是在线生效的(§6.3)——两件事分开: + "换一个发行版"在启动时发生,"在当前发行版里升级"随时发生。 +- 典型用法:`subos new trial --rootfs --from default` 克隆当前系统(payload 共享,几乎不占空间)→ 在 trial 里升级、 + 作为 rootfs 实例跑你的工作负载 → `subos boot trial --once` → 重启验证 → 确认。这就是初稿里的 `apply`,但不复制、不替换 + 正在运行的系统。 +- 不重启的切换(不换内核、只重启用户态,相当于 systemd 的 soft-reboot)依赖 init 的支持,作为 `subos boot --now` + 在 init 支持时提供,否则拒绝并说明(C42,§17.1)。 + +### 8.2 stage-0:xlings 作为最早的用户态 + +内核参数 `init=/xlings/boot/init`(`→ /xlings/bin/xlings`,多调用名 `init` 进入 stage-0)。xlings 是静态的, +不依赖任何一代 SubOS: + +1. 挂载 `/proc` `/sys` `/dev` `/run`;读 `/etc/xlings/root.json` 找到系统 home(缺省 `/xlings`);single 布局且 home 不在根分区上时, + 按 `/etc/fstab` 先挂好它; +2. 读 `/boot.json`,按 `once` / 计数 / `fallback` 选出宿主; +3. 把 `/usr` 指向它的当前代(§6.1);factory `/etc`、sysusers、ldconfig(§6.4); +4. 记录 `boot` 事件到 `/xlings/logs/boot.ndjson`; +5. `exec` 这个 SubOS 声明的 init(`boot.init`:busybox init、systemd、LubanOS 的服务管理器……)。 + +启动成功由服务管理器在启动完成后执行 `xlings subos boot --mark-good` 确认;没有确认就按计数回退。 +容器(docker、WSL)里没有 stage-0:导出时就把 `/usr` 指好(§11),也可以把 `/xlings/boot/init` 作为容器的入口。 + +### 8.3 `host` 角色的特殊规则(强制执行) + +| 操作 | 普通实例 | `host`(当前是 `/`) | 启动项(`default` / `fallback` / `once`) | +|---|---|---|---| +| `use` / `exec` 进入 | 可以 | `use` = 回到宿主;`exec` = 在宿主上执行 | 可以(作为 rootfs 实例) | +| 隔离策略 | dev / private / locked | 不适用(最外层;它给子实例授权) | 作为实例时适用 | +| `remove` / 重置 | 确认后可以 | **永远拒绝** | **拒绝**,先改启动项 | +| 包变动 | owner 或 broker | 只有 root;新的一代 + 原子切换 | 同普通实例 | +| 身份 | 中性(`user` / 实例名) | 真实用户与主机名 | 作为实例时中性 | +| 用户数据 | 实例的 `home/` | 机器的 `/etc` `/home` `/var` `/root` `/srv` | — | +| GC | 只回收不被引用的 | 也不回收**任何一代**、任何启动项引用的 payload | 同左 | + +这张表就是代码里的允许操作表;测试对每个 subos 子命令、每种角色各跑一遍(§14 的 ROOT-ROLE-TABLE)。 + +--- + +## 9. Luban(D5) + +**Luban = kernel + xlings + LubanOS 服务(可选)。** 一款 Luban 发行版是一个可以作为宿主启动的 SubOS: + +- **版本即 subos 类型的 xpkg**:`subos:luban-tiny`、`subos:luban-core`、`subos:luban-desktop`(分级名字待定)。 + 包里是工作区声明(基础工具和运行时)、`boot` 声明(内核包、init)、平台锚点(glibc / gcc 等一组版本, + 沿用 `2026-08-05-ecosystem-three-tier-and-composable-distro.md` 的 platform manifest)。 +- **分级叠加**:desktop `from` core,core `from` tiny。subos 类型 xpkg 增加 `from` 字段,fork 时按链合并工作区, + 下层的版本被上层覆盖。 +- **定制自己的发行版**,和定制一个 SubOS 是同一组命令: + +```bash +xlings subos new mydistro --rootfs --from subos:luban-core@2026.10 +xlings subos exec mydistro -- xlings install nginx postgresql +xlings subos export mydistro --oci mydistro.tar # 容器镜像 +xlings subos export mydistro --disk mydistro.img # 可启动的磁盘镜像(ext4,内核 + init= 已就位) +xlings subos pack mydistro --as myns:mydistro@1.0 # 打成 subos 类型 xpkg,发布到你自己的索引 +# 别人:xlings subos new x --rootfs --from myns:mydistro@1.0 +``` + +- **第一版官方分级**(D4:缺的包补到 xim-pkgindex): + +| 分级 | 内容 | 本 PR 的验证 | +|---|---|---| +| tiny | 内核、xlings、glibc、bash、coreutils、util-linux(最小)、ca-certificates、用户生成、一个最小 init | 容器、chroot、rootfs 实例、qemu 启动到 shell | +| core | tiny + 服务管理、网络、ssh、sudo / shadow、编译工具链(gcc / binutils / make) | 容器、rootfs 实例、qemu 启动后网络可用、在里面编译程序 | +| desktop | core + 图形栈(已有的 mesa / wayland / X 相关包)、字体、音频 | 容器里冒烟 + 无头离屏渲染(llvmpipe 渲染一帧并校验像素);图形会话放到之后(§17.1) | + +--- + +## 10. M:多用户系统层(修订 C26) + +- `sudo xlings install --system `:装进 `/xlings`(系统域),在系统层的 `default` 里激活。 +- 用户解析:读和激活可以跨层 —— 用户 home 的工作区可以选择系统层的 payload,**不复制**(HOME-LAYER-RESOLVE)。 + 用户自己装的同名包优先;用户写不了系统层(root 拥有,0755)。 +- 前缀域的约束:系统层 payload 的路径前缀是 `/xlings`,用户层是 `~/.xlings`,两者各自成立;激活只是链接,不涉及重定位。 +- `fetch=layer`(沙箱里的 xlings 装进实例自己的层)与这一节用同一个查找函数:实例层 → 用户层 → 系统层。 +- R 机器上的普通用户就是 M 的用户:系统是 `/xlings`,自己的是 `~/.xlings`。 + +--- + +## 11. 使用面(新增 / 修改的命令) + +| 命令 | 作用 | +|---|---| +| `subos new --rootfs [--from ]` | 建一个根呈现的实例;`--from default` 克隆当前系统 | +| `subos new --rootfs --domain /xlings` | 用系统域(要导出成多用户镜像时);缺省是当前 home 的前缀域,共享已经装过的 payload | +| `sudo xlings self install --root [--layout multi\|single]` | 把一台机器(或一棵根)初始化成 R:写 `/etc/xlings/root.json`、系统 home、`default` 的根呈现 | +| `subos use/exec `(rootfs 实例) | 以它为根进入;策略、broker、审计照常;里面 `id -u` = 0(user namespace 映射) | +| `subos export --rootfs \| --tar \| --oci \| --wsl \| --disk ` | 导出:闭包里的 payload 复制到 `/xlings`;`--disk` 生成 ext4 镜像(`mkfs.ext4 -d`,不需要 root) | +| `subos pack --as ` | 打成 subos 类型 xpkg | +| `subos rollback [--to k]` | 切回某一代 | +| `subos boot [--once] [--now]` / `subos boot --mark-good` | 启动项 | +| `subos diff ` | 两个 SubOS 差在哪些包和版本 | +| `sudo xlings install --system ` | M:装进系统层 | +| `subos list` / `status` | 标注 `kind`、`role`、启动项、当前代 | + +interface 同步:`subos_new` 的 `rootfs`、`subos_export`、`subos_boot`、`subos_rollback`;agent 模式下不会停下来等输入。 + +--- + +## 12. 安全与用户数据 + +- 实际 rootfs 视图在 user namespace 里映射 uid 0;私有域管理和构建编排使用映射到同一 owner 的非零 UID, + 所有子 worker 与 root provider 继续清除 capabilities(多个 uid 用 `newuidmap` + `/etc/subuid`,没有就只映射一个), + **不需要 setuid,不需要 root**(Part 1 §20 已确认的前提)。 +- 宿主角色的拒绝规则(§8.3)由允许操作表强制;`subos remove` 遇到启动项拒绝。 +- 机器状态(`/etc` `/home` `/var` `/root` `/srv`)和 rootfs 实例的同名目录都是用户数据;`subos export` 默认**不**包含 + `home/` 与 `var/`(`--with-data` 显式要求时才包含,并提示)。 +- `boot.json`、每一代、stage-0 的选择都写审计事件;stage-0 的日志在 `/xlings/logs/boot.ndjson`。 + +--- + +## 13. 跨平台 + +| 平台 | 根呈现 | +|---|---| +| Linux | 本文全部 | +| Windows | `subos export --wsl` → `wsl --import`:Luban 跑在 WSL 里就是 R 模式;本机上仍是 home 重定向(Part 1) | +| macOS | 不提供;gate `RootfsRuntime` 报 unavailable,路线是轻量 VM(Part 1 附录 A) | + +--- + +## 14. 需求与测试 + +新增需求 ID(进 `tests/requirements.toml`,全部 `required`): + +| ID | 内容 | 车道 | +|---|---|---| +| DEPLOY-S-ENTRY | 系统 entry:用户装包后能直接运行;包管理器升级后跟随;`self update` 拒绝、`--user` 放行 | isolation-fix(sudo) | +| DEPLOY-S-AUR | 真实 AUR 包(当前构建):两个用户各自装包、运行 | arch(容器,PR 触发) | +| DEPLOY-M-SYSTEM | `install --system`;用户不复制地使用;用户覆盖;用户写不了系统层 | isolation-fix(sudo) | +| HOME-LAYER-RESOLVE / PERM-FETCH-LAYER | 由 deferred 改为 required | L3 + rootfs | +| DOM-PREFIX | 被搜索的位置(`PT_INTERP`、`RPATH`/`RUNPATH`、`.pc`、`.la`、无 `=` 前缀的链接脚本、shebang)里只有本前缀域的路径;构建机路径一个也没有(G3 的 perl 就是反例) | rootfs + L1(扫描器) | +| DOM-BUILD-INSIDE | 装进系统域一律在根里执行;不改 recipe | rootfs | +| DOM-BOOTSTRAP | 空根(没有 `/usr`、`/etc`)里 `install` tiny 的全部包成功;hook 只用安装工具集 | rootfs | +| DOM-ELFPATCH-FAILCLOSED | elfpatch 不可用时安装失败并说明原因,不报告"已安装" | L3 | +| DOM-LAYOUTS | multi(`/xlings`)与 single(`/home//.xlings`)两种布局各构建、导出、启动一次 | container + boot | +| ECO-HOST-INDEPENDENT | §2.4 的检查变成测试:tiny / core 的每个形态 X 可执行文件在空根里闭包完整;代表程序在空根里运行,`/proc/self/maps` 里没有根外路径 | rootfs | +| SHIM-NO-SHELL | shim(含 alias 展开)在没有 `/bin/sh` 的根里也能分发 | L3 + rootfs | +| ROOT-PROJECT | 根呈现的树:merged-usr 链接、`/usr` 指向当前代、直接链接无 shim | rootfs | +| ROOT-SAME-TREE | 同一份声明在 bwrap 实例、chroot、容器、启动下的树完全一致 | rootfs + container + boot | +| ROOT-GEN-ATOMIC | 切换是一次 rename;中途杀掉进程,指针只会是旧的或新的 | rootfs | +| ROOT-ROLLBACK | 回滚切指针,不重跑 `use` | rootfs | +| ROOT-SURVIVES-GLIBC | glibc 被破坏后,静态 xlings 仍能回滚并恢复 | container | +| ROOT-ETC-FACTORY | factory 只补缺;已有 `/etc` 永不覆盖;sysusers 只追加 | rootfs | +| ROOT-LIBSEARCH | 受管二进制不读 cache;外来二进制经 `/lib64` 用统一视图和本根的 `ld.so.cache` | container | +| ROOT-NO-HOST | 根里进程的 `/proc/self/maps` 没有任何宿主路径 | rootfs + container | +| ROOT-ROLE-TABLE | 每个 subos 子命令 × 每种角色,按允许操作表接受或拒绝 | L3 | +| ROOT-SELF-UPDATE | R 下 `self update` = 宿主 SubOS 的新一代,可回滚 | container | +| INST-ROOTFS | `--rootfs` 实例:uid 0 映射、看不到宿主 `/usr`、策略 / broker / 审计照常、只看到自己的闭包 | rootfs | +| EXPORT-OCI / EXPORT-WSL / EXPORT-DISK | 导出物能被 docker / `wsl --import` / qemu 使用 | container / windows / boot | +| BOOT-STAGE0 | qemu 启动:stage-0 选择宿主、`/usr` 就位、exec init、打出标记 | boot | +| BOOT-ONCE-FALLBACK | 试启动未确认 → 下次回到 fallback;确认后成为默认 | boot | +| BOOT-NOW | `subos boot --now` 在声明支持的 init 下切换用户态;其余 init 拒绝并说明 | boot | +| LUBAN-DESKTOP-RENDER | desktop 在容器里离屏渲染一帧,像素校验通过 | container | +| LUBAN-TINY / LUBAN-CORE | 两个分级从 xpkg 创建、导出、启动、在里面装包 | container + boot | +| LUBAN-FROM-CHAIN | `from` 链合并工作区,上层覆盖下层 | L3 | + +CI 车道(Part 1 §24.5 的架构,新增三条): + +| 车道 | 环境 | 内容 | +|---|---|---| +| **rootfs** | ubuntu runner + bwrap(C21 修复后) | 构建 tiny / core 的 rootfs 实例;ROOT-* 里不需要容器和启动的部分 | +| **container** | ubuntu runner + docker | `export --oci` → `docker import` → `docker run --network none` 里跑 ROOT-* / LUBAN-* | +| **boot** | ubuntu runner + qemu(有 `/dev/kvm` 就用,否则 TCG) | `export --disk` + 内核包 → `qemu -kernel … init=/xlings/boot/init`,串口里断言标记;试启动与回退 | +| isolation-fix(已有) | sudo | S、M | +| arch(修复) | archlinux 容器,PR 触发 | AUR 包 | +| windows(已有,加一步) | `wsl --import`(runner 只有 WSL1 时作为尽力项:只报告不阻塞,§17.1) | EXPORT-WSL | + +网络与确定性:基础包的 payload 用 actions/cache 按索引提交缓存;PR 车道不在测试里联网(Part 1 §24.5 的规则), +索引固定在 fixture 的提交上(固定的是索引,不是 xlings 版本,不违反 "Never pin a released xlings version into CI")。 + +--- + +## 15. 性能预算(进 Part 1 §21.1 的断言) + +| 项 | 预算 | +|---|---| +| 生成一代(core,约 300 个包的链接) | ≤ 1 s | +| 切换一代(rename) | ≤ 10 ms | +| 进入 rootfs 实例(含闭包绑定) | ≤ Part 1 沙箱进入预算 + 100 ms | +| stage-0(启动到 exec init) | ≤ 200 ms | + +--- + +## 16. 交付:C28 起(同一个 PR) + +顺序按"推荐的先做"(D9):先修已经影响用户的(S),再把最小的 tiny 在 rootfs 车道里真正跑通(风险最大的一段), +然后才往上加启动、导出和分级。 + +| # | checkpoint | 依赖 | 验证 | +|---|---|---|---| +| C28 | S:用户 home 的 entry 链接;doctor 报告与修复(同时修好 G4:gcc 在这种 home 里装不上) | — | DEPLOY-S-ENTRY | +| C29 | AUR 包更新到当前 release 流程;Arch CI 在 PR 上触发 | — | DEPLOY-S-AUR | +| C30 | 前缀域:两种布局、`--domain`、用户私有系统域、在根里安装的执行器 | — | DOM-PREFIX / BUILD-INSIDE / LAYOUTS(构建部分) | +| C30a | 在前缀域里安装(宿主 `/` 只读 + 域 home 在逻辑路径);缺 patchelf 自动安装 `xim:patchelf`,仍失败则包安装失败;shim 的 alias 在进程内展开 | C30 | DOM-BOOTSTRAP、DOM-ELFPATCH-FAILCLOSED、SHIM-NO-SHELL | +| C31 | 模型:`kind` / `role`、允许操作表 | — | ROOT-ROLE-TABLE | +| C32 | 根呈现物化:merged-usr、直接链接、generation 树、原子切换、回滚改为切指针 | C30 C31 | ROOT-PROJECT / GEN-ATOMIC / ROLLBACK | +| C33 | `/etc` factory、sysusers、ldconfig 钩子 | C32 | ROOT-ETC-FACTORY | +| C34 | 库搜索:`/lib64` loader 链接;glibc 新 revision 的接入(sysconfdir 跟随调用路径) | C32 + xlings-res | ROOT-LIBSEARCH | +| C35 | `--rootfs` 实例:bwrap 以根呈现进入、闭包绑定、嵌套的 R 视图、uid 0 映射 | C32 C30a | INST-ROOTFS、ROOT-NO-HOST、ECO-HOST-INDEPENDENT | +| **里程碑 A** | **tiny 在 rootfs 车道里从空根构建、进入、在里面装包、跑通 §2.4 的全部检查** | C30–C35 + xim-pkgindex 的 tiny 包 | ROOT-SAME-TREE(rootfs 部分) | +| C36 | 分层查找:M 的 `install --system`、HOME-LAYER-RESOLVE、`fetch=layer` | C30 | DEPLOY-M-SYSTEM、两个原 deferred | +| C37 | `export`(rootfs / tar / oci / wsl / disk)、`pack`、`diff` | C35 | EXPORT-* | +| C38 | R:`mode: root`(两种布局)、`self update` 走新一代、宿主角色规则 | C32 | ROOT-SELF-UPDATE、ROOT-SURVIVES-GLIBC、DOM-LAYOUTS | +| C39 | 启动:`root.json`、`boot.json`、`subos boot`、stage-0、计数与回退 | C38 + 内核包 | BOOT-* | +| C40 | Luban:subos 类型 xpkg 的 `from` 链与 `boot` 声明;tiny、core、desktop | C37 C39 + xim-pkgindex | LUBAN-* | +| C41 | CI 车道 rootfs / container / boot;文档(使用指南加"发行版"场景、隔离模型、命令参考) | 全部 | ROOT-SAME-TREE | +| C42 | `subos boot --now`(不重启内核的用户态切换),只对声明支持它的 init 开放 | C39 | BOOT-NOW | + +**跨仓库前置**(先于 #641 合入并发布): + +| 仓库 | 内容 | 阻塞 | +|---|---|---| +| xim-pkgindex | bash、coreutils(形态 X,链接 xlings 的 glibc);shadow 或等价的用户工具;一个最小 init;静态 patchelf 进安装工具集;**perl 的 shebang 修复(G3)**;binutils 的 ld 包装脚本去掉 `dirname`(G2) | 里程碑 A | +| xim-pkgindex | 内核包(第一版重新打包上游预编译内核,§17.1);`subos:luban-tiny` / `luban-core` / `luban-desktop` | C39、C40 | +| xlings-res | 上述包的构建;glibc 新 revision(sysconfdir 跟随调用路径,§7) | C34 | + +--- + +## 17. 待定项的结论与风险 + +### 17.1 第一轮待定项(D9:按推荐,推荐的先做) + +| # | 问题 | 结论 | 理由 | +|---|---|---|---| +| 1 | 分级名字 | **tiny / core / desktop**,包名 `subos:luban-tiny` 等 | 维护者的原话;三个词各自说明用途,不需要再解释 | +| 2 | desktop 的验证深度 | **容器里冒烟 + 无头的离屏渲染**(mesa llvmpipe 渲染一帧并校验像素);qemu 里起图形会话放到之后 | 离屏渲染已经能证明图形栈的闭包完整;图形会话要 virtio-gpu 和长得多的 CI 时间,收益主要在 compositor,不在 xlings | +| 3 | 不重启的切换 `subos boot --now` | **放在本 PR 最后(C42)**,只对声明支持的 init 开放,其余拒绝并说明 | 重启生效的路径(C39)先完整;`--now` 是锦上添花,依赖 init | +| 4 | 内核包 | **第一版重新打包上游的预编译内核**(选一个带 virtio 驱动、模块内置的通用内核);自己构建作为后续 | 内核与 xlings 的 glibc 无关,自己构建对本 PR 的目标没有增益,却是最长的一条构建链 | +| 5 | Windows runner 只有 WSL1 | **接受作为尽力项**:WSL1 上导入成功、在里面跑 tiny 的检查;失败只报告不阻塞,直到有 WSL2 的 runner | WSL1 是系统调用翻译,不能代表 WSL2 的行为,硬性阻塞会测出与产品无关的问题 | +| 6 | 引导器 / 装到真实硬盘 | **Luban 的安装器负责**;本 PR 做到 `export --disk` + qemu `-kernel` 直接启动 | 引导器与 SubOS 模型无关,属于发行版的安装体验 | + +### 17.2 风险(第二轮按 §2.4 的实测更新) + +1. **运行期的宿主独立性已经实测成立**(§2.4:88 / 88 闭包完整,代表程序在空根里运行,没有根外映射)。第一轮担心的 + "基础包能否全部链接 xlings 的 glibc 并且自洽"因此降为**新增包的工作量**问题(bash、coreutils 等按现有的形态 X 规则构建), + 不再是模型风险。 +2. **新的头号风险是安装期**:hook 依赖宿主工具(G1),而 R 里没有宿主。C30a 用自带的工具集解决,但 130 个调用外部命令的 + recipe 里可能还有工具集没覆盖的命令(例如 `sudo`、`systemctl`、`powershell`,它们属于宿主集成类的包,不进 R)。 + DOM-BOOTSTRAP 先覆盖 tiny / core 的全部包,其余包在 xim-pkgindex 的 CI 里加同一个空根检查,逐步收敛。 +3. `/usr` 是符号链接:大多数工具没问题(merged-usr 已经让 `/bin` 是链接),个别服务管理器可能检查 `/usr` 是否是挂载点。 + boot 车道会验证;不行的话 stage-0 改为 bind mount,容器仍用链接。 +4. single 布局把一个用户变成等价于 root。只在明确选择时使用;doctor 发现多个可登录用户时警告。 +5. PR 规模:#641 现在 182 个文件、+16.4k 行,这一部分估计再加 6k–10k 行,外加两个外部仓库的发布。按 checkpoint 推进, + 每个都能单独 revert;里程碑 A 之前不开始启动和分级的工作。 + +### 17.3 本轮需要 review + +1. §4 两种布局的定义,尤其是 single 布局"那个用户等价于 root"的取舍,以及 U 机器上 `--rootfs` 实例默认用用户自己的 home 作前缀域。 +2. §2.4 的结论"运行期成立、安装期不成立",以及 C30a 的做法:自带安装工具集 + elfpatch 失败即报错 + shim 不依赖 shell。 +3. §17.1 六个待定项的结论。 +4. §16 的顺序,以及里程碑 A 作为后半部分的闸门。 + +--- + +## 18. 实施计划(第三轮:维护者确认 §17.1 与 §17.3 的 1–4,开始实现) + +### 18.1 按角度拆分 + +| 角度 | 任务 | 落在哪个 checkpoint | +|---|---|---| +| 架构 | 呈现方式与 `kind`/`role` 进 `modules/subos`(纯逻辑:投影计算、启动项选择、允许操作表),文件系统动作进适配层,系统调用(mount、switch 等)进 `xlings.platform` | C31 C32 C39 | +| 稳定性 | 一代 = 一次 rename;启动计数与 fallback;静态 xlings 能在 glibc 损坏时回滚;elfpatch 不再静默跳过 | C30a C32 C38 C39 | +| 优雅简洁 | 不新增"rootfs build"概念:`new --rootfs` + `export`;投影规则就是普通发行版的语义(包的 `bin/` 进 `/usr/bin`) | C32 C37 | +| 用户体验 | 每个新命令有 agent 模式与 interface;`subos list/status` 标出 kind、role、启动项、当前代;拒绝时给出可执行的下一步 | C31 C35 C37 C39 | +| 兼容性 | 老实例默认 `kind=view`,行为不变;`subos_info.kind` 是新增键,老客户端忽略;系统层从 `/opt/xlings` 改 `/xlings` 只涉及本 PR 未发布的代码 | C30 C31 C36 | +| 跨平台 | 根呈现只在 Linux;macOS / Windows 的 `--rootfs`、`boot` 返回 unavailable 并给出路线(Windows:`export --wsl`);每个分支用 `if constexpr` 编译 | C35 C37 C39 | +| 一致性 | 根呈现的树在 bwrap 实例、容器、启动三处同一棵(ROOT-SAME-TREE);需求 ID 覆盖图不允许未覆盖 | C35 C37 C41 | +| 无感升级 | S 的 entry 链接在下一次任何命令时自动建立;`self update` 在 R 下是新一代;版本号 2026.10.6.1(发布时) | C28 C38 | + +### 18.2 依赖 + +``` +C28 ─┐ +C29 │ +C30 ─┼─ C30a ─┐ +C31 ─┴─────── C32 ─ C33 ─ C34 ─ C35 ═ 里程碑 A ─┬─ C37 ─┐ + │ ├─ C36 ├─ C40 ─ C41 ─ C42 + └──────────────── C38 ─ C39 ───────┘ │ +xim-pkgindex:patchelf 已有;busybox applet 链接;perl shebang(G3);luban-tiny/core/desktop;内核包 ─┘ +xlings-res:bash、coreutils;glibc sysconfdir revision(C34);内核(重新打包) +``` + +### 18.3 每个 checkpoint 的完成标准 + +能编译;已有测试全部通过;本 checkpoint 的需求 ID 有测试覆盖(`tests/requirements.toml`,不允许未覆盖); +文档同步(使用指南、隔离模型、命令参考自动生成);CI 全绿后再进入下一个依赖它的 checkpoint。 + +--- + +## 19. 实施记录(2026-10-06,PR #641;xim-pkgindex #929) + +| checkpoint | 落地 | 与设计的差别 | +|---|---|---| +| C28 S | 首个写命令 / `self init` / `doctor --fix` 把 home 的 entry 链到系统本体并铺好 home;shim 的属主按"自身位置、逐跳"找 | — | +| C29 AUR | PKGBUILD 只装 `/usr/bin/xlings`,去掉以 root 跑 `self uninstall` 的 remove hook;`arch-package` 每个 PR 用本次构建打包、两个用户使用 | — | +| C30 / C30a | 两种布局(multi `/xlings`、single 构建者 home 的路径);缺 patchelf 先装 `xim:patchelf`,仍没有则失败;纯词的 alias 不经 shell | **不另做 `--domain` 的 bwrap 构建器**:multi 布局在 `/xlings` 里构建(CI 用 sudo),single 用当前 home;装包始终在 home 的逻辑路径上进行,DOM-BUILD-INSIDE 由此成立 | +| C31 | `roles`:kind × role → 一张表;remove / config / cp / boot / export / rollback 都问它 | — | +| C32 / C33 | `rootfs`:投影规则、代、rename 切换、回滚、prune、merged-usr、factory `/etc`、sysusers | 冲突只在两个目标不是同一个文件时记录 | +| C34 库搜索 | 外来程序经 `/lib64` = 根的 `/usr/lib`(实测,无需改 glibc) | **根自己的 ld.so.cache 推迟**(ROOT-LDCACHE):需要 glibc 新 revision;`/lib64 = /usr/lib` 已覆盖它的用途 | +| C35 | rootfs 实例:树为 `/`、uid 0、home 只读、实例同时是嵌套根的 `default`;里面的安装经 broker、即时出现在 `/usr` | store 在实例里整体只读可见(与 Part 1 一致),闭包级绑定未做 | +| C36 M | `sudo xlings install --system`;profile v13 把系统层放在用户自己之后、宿主之前 | 解析经 PATH(不复制),不把系统层条目写进用户的版本库 | +| C37 导出 | `--rootfs` / `--tar` / `--disk`(`--tar` 即 docker / podman / `wsl --import` 的输入);`diff`、`pack` | `--oci`、`--wsl` 合并为 `--tar` | +| C38 R | `self update` 在 R 下是新一代(entry 保持为 stage-0),可回滚 | — | +| C39 启动 | stage-0、`boot.json`(once、计数、fallback、mark-good)、起不来的试启动被消耗 | — | +| C40 Luban | `subos:luban-tiny / core / desktop`(模板由 install() 写出,`from` 链合并,上层优先);bash 5.2.37、coreutils 9.5(xlings glibc 构建)、linux-kernel 6.8.0-71(Ubuntu 预编译重新打包);busybox applet 链接、perl shebang、binutils ld 包装脚本修复 | 根里编译的程序默认不在 `/usr/lib` 找库(gcc specs 用 payload loader),需要 `-Wl,-rpath,/usr/lib` | +| C41 CI | `distro` 车道(bwrap + docker + qemu/kvm + sudo):rootfs_instance、rootfs_image、rootfs_boot;`isolation-fix` 加 system_layer;`wsl-import`(WSL1,报告不阻塞) | 场景脚本用 `# xtest:` 头进入需求覆盖图 | +| C42 | `subos boot --now`:busybox init 的 `::restart:` 重新执行 stage-0,不重启内核 | — | + +本地实测(CI 之外):luban-tiny 从导出的 ext4 镜像由 Ubuntu 6.8 内核启动三次——跳过没有 init 的试启动、试启动一次、 +`--now` 切回、未确认的试启动不成为默认;bwrap 实例与启动后的机器 `/usr` 树哈希一致;luban-core 里 144 个形态 X +可执行文件的闭包在空根里完整;luban-desktop 里编译的 GL 程序用 llvmpipe 离屏渲染出期望的像素。 + + +## 20. 后续实施记录(2026-10-08,PR #641) + +§19 保留初次实现的现场。本节记录后续接线;源码存在、本地测试通过与真实隔离验收分别记录, +不能把本机缺少 bwrap 而跳过的用例视为完成。 + +| checkpoint | 后续实现 | 验收状态 | +|---|---|---| +| C30 / C30a | `new --rootfs --domain ` 由普通用户的 private namespace producer 构建;physical/recorded/logical home 分开,安装发生在实际逻辑前缀 | 本地 prefix unit 已通过;真实 producer 及生命周期路由待新 head CI | +| C34 | glibc 2.44.3 revision 2 接入 root loader 的 cache/preload;根 cache 独立生成与 ownership 检查 | x86_64/aarch64 资源、CN GET/SHA 和索引 CI 已通过;客户端真实根场景待 CI | +| C35 | owner-private RootView 只绑定精确依赖闭包、generation 和过滤后的 metadata;trusted init 捕获 user/mount/root 三 FD;broker 更新挂载后回复 | 本地闭包 unit 通过;普通/locked namespace、inode 替换与失败回滚待新 head CI | +| C36 / C37 | source facade 对限定 schema 路径做映射,来源 payload 不改写;镜像导出复制借用的完整闭包并改为镜像自有 metadata | 本地 source reader unit 通过;静态 candidate 的 DOM-SOURCE 实际隔离门禁待 CI | +| C41 | 三平台先一次构建测试再由 xdev 执行;实际能力门禁拒绝 skip;声明覆盖与同车道执行证据分开 | 完整本地回归 107 程序 pass、54 具体 case skip;新固定 head CI 尚未完成 | + +私有 domain 已接 confirmed remove、typed physical cp 和同域 fork;源码冻结后仍需真实隔离验收。 +跨前缀实例迁移不是 §4 / §16 的验收项;不同前缀不能共享绝对路径 payload。新前缀通过 +`new --rootfs --domain --from <包坐标>` 重新制作,原实例用户数据保留。设计验收仍以 +真实 namespace/source/broker/export CI 为准。性能已实测 300 payload 的 +生成/切换预算;stage-0 四次真实启动预算待 boot 车道。最终自审、合入、客户端发布和 CN +fresh install/self update 仍待完成。后续提交只追加 commit、普通 push,保留 PR 历史。 + +2026-10-08 后续 CI 修复还补齐 private dispatcher 的无感升级:创建与 runtime 共同 +刷新当前 owner 选择的客户端,不固定旧构建路径。只替换证明属于 managed mirror 的 +entry;pending/ready 与旧/新 SHA 约束中断恢复,独占 snapshot 验证后走现有入口 writer, +事务锁串行化并修复 stale shim。未知客户端、证明和 staging 均保留拒绝。 +新增六个 filesystem 用例已本地实际通过;namespace 验收仍待新 head CI。 +300 payload 的新本地测量为生成 23.034 ms、完整 checked switch 4.626 ms;保留全量 +库存及链接目标检查,原 CI 13 ms 超预算不能记为通过。 + +backend 能力探针与实际 provider 一样显式创建 user namespace;缓存须区分当前 +user/mount namespace、凭据及 seccomp 状态,不跨宿主和 producer 复用权限结论。 +动态 worker 除 canonical runtime 目标外,须保留 ELF 声明的解释器及库搜索路径; +私有 `/home` / `/tmp` 会遮蔽宿主的 alias。绑定限于入口声明的加载路径,已由 RO +视图呈现的 alias 不重复挂载,不能为修复加载问题暴露整个宿主 home。 + +私有域控制器(构建与 runtime)使用 UID/GID 1000,实际 rootfs provider 仍是 UID 0。 +Linux 5.12 起,映射 parent UID 0 要求创建者的 CAP_SETFCAP;清 caps 的 UID-0 控制器 +不能创建 metadata/config 的 Must worker,也不能创建实际 root 视图。因此控制器映射 +非零 owner,同时保持宿主 UID、文件归属和逻辑前缀,避免增加 capability。静态 DOM-SOURCE +检验真实 worker 的 UID1000/CapEff0、只读来源与导出,domain exec 检验实际 root 的 UID0/CapEff0。 +recipe 捕获输出由宿主发布到安全创建的 `logs/recipes`;拒绝符号链接目录,不给 worker +额外日志写挂载。发布目录错误独立归因,不能把合法 worker 响应误报为协议损坏。 + +root domain 的完整生命周期使用静态 release candidate 验收;dynamic 开发 client +若缺少根内已声明 runtime 闭包必须拒绝,不能为通过 fixture 扩大宿主 runtime 挂载。 +静态步骤对 source、export、producer lifecycle 三项实际 case 强制 pass,不接受 skip。 +首次 writable payload 只在已声明 managed store 下安全准备单 package 父 mountpoint, +版本内容仍由 private shadow 提交,其他 package 保持只读。系统层 fixture 从首次 index +metadata 读取起声明该层,避免把未声明策略下的 legacy setup 执行误归于受限安装。 + + +机器 `/etc` 写入使用 anchored POSIX directory/file FD,逐级拒绝 symlink;factory +目的使用 lexical 相对路径,保留真实投影叶链接。`passwd`/`group` 的默认值独立复制, +既有文件优先;sysusers 仅通过锁定的 regular、single-link FD 追加。未知账号 symlink +或共享 inode 保留拒绝,路径或写入失败返回到 generation rollback / stage0 recovery。 +外部 sentinel、路径置换和账号 hardlink 回归须实际通过,Windows 不支持该 root writer。 +release 清理 triple target 前保留静态 xdev 和已构建的四个专项程序,避免把工具缺失 +误归于 domain 功能;三项 domain 与六项静态预算仍必须实际 pass。 + +### Payloadless config 卸载发布(2026-10-08 最终回归) + +只读 hook 预检不能申请 payload write context;执行后的空 shadow 与不存在的旧 +payload 表示没有字节需要发布,不创建空版本目录。非空首次 publication 仍采用 +no-replace,已有目录保留 set-aside/rollback,未知或非目录目的地拒绝。默认 XVM +卸载只对已验证 selection 合成;实际 hook 失败保持原错误和非零退出。回归覆盖 +真实 dev worker 与无 policy 的旧 recipe,显式/裸名称 hook 恰好一次、错误版本 +不运行 hook,以及失败 hook 不报告成功。最终固定 head CI 待通过后记录发布证据。 + + +系统模板的只读slot存在只证明payload可读,不能作为当前HOME已配置的证据。 +`subos new --from`在checked source producer内识别borrowed mount后,仍在创建 +新scope之前通过既有installer建立本地借用登记和config记录;不重装来源payload, +不复制来源configured,也不绕过scope配置判定。borrowed mount authority不可读 +或验证失败时拒绝操作,不能把失败解释为普通owned目录。 diff --git a/.agents/docs/2026-10-08-pr-641-progress-report.md b/.agents/docs/2026-10-08-pr-641-progress-report.md new file mode 100644 index 000000000..d45027c51 --- /dev/null +++ b/.agents/docs/2026-10-08-pr-641-progress-report.md @@ -0,0 +1,964 @@ +# PR #641 总体进度报告 + +评估日期:2026-10-08。评估对象:[xlings PR #641](https://github.com/openxlings/xlings/pull/641),以及 SubOS 总体架构设计 Part 1、Part 2。§1–10 保存最初 rebase 后的审查基线,§11 起记录后续实施与对应提交的验证。本文结合设计、代码、测试声明、远端 CI 和本地验证;实施记录中的“本地实测”属于原作者的记录,不等同于本轮复验。 + +## 当前交付状态(Linux 后续验收修复待最新 CI) + +后续实现均追加 commit、普通 push,保留审查历史。main 仍为 `c55d89a`, +候选版本为 `2026.10.8.2`,尚未合并、发布。 最新固定head的结果见§26;以下保留历史轮次证据。 + +`8e8638fc` 的 macOS(147 pass、0 fail、2 skip)、Windows(105/0/44)、 +Linux root 完整流水线通过。ARM64 首次 qemu version 检查退出 139,同一 head +重跑交叉构建和原生兼容均通过;原因未确定,补充失败 trace 与 archive 保存, +不放宽原始退出码和候选版本匹配。Linux 主单元为 202 pass、1 fail、5 skip, +ASAN 仍运行;静态 domain、性能和发行版下游因单元失败尚未执行。 + +唯一 Linux 失败是新 payloadless config remove 回归。artifact 的 uninstall log +确认 hook 已运行一次,但 worker publication 无条件移动从未存在的旧 payload, +随后 fallback 合成不存在的 XVM remove 又遮蔽该错误。最小修复使 missing old + +empty shadow 不制造空 payload,非空首次发布仍用 no-replace;unknown/nonregular +目标拒绝,已有目录保留事务。fallback 仅对 validated selection 合成 remove。 +真实 hook 失败继续非零;dev 与 legacy 两路都校验显式/bare hook once、错误版本 +拒绝且旧日志不变、真实 hook 错误不吞掉。正在有限本地复验,最新 head 全 CI 待完成。 + +最终只读审查的 anchored FD `/etc`/sysusers 写入修复已在 Linux/macOS 实际通过, +外部 user data、symlink/hardlink 与 rename 后目录边界均有回归。静态 release 清理 +后的 xdev 与四个已构建验收程序保留修复已落地,完整静态实际 pass 门禁尚待执行。 +不能用其他 head 或平台的 green 替代这些验收。 + +`1019d353` 的 macOS、Windows、ARM64 和 Linux root 完整 CI 已实际通过; +Linux 单元为 198 pass、0 fail、5 skip,静态 release 和 candidate cold-home 通过。 +静态 domain 硬验收启动前因 release 清理 musl target 同时删除 xdev 而失败。 + +`476fc244` 的 macOS、Windows、ARM64 和 Linux root 已实际通过;Linux 主车道为 +196 pass、3 fail、4 skip,ASAN 仍运行。worker 的 dev/locked、loader alias、完整 +exec/net 观测及日志 symlink 拒绝已实际通过。余项分别为首次 writable payload 的 +父 mountpoint、HomeLayer fixture 在声明 layer 前执行 legacy metadata,以及 dynamic +开发 client 的 root runtime 闭包拒绝。对应修复与静态实际 pass-only 门禁已落地, +待本批构建和新 head CI。 + +`f1fd0b19` 的 macOS、Windows、ARM64 和 Linux root 已实际通过;Linux 主车道为 +192 pass、6 fail、4 skip,ASAN 仍在运行。此次 worker 已实际启动并返回合法响应, +失败发生在宿主发布日志时没有创建 `logs/recipes`;另一项失败是清 capabilities 的 +UID-0 域控制器无法再映射 parent UID0。两项对应修复已落地,待本批构建与新 head CI。 +不能将其他平台 green 或后续未执行的发行版场景当作该主车道通过。 + +`e8fbdd01` 的 macOS、Windows、ARM64(含原生兼容)及 Linux root 流水线已实际通过。 +Linux 主车道仍为 191 pass、5 fail、4 skip;ASAN 为 105 程序 pass、1 fail,唯一失败是 +受 instrumentation 影响的 generation 切换 11.069 ms 超过 10 ms,无 sanitizer 报错。 +完整失败现场证明,worker +在加载 ELF 入口时即因解释器 literal 路径不可见而退出,审计握手错误为次生报错; +前一轮将该错误直接归于两个 listener 的诊断不充分。单 listener 的实际 kernel 回归 +已通过,但 namespace 组合验收须等加载路径修复后的 CI。私有域 probe 已正确找到 +system bwrap,剩余假阴性来自 uid=0、清 capabilities 的 context 中省略 `--unshare-user`。 +两项修复与实际回归已完成本地构建,待新 head CI,不能沿用其他 head 的 green。 + +`4daee595` 的 ARM64 与 Linux root 流水线通过;macOS、Windows 的构建及单元测试 +通过,但两个旧 shell/PowerShell fixture 与新入口所有权边界不兼容,已按实际失败 +日志修正 fixture,未降低产品边界。Linux 主测试实际 191 pass、5 fail、4 skip;失败集中于 Lua worker 挂载/审计与私有域 +backend 判定;对应修复已追加提交并等待下一 head CI。root 闭包隔离回归和 ASAN +已实际通过。 + +最终 review 另修复私有域 exec/use 对 `./`、`~/` 挂载源的二次解析;命令分隔符后 +argv 保持原样。开发与静态构建通过,真实隔离回归等待 CI。 +镜像 self update 不再吞掉错误,验证真实旧 payload→候选 archive 下载/安装/激活、 +generation 改变、stage0 SHA/inode 不变及回滚。静态性能测试严格执行 shim <10 ms、 +热 exec 额外 ≤5 ms、冷 exec 相对等价直接 provider 额外 ≤10 ms、rootfs 闭包入口 +额外 ≤100 ms;CI 要求全部实际 pass。报告要求所有 Linux 必需能力都有通过证据; +Windows 引号契约由 Windows 单独硬验收,WSL1 保留设计明确的尽力验证例外。 + +下面 §1–10 为初次审查快照,包含当时已知、后来已修复的问题;不能当作当前缺项表。 + +## 1. 初次审查结论(历史快照) + +**两阶段主要代码已落地,当前处于集成与验收收口阶段,还不满足合并、发布条件。** + +Part 1 已形成声明策略 → spec 编译 → provider → supervisor/session → broker → 审计的完整主链。Part 2 已加入根投影、generation、部署 R、启动项、导出以及 Luban 分级模板,范围已经明显超过 PR 标题和描述。 + +现在的主要工作是修复已有实现的边界、同步设计和实际能力,并取得真实环境测试的通过证据。尤其需要处理 `subos pack` 删除现有目录、Linux 删除 lint、macOS generation 原子可见性失败、locked 审计失败语义及 rootfs 能力报告不一致。 + +不能用“123 required 都有测试声明”推导出“全部功能验收完成”。本轮普通 Linux 测试通过,但真实沙箱用例跳过;当前远端 Linux 主车道在构建前失败,新增加的发行版验收车道尚未运行。 + +## 2. 本地分支与 rebase + +| 项目 | 结果 | +|---|---| +| 原远端分支 | `feat/subos-architecture-640` | +| PR 原 head | `4df5c19ca525082eeeacf4d34ae468c95c52e619` | +| 获取方式 | `git fetch origin main refs/pull/641/head:refs/remotes/origin/pr-641` | +| 本地工作分支 | `review/pr-641-rebased` | +| 独立 worktree | `/home/speak/workspace/github/openxlings/xlings-pr641` | +| rebase 基线 | `origin/main`:`c55d89aa43ed4e048964af3791365d7f6532f13d`,#647 | +| rebase 后 head | `a8c2aba1834737cad4b088ef2e6500bd05115248` | +| 原 head 备份 | `backup/pr-641-before-rebase` | +| rebase | 成功,无需人工解决冲突;main 是新 head 的祖先 | +| 提交数量 | PR API 返回 75 个提交;rebase 后相对 main 为 74 个非 merge 提交 | +| 当前规模 | 相对 main:206 个文件,+21,320 / -3,115 行,包含重命名和模块迁移 | + +原工作目录处于 `perf/shim-dispatch`,有 `.xlings.json` 删除及两个未跟踪文件;本次使用独立 worktree,保留这些变动。未推送、未修改远端 PR、未提交产品代码修复。 + +main 的 macOS LLVM 23.1.3 调整(#645)和 recipe 按客户端进程架构解析的修复(#647)已进入本地分支。`range-diff` 中移除的是已在 main 的 #643 shell completion 提交;另一个差异是 #647 对测试 import 上下文的更新。版本继承 main 的 `2026.10.8.1`,不是对本 PR 的独立发版。 + +## 3. Part 1:安全隔离与管理架构 + +“已实现”在下表指代码和对应测试已存在;真实环境验收情况另见第 6 节。 + +| 设计范围 | 当前实现 | 进度判断 | +|---|---|---| +| C0,C1–C4:模块拆分、确认、事件、HomeView/Ports、名称模型 | SubOS 独立包;公共 guard/observe 最终合入 `modules/runtime`;CLI 适配拆到 configure/run/audit 等单元 | 主体完成,最终目录与初稿不同 | +| C5–C6:HomeContext、mode/layout、写者规则 | mode/layout 声明、高 layout 限制写入、home 配置损坏拒绝覆盖、未知键保留 | 主体完成;Part 2 新读写者仍有缺口 | +| C7–C8、C10–C11:spec/provider 与 S0 修复 | home 只读、实例可写、其他实例与审计隐藏、环境白名单、pid/ipc/uts、终端防注入 | 主体完成;本机无法复验真实 bwrap | +| C9、C12:会话和外部执行 | supervisor/session-init、Unix socket 与 fd 传递、exec/start/stop/cp/temp、退出码、keep/ttl | Linux 主链已实现;跨平台 detached session 不支持 | +| C13、T4:agent 契约 | `XLINGS_AGENT_MODE`/`--agent`,PTY 无输入扫描,候选与非阻塞错误 | 已实现,相关本地契约通过 | +| C14、C16:策略 | dev/private/locked、外置 policy、未知安全字段拒绝、decide、规则、策略包锁定和升级 | 已实现;策略包联网用例本轮跳过 | +| C15:broker 权限 | 客户端和服务端判定、auto/ask/deny、审批、owner-only、broker 默认拒绝未知请求 | 主体完成;hook 沙箱明确未实现 | +| C17、C21、C25:能力、诊断与性能 | caps 缓存、must/should、降级提示、doctor/isolation fix、shim 与热会话性能测试 | 主体完成;完整环境矩阵与原预算未全部验收 | +| C18–C20:Landlock、挂载、授权、身份 | Landlock 显式选择;具名授权;private/locked 中性身份与 nested userns 限制 | 已实现;Landlock 为写限制,不提供文件隐藏 | +| C22:观测 | supervisor 写 events.ndjson,exec 通知、会话/权限事件、结束时统计 rw 映射文件变化、log/report/trace | 部分完成;审计失败语义与逐连接 net 事件未闭合 | +| C23:网络 | none、pasta NAT、publish、host-loopback | none/NAT 已实现;proxy 延后 | +| C24、C26–C27:layer、系统部署、rootfs | 已由 Part 2 接续,不能继续按 Part 1“全部延后”描述 | 见下一节 | + +隔离的实际强度仍需按档位和平台说明:dev 默认保留宿主网络,private/locked 的网络隔离才隐藏宿主抽象 socket;macOS/Windows 仍是 home-redirect,未实现 Linux 等强度的文件、进程、网络边界。 + +## 4. Part 2:宿主即 SubOS、根呈现与 Luban + +Part 2 §19 已有 C28–C42 的实施记录,代码也有对应入口;但 checkpoint 有实施记录并不表示其完成标准“CI 全绿”已经满足。 + +| checkpoint | 当前落地 | 尚需确认 / 差异 | +|---|---|---| +| C28 S,C29 AUR | 系统二进制为新用户 home 建立 entry;AUR 仅装系统本体,去掉 root uninstall hook;增加两用户包测试 | isolation-fix、arch-package 当前远端均跳过 | +| C30/C30a:前缀域、bootstrap | single/multi 路径模型;缺 patchelf 先加入安装计划,仍不可用则失败;简单 alias 不经 shell | `--domain` 构建器/用户私有系统域未做,multi 直接在 `/xlings` 构建;是范围简化 | +| C31:kind/role | view/rootfs、host/boot_entry,允许操作统一表 | 损坏 instance.json 被解释为 view,读者规则需收口 | +| C32–C33:根投影与 generation | merged-usr、直接链接、generation 树、rename 指针、rollback/prune、factory etc/sysusers | macOS 原子可见性测试失败;投影更新失败目前只警告 | +| C34:库搜索 | `/lib64` 接到根的 `/usr/lib`,支持 host-built ELF | 根自己的 ld.so.cache 推迟,需 glibc revision;gcc 编译的程序仍需显式 `/usr/lib` rpath | +| C35:rootfs 实例 | bwrap 根呈现、uid 0、嵌套 default、broker 安装后刷新 `/usr` | store 整体只读可见,未按闭包逐 payload 绑定;gate 仍报告 RootfsRuntime 不支持 | +| C36 M/layer | `install --system`,用户 PATH 顺序为用户层 → 系统层 → 宿主;rootfs 支持 fetch=layer,普通 view 拒绝 | 属于 PATH 跨层使用,不是把系统层条目合入用户 VersionDB;需求文本已缩窄 | +| C37:导出、pack、diff | rootfs 目录、tar、disk,tar 作为 docker/WSL 导入输入 | OCI/WSL 专有开关合并为 tar;pack 现有目录删除问题已复现 | +| C38 R | root 中切换 xlings 保留 stage-0 entry,运行版本随 `/usr` generation 移动 | 实际 root 中 self update/rollback 需要 distro 验收 | +| C39、C42:启动 | stage-0、boot.json、once/tries/fallback/mark-good,busybox init 的 `--now` | qemu 启动和不重启内核切换车道尚无当前 head 的通过记录 | +| C40 Luban | tiny/core/desktop、from 链、基础工具、内核与离屏渲染场景 | 跨仓库模板已合并;当前 xlings head 的联验未完成 | +| C41:发行版 CI | distro 的 bwrap/docker/qemu 场景、system_layer、AUR、WSL 报告 | YAML 和脚本已接线,但当前 distro 等跳过,WSL 实际也 skip | + +Part 2 里程碑 A 要求 tiny 从空根构建、进入、内部装包并通过闭包检查。原作者记录称这些本地实测已完成,还包括 core 144 个形态 X ELF 的闭包和 desktop 离屏像素验证。本轮未重新运行这些场景;当前 head 的 distro CI 未启动,因此里程碑 A 应标为“有作者本地证据,CI 验收待完成”。 + +跨仓库前置:[xim-pkgindex #929](https://github.com/openxlings/xim-pkgindex/pull/929) 已在 `2026-10-06T06:33:24Z` 合并,head 为 `0ac7cb2b292218362a17c5c15c58e685ecce7397`。PR 包含 Luban 模板、bash/coreutils、内核、busybox applet、perl shebang、binutils wrapper 修复;其主要检查通过。资源发布和所有平台可下载性仍应由发行版场景验证,不能由索引 PR 合并状态代替。 + +## 5. 测试与开发基础设施完成度 + +T1/T2/T4/T5 的主要链路已经存在:C++ XTEST 元数据、隔离 home、进程 runner、能力声明、统一报告、脚本适配、需求覆盖门禁。T3 有实质推进:Linux release 产物复用于 e2e,asan 离开普通 PR 关键路径,多个车道集中报告。 + +但 Part 1 §24 的完整 CI 方案未全部实现: + +- `xdev ci plan` 只出现在 usage;`main()` 没有执行分支,调用会返回 2。 +- 未见设计中的动态分片、按 area 选择、资源调度、趋势、quarantine/flaky、代码覆盖率门槛完整实现。`resources` 元数据存在,不能视为调度器已完成。 +- Linux root workflow 仍独立构建,dev/unit 与 release 也有不同构建;“每平台每 commit 只构建一次”不能按字面宣称完成。 +- testkit 的 isolated home 仍写镜像配置,未实现设计中的进程内 fixture HTTP server;distro 明确声明 network,并安装真实包。无网络 PR 的目标尚未完成。 +- Part 2 新增了 shell 场景,通过 `# xtest:` 接入报告;这是对“新增测试全部 C++”的实际偏离,实施记录已说明,但总体设计应同步。 +- T6 的 `xdev lint/release` 子命令未实现;现有 lint 通过 `xdev test --suite lint` 适配。T6 原本允许放在 PR 后。 +- Part 2 的 generation/stage-0 性能预算未见对应完整断言;现有 perf 主要验证 shim 开销和热加入比冷启动快。 + +## 6. 验证证据 + +### 6.1 rebase 后本地 + +工具:mcpp `2026.10.5.3`,gcc `16.1.0`;产物为 Linux GNU 开发版,非静态 release 包。 + +| 验证 | 本轮结果 | +|---|---| +| `mcpp build` | 通过,约 32.65 s | +| `mcpp build -p xdev` | 串行构建通过,约 4.49 s | +| 首轮 `XLINGS_TEST_MIRROR=CN mcpp test` | 75 个测试二进制通过,2 失败 | +| 首轮失败归因 | 既有 ProgressOutput 用例未隔离 TERM=dumb;新增 Deployment 用例中测试镜像变量覆盖 GLOBAL 断言 | +| 清理环境后 `xdev test` | **77 个二进制通过,0 失败;XTEST 69 pass / 0 fail / 36 skip** | +| XTEST 跳过原因 | sandbox 33、macOS 1、Windows 1、network 1 | +| `xdev test --no-mcpp --suite lint` | 2 pass / 1 fail;SubOS 删除 lint 报 7 条违规,含重复归类 | +| `xdev test --no-mcpp --suite contract-scripts` | 9 pass / 1 fail;文档示例检查把行尾 `wsl --import` 注释识别为 xlings 参数 | +| 其余契约 | CLI spec parity、generated command reference、i18n、版本、头文件等检查通过 | +| 本地 `report --requirements --fail-uncovered` | 123 required:97 covered / 26 uncovered,0 planned,3 deferred,0 unknown;退出 1 | +| 全仓静态声明核查 | 123 required 均有 C++、场景脚本或 WSL workflow 声明;未发现未知 ID | +| `git diff --check origin/main` | 失败,设计文档和 subos.cpp 有少量空白格式问题 | + +本机 `xdev doctor` 显示 userns/bwrap/sandbox 不可用,pasta 未安装,也没有免交互 sudo。因此 77 个二进制通过只说明可执行的本地检查通过,**不证明 bwrap/NAT/rootfs/启动隔离已复验**。本次没有改变主机 userns/AppArmor 设置,也未运行修改 `/xlings` 的系统场景。 + +本地覆盖的 97 项是 xdev 读取到的测试元数据覆盖,包含已 skip 的用例声明;26 项未覆盖主要是部署、rootfs、镜像、启动及 Luban 场景,本轮没有执行其脚本。`apps/xdev/src/main.cpp:413` 起的覆盖计算遍历 meta,不与 pass 状态关联。门禁有助于防止漏写测试,但不等于行为验证通过。 + +### 6.2 远端:原 PR head 的最近检查 + +以下状态对应原 head `4df5c19`,不能转移为 rebase 后 `a8c2aba` 的 CI 结论。PR 仍为 OPEN、Draft,API 中无正式 review。 + +| 车道 | 状态 | 含义 | +|---|---|---| +| [Linux build-and-test](https://github.com/openxlings/xlings/actions/runs/37423886275/job/112139141966) | FAILURE | 删除 lint 先失败,后续 build/unit/release 尚未执行 | +| [macOS build-and-test](https://github.com/openxlings/xlings/actions/runs/37423886270/job/112139098051) | FAILURE | 76 个测试二进制 pass,1 fail;generation 并发读者测试记录 11 次“没有有效代” | +| [Windows](https://github.com/openxlings/xlings/actions/runs/37423886304) | SUCCESS | 当前远端 Windows 车道通过 | +| [aarch64](https://github.com/openxlings/xlings/actions/runs/37423886323) | SUCCESS | cross-build/qemu 与 native compatibility 均通过 | +| [Linux root](https://github.com/openxlings/xlings/actions/runs/37423886395) | SUCCESS | 现有 root workflow 通过,不是新增 distro 验收 | +| [unit-asan](https://github.com/openxlings/xlings/actions/runs/37423886275/job/112139142373) | SUCCESS | 本 PR 带 ci:asan;该 job 未声明真实隔离能力 | +| e2e / isolation-fix / arch-package / distro | SKIPPED | 依赖失败的 Linux build-and-test | +| [wsl-import](https://github.com/openxlings/xlings/actions/runs/37423886275/job/112139184538) | SUCCESS,实际用例 SKIP | 下载 artifact 后确认:`WSL1 could not be set up on this runner` | +| [Linux report](https://github.com/openxlings/xlings/actions/runs/37423886275/job/112139242765) | FAILURE | 汇总失败;不是发行版需求覆盖通过证据 | + +总计:7 SUCCESS、3 FAILURE、4 SKIPPED。WSL 的 green 是尽力车道设计所允许的结果,不能计为导入成功。 + +## 7. 需要优先收口的问题 + +### P1:pack 删除了调用者预先存在的目录(已复现) + +`src/core/subos/root_cmd.cpp:676` 的 pack staging 使用 `/-`,无存在性/所有权检查就 `remove_all`。在临时目录预建 `out/review-1/user-owned.txt`,执行: + +```sh +xlings subos pack probe --as demo:review@1 --out /out +``` + +命令退出 0,预先存在的文件被删除;没有确认过程。export 的固定 `.name.stage` 也有同类实现,需要一并审查。本轮复现全部在临时 home 和临时输出目录进行。 + +应使用唯一且由本次操作创建的 staging 目录,清理只作用于已证明所有权的路径;现有输出冲突应明确拒绝或采用授权后的替换语义。**不要通过补 lint 豁免掩盖此问题。** generation/prune 是派生数据,可以在证明其边界后标记,但不能与任意输出目录等同处理。 + +### P1:generation 的验收与失败处理未闭合 + +远端 macOS 的 `SubosRootfs.AGenerationSwitchIsOneRenameAndARollbackMovesThePointer` 失败,本轮 Linux 同一测试通过。当前证据还不足以判定 macOS 文件系统行为、测试读取方式或实现哪一侧是根因,需要保留该平台上的测试结论,不能直接降为“偶发”。 + +另一个代码问题是 `src/core/xself/init.cpp:691` 处 root refresh 失败只 `log::warn`。这条路径不能把投影失败传回安装/切换结果,可能使 workspace 与 `/usr` generation 不一致。需要错误传播及失败后的状态一致性验证。 + +### P1:locked 审计失败没有执行设计规定的终止语义(代码确认) + +Part 1 §22 明确要求:审计写入失败时 locked 结束会话,其他档位警告。实际 `modules/runtime/src/observe.cpp:70` 的 append 返回 void、忽略打不开文件及写失败;`modules/subos/src/session.cpp:94` 的 audit 无法获知结果,未实现 locked 分支。 + +需要可检测的 journal 写入结果及会话层的处理,并补审计不可写/磁盘满的真实失败用例。本轮由于 bwrap 不可用,未把静态判断冒充运行复现。 + +### P2:新增 kind/boot 状态读写未全部遵循 C6 规则 + +`src/core/subos/root.cpp:21` 将损坏 JSON 变为 `{}`,`kind_of` 再默认 view;本轮把临时实例 instance.json 写坏,`subos status --json` 仍退出 0、显示 kind=view。`declare_kind` 复用该读者,存在覆盖损坏文件的实现路径。 + +`modules/subos/src/boot.cpp:46` 的 save 从 Config 重建 JSON,不保留未知字段。应区分缺失与不可读/损坏,复用严格更新读取并保留扩展键。 + +### P2:rootfs 功能、能力报告和覆盖清单矛盾 + +`modules/subos/src/gates.cpp:52` 仍将 Linux RootfsRuntime 固定报告为 `supported=false / not in this release`;`tests/requirements.toml` 中 ISO-ROOTFS 仍 deferred,而 INST-ROOTFS 已 required;PR 描述及旧计划仍称 C27 rootfs、fetch=layer 和系统层解析延后。 + +应以 Part 2 最终实现更新这些回答者,说明 layer 的 rootfs 限制、PATH 跨层语义、未做闭包绑定,而不是只修改摘要文字。 + +### P2:新增场景与发布链尚缺当前版本验收 + +Linux lint 阻断了新增 distro、AUR 和 isolation-fix;本地文档契约还新增一个失败。先修这两个门禁,再运行静态 release 包对应的 rootfs_instance/image/boot、system_layer、AUR、isolation doctor fix。`--tar` 的 WSL2 验收仍需合适 runner,现有 WSL1 尽力报告可保留。 + +## 8. 明确保留的范围限制 + +以下不应隐藏在“全部完成”里;其中若要随本 PR 发布,需维护者接受并在用户文档说明: + +- net=proxy;broker 安装的 hook 沙箱;interface subos_exec 实时流式输出:Part 1 实施记录明确延后。 +- root 的 ld.so.cache/glibc sysconfdir revision:Part 2 明确延后;`/lib64 = /usr/lib` 提供当前替代搜索路径。 +- rootfs store 的闭包级隐藏:未做;整体 store 只读可见。ROOT-STORE-CLOSURE 没有成为现行 required ID。 +- macOS/Windows 原生强隔离及 rootfs runtime:未做;通过 home-redirect/导出 Linux tar 路线提供当前能力。 +- private/locked 下的网络/权限限制应按实际 provider 判定;dev 及 proot/home-redirect 的降级不能用“默认强隔离”概括。 +- 按连接的 net 观测、min_client 老 entry 诊断、完整性能/环境/故障矩阵及 xdev 动态 CI/趋势工具:与总体设计相比尚未完整落地,也未全部纳入 required 清单。 + +## 9. 建议的后续顺序 + +1. 先修 pack/export staging 的所有权边界,逐项审查删除 lint;同时修文档示例的行尾注释识别问题。 +2. 收口 generation 的 macOS 失败、投影失败传播、kind/boot 严格读写、locked 审计失败语义。 +3. 更新 RootfsRuntime probe、ISO-ROOTFS 状态、PR 标题/描述、Part 1 实施计划;给 T3/T6、hook/proxy/cache/closure 等范围明确标记。 +4. 对 rebase 后 head 重新取得 Linux/macOS/Windows CI,重点跑新增 distro、AUR、system layer 和 isolation-fix;检查每项为 pass 而不是只看 job green。 +5. 根据这些结果决定是否进入 ready-for-review;发布再按实际发布日期编号,走 release、CN mirror、索引更新和真实冷 home 升级验证。 + +## 10. 本轮证据位置 + +- 统一 C++ 测试和需求报告:`target/xdev/pr641-review/report.md`、`report.json`。 +- lint:`target/xdev/pr641-review-lint/report.md`。 +- 契约脚本:`target/xdev/pr641-review-contracts/report.md`。 +- 构建、首轮测试、远端 macOS日志、原 PR JSON、range-diff:`target/xdev/pr641-review/evidence/`。 +- 复现现场:`/tmp/pr641-review-9ofjuh0h`(只用于本轮隔离复现)。 + +本轮执行了分支获取、rebase、分析、构建与验证,新增本文;没有把检查失败自动修成通过,也没有 push 或修改远端 PR。 + + +## 11. 续行实施状态(2026-10-08) + +本报告前述结果是初次评估的快照。续行依赖计划见 +[完整交付计划](../plans/2026-10-08-subos-delivery-plan.md),任务状态见 +[交付状态](../tasks/subos_delivery_641/README.md)。 + +已经实现:独占 staging 和不覆盖发布;generation 完整清单校验;严格 kind/role/boot/anchor/init +读取与未知键保留;stage-0 错误恢复;root refresh 失败传播;locked 审计写失败拒绝/终止与 +先审计后确认 exec;默认 argv 值脱敏;policy min_client 与实际 entry doctor;dangling policy +拒绝;随机独占 atomic writer 与 Windows 替换。修复了文档行尾注释解析与 TERM/mirror 测试污染。 + +产品 build 通过。一次完整本地运行产生 84 个测试程序记录,82 通过、2 失败;针对性修复后 +两类失败用例单独通过,逐程序最新证据为 84 pass。具体用例仍有 42 个 skip(包括真实沙箱、 +平台、network 与尚未通过 xdev 调用的报告用例),没有将这些声明转成实际验证。 +原始运行、重跑和合并后的 `latest-binaries.ndjson` 均保存在 `target/xdev/pr641-hardening/`。 + +xdev 的报告已区分声明覆盖与执行证据,并用同车道关联防止另一平台的通过覆盖本车道的 skip。 +新的 CI 选择实现正在进行。libxpkg 的独立工作树已实现持久外置 hook 执行接口,尚未集成客户端; +元数据加载及 index build 的 Lua 顶层执行也必须纳入边界,不以只拦 system.exec 宣称隔离完成。 +xim-pkgindex 使用独立工作树,原工作树用户改动保留。真实 home 的镜像已设置为 CN。 + +这些工作尚未改变最终验收结论:完整 CI、proxy/连接事件、系统层/闭包、hook worker、库 cache、 +fixture/资源调度、升级/性能证据与发布链仍需完成。候选版本为 2026.10.8.2,正式发布时按当天 +已有 release 重新确定;没有发布 #641 的资源或更新 latest。 + +## 12. 下一实施批次的证据(2026-10-08) + +后续遵循用户的新约束:只追加 commit、普通 push,不 force push 或改写已推送提交。 +`3ea30c71` 修正隔离测试使用的 xdev 路径,`dd118625` 将 app 自测的实际结果接入报告, +并为 macOS generation 读取失败增加 errno/path 诊断;断言仍要求取得完整 generation。 + +上游 [libxpkg #46](https://github.com/openxlings/libxpkg/pull/46) 已通过 CI 并合入, +发布 0.0.61;[mcpp-index #516](https://github.com/mcpplibs/mcpp-index/pull/516) 已通过 +检查并合入。CN source archive 通过 GET 校验,SHA256 为 +`ee1e1a6dd2367a50fd9cd13f179367aa8638b9fa83410bdda60cd4bdb54b9019`。 +客户端已经恢复正式依赖并成功下载/构建,未依赖 local path override。 + +新增实现包含持久 Lua worker(顶层加载、metadata、builder、hook 均可进入执行边界)、 +独立控制协议、每目标 payload shadow、受限写入、输出日志和 exec/net 审计通知。 +网络 proxy 使用独立 netns 与 supervisor 转接,只有声明的 SOCKS5h 出口;目标域名交给 +代理解析。原生强边界仍以 Linux 支持为准,不把 macOS/Windows 的不支持解释成同等隔离。 + +本地有限回归:protocol 2、worker flow 2、cache 2、network unit 3 个用例通过;worker 的 +3 个真实隔离用例及 network 的 2 个用例因本机 bwrap 不可用跳过。后续的 net 审计接入、 +cache 所有权证明、resolution evidence 与系统层复用仍需要新批次回归。xdev 资源锁、 +HTTP fixture、CLI 与选择器共 17 个 app 用例已通过;动态 CI、趋势及性能矩阵尚未验收。 + +[索引 #940 的两架构构建](https://github.com/openxlings/xim-pkgindex/actions/runs/37707873566) +均通过,包括 loader 的版本、私有库搜索、locale/gconv、logical-root cache/preload 和 +managed loader 不读外来根 cache 的实际运行。两份资源已发布到 GitHub,并在资源出现后立即由本地 gtc 补齐 GitCode;CN 完整 GET 的 +SHA256 与构建产物一致。recipe 2.44.3 revision 2 已按架构选择 loader、ABI、URL 和 digest, +#940 最新 head 的两架构及三平台检查通过,已于 2026-10-08T01:22:00Z 合入(`ebf1fbb3417ec513b923279044433887b3af8850`);旧客户端 2026.10.4.1 在独立 HOME、CN 镜像下实际安装成功,记录 configured revision 2; +对安装后的真实 payload 再跑 logical-root cache/preload 探针通过。候选客户端生态安装仍待验证。 + +客户端新增库 cache 生成遵守用户数据规则:只写专属 staging,既有 cache 必须有匹配 +digest 的 xlings ownership record;未知/外部修改的数据保留并报错。写前记录旧、新 digest, +中断后的任一有效状态可再次识别。root refresh、rollback 与 stage-0 已接入,待本批 CI。 + +macOS 日志将并发读取失败定位为 `readlink` 返回 EINVAL;`2b78ce6e` 追加有界读取重试, +维持完整 generation 的断言,macOS CI 已通过(run 37709550248)。Windows 的 JSON/string_view 测试比较歧义 +由 `88eafe96` 追加提交修正;Linux locked audit 清理断言失败,supervisor teardown 修复待本批回归。xdev 动态矩阵已接入 distro 三车道, +本地计划确认三个脚本各执行一次;未把计划输出当作场景执行证据。 + +完整交付仍未完成:真实 Linux 隔离、系统层完整闭包/prefix domain、 +升级与性能证据、最终 CI、自审、客户端 release 及 CN 真实升级均须继续推进。 + + +## 13. 事务、流与闭包基础批次(2026-10-08) + +本批已完整构建通过(GCC 16.1,build 8)。一次完整客户端回归为 98 个测试程序: +94 pass、4 fail;运行记录中的 49 个具体用例 skip 保留为缺证据。三处失败已定位为 +Meta 字段顺序、模块导出签名不一致、loader fixture 缺失;第四处 self init 超时来自 fixture +仍自动获取官方索引,已改成显式本地默认索引和测试索引,现有二进制初始化实测 0.047 秒。 +四项针对性复验均已通过;借用正式 group fixture 补齐 kind,旧 shared-entry fixture 改用 +正式 xim:xlings 身份,产品权限不放宽。逐程序最新证据为 98 pass,保存于 +`target/xdev/pr641-hardening/boundaries-latest-binaries.ndjson`,不是第二次全量运行。 +xdev 本批完整 4 程序 / 18 用例 pass、0 skip,删除/header lint 和 diff 检查通过。 +借用使用正式 provider/version 登记和唯一 checked runtime evidence reader,不复解析依赖名称; +`use`、config 和 remove 对全部资产先预检,以旧 scope 登记作为 ownership,未知用户文件不覆盖。 +资产、候选 DB/workspace 与 root refresh 失败时回滚;状态发布使用 atomic writer,并检查写后读回。 +系统共享 store 的 GC 保留无法观察外部引用的 payload,用户 store symlink 不能取得删除授权。 + +新增 NDJSON 1.6 的 `subos_exec` 流式 stdout/stderr、有界分块与取消;非 UTF-8 分块显式标记 +base64。xdev 的趋势端到端用例已通过,报告保持 member-qualified ID,导出每平台成功耗时 +中位数用于下一次实际 CI 计划;main 历史和 90 天报告 artifact 的接线仍待 CI。 + +root_mount 的平台接口已构建,通过捕获的 namespace/root FD 发布只读文件或目录,失败回滚; +新增真实 namespace 回归尚待执行。Root GCC shim 的逻辑 loader 选择仅在对应 generation +的真实投影和正式 gcc provider 证明下启用,默认宿主执行不改变;真实生态 consumer 验证待完成。rootfs_instance 已增加唯一 SONAME、/opt 私有库、 +root cache、逻辑 PT_INTERP 与 managed loader 对照场景,并移除 desktop 人为 rpath;待 Linux CI 实际执行。 + +D2 当前只完成 pure closure 与 evidence 基础;private skeleton、逐 payload 的实际绑定和 broker +后的实时挂载更新尚待接入。D3 已加入 strict prefix-domain 选择与创建前预检,跨前缀 namespace +producer 未接通时明确拒绝,不能把它计作已完成。旧镜像测试删除宿主 `/xlings` 的操作已移除, +替换为 owner-private domain 的正式制作场景;producer 接通后必须实际通过,不能用 skip 代替。 + +已推送 `88eafe96` 的 macOS、Windows、aarch64、Linux root 和 ASAN 已通过;Linux 主套件 +仍因 locked audit cleanup 失败,修复处于本批尚未推送的代码中。最终固定 head CI、升级/性能、 +Root/Luban 场景、自审、合入和客户端发布链均未完成。 + +## 14. HTTP fixture、平台修复与旧 home 升级(2026-10-08) + +基础批次已普通推送为 `6a1df938`、`d968ee47`、`0faff256`。其固定 head CI 失败: +Linux/ARM musl 缺少 `linux/openat2.h`;Windows 模块混入 Winsock 1/2;旧 mcpp +2026.9.28.3 的生成模块缓存依赖缺失影响 Linux root、ASAN 和 macOS。macOS 另有两项 +路径断言把 `/var` 原始路径与 `/private/var` canonical 路径混用。 +追加 `562cfbd9` 使用稳定 syscall ABI、统一 Windows 精简头,mcpp 固定到已发布的 +2026.10.5.3,CI 索引快照更新为 `ebf1fbb`,路径断言使用实际 canonical 路径。 +本地产品构建及 home context/layers 的 20 个用例通过;新 head 的 CI 尚待结果。 + +testkit 默认 home 已接真实进程内 HTTP fixture:本地真实 xpkg 索引、数值 loopback、 +SHA256、实际解包,以及缺包不回落公共索引。真实下载暴露安装器漏识别 `.tar`,修复后 +端到端安装通过。HTTP transport 的 3 个用例通过,覆盖真实字节、来源/本地错误、 +拒绝外部地址/重定向/畸形响应和取消。带 `network` 的测试继续明确使用真实镜像。 +xdev 合并结果为 case 保留 program 身份,防止不同测试程序的同名 case 混入趋势。 +xdev 本批完整 6 程序 / 22 用例通过、0 skip,证据为 `xdev-http-complete.ndjson`。 + +隔离 home 的已发布 2026.10.4.1 已真实安装新版 glibc recipe revision 2;更换该 home +的实际 dispatcher 为候选 2026.10.8.2 后,CN 配置、self init、再次 install glibc 均通过。 +configured revision、完整 workspace 与 payload 文件大小/mtime 保持一致,证据为 +`target/xdev/pr641-hardening/nminus1-candidate-upgrade.ndjson`。这是现有 home 升级验证, +正式发布后的 self update/fresh install 验证仍待完成。 + +D2 新增 owner-private RootView 与 cache exact bindings,本地全构建通过;SCM 三 FD 与 +broker 完成后的真实挂载更新仍在接线。D3 `new --domain /xlings` 与 export 已接 namespace +producer,本地 prefix 6 用例通过;真实 namespace 用例因本机 bwrap 能力跳过,不能算验收。 +已有只读 systemSource 桥、runtime scope 路由及旧镜像 reader 仍在实现。最终 CI、 +Root/Luban/性能真实场景、自审、合入与发布链尚未完成。后续继续仅追加提交与普通 push。 + +## 15. 根视图源码检查点与性能预算(2026-10-08) + +`33bd4499` 的 ARM/Linux musl CI 暴露旧 SDK 没有 seccomp notification UAPI。 +追加 `777dccf5` 将网络通知和 exec 审计收口到同一稳定 ABI 定义;本地原生平台构建 +1.94 s、ARM musl 16.1 交叉构建 8.12 s 均通过。该 head 的全平台 CI 尚待结果。 + +D2 已接 trusted init 的三 FD(userns/mntns/root)传递、监督 ready 前握手、owner-private +RootView、逐 payload/generation/metadata RO mount,以及 broker 成功后同步 refresh。 +refresh 完成前不回复成功;失败回复 125 并结束 session。初次检查点编译发现 array FD +误用 vector-only close API,修复后依赖源码和两项 RootView/闭包 unit 通过。 +两项真实 namespace/broker 回归已编译,但本机 bwrap 不可用而 skip;仍需 CI 验证行为。 + +D3 source reader/facade 源码检查点已编译:typed physical/recorded/logical mapping、RO mount +与 marker inode 证明、限定 schema 路径的 metadata facade、exact payload slot 和外置 ownership +清单、已知 legacy layout 2 reader。producer/runtime/export 的完整桥接仍在继续,尚未验收。 + +新 generation 性能断言实际使用 300 个不同 payload、至少 600 个链接,测量 plan+commit +及完整 checked switch。三次样本中位数为生成 26,876 us、切换 4,939 us,分别满足 +1 s / 10 ms 预算,`test_root_generation_perf-checkpoint-2.ndjson` 为通过证据。 +stage-0 在 boot audit 记录 `stage0_elapsed_us`;真实 boot 场景要求四次启动(含 fallback +和 `--now`)均有记录且 ≤200 ms。该启动预算尚未执行,不能用本地 unit 代替。 + +## 16. D2/D3 集成与实际能力门禁(2026-10-08) + +本批已完成 RootView exact closure、broker 同步更新、inode 替换检测、source facade、 +私有 prefix producer 和 export 的源码接线。完整 GCC 16.1 产品构建通过;随后一次 +完整客户端运行 `d2-d3-full-regression.ndjson` 为 **107 程序 pass、0 fail、54 具体用例 skip**。 +RootView/闭包 3 项、source reader 3 项、prefix domain 7 项和 materialize 8 项 unit 通过; +namespace/broker/source export 仍因本机能力跳过,不计为实际隔离证明。 +此次 300 payload 性能中位数为生成 36.592 ms、checked switch 5.394 ms,预算通过。 + +审查确认 locked 的 FD 捕获需要额外规范化:bubblewrap 完成挂载后,`--disable-userns` +创建第二层 user namespace。因此由外层 supervisor 从可信 mount FD 派生真实 owner, +检查捕获 userns 的祖先链,再在 ready 前替换 owner FD;update 保持精确 owner 核验。 +相应普通/locked 的真实回归已加入,仍需 Linux CI 成功证据。 +依据:[bubblewrap v0.11.2 官方源码](https://raw.githubusercontent.com/containers/bubblewrap/v0.11.2/bubblewrap.c)。 + +`777dccf5` 的 ARM cross/QEMU、native ARM、Linux root 和 ASAN 已通过。 +Windows 失败为 fixture 错把 `/etc` 形式的 root-relative path 当作普通相对路径,已改 +`has_root_path()`,本地相关断言通过。macOS 单元和 xdev 全通过,后续 e2e 暴露旧 +header 来源消失会阻止其他 scope 刷新。现已区分 Recorded ownership 与 Present 新资产, +从目标树逐条证明并清理过期链接,保留未知 sibling,修复路径尾斜杠的归属比较;原 +e2e 的 S1–S4 已通过,S5 提示契约的修复待本批复验。 + +Linux 主车道实际执行隔离后失败,不能用本地 107 pass 覆盖:worker 传入 upstream +bubblewrap 不存在的 `--preserve-fds`,网络 exec 未识别 `--observe`,root mount 更新 +返回 EINVAL。locked cleanup 用例原先 start/exec 的策略不同,125 是隔离 digest 拒绝, +没有触发审计故障;测试改为先持久声明 locked,并检查 `E_AUDIT_WRITE` 后验证清理。 +上述失败分别修复、复验后继续普通 push,最终结果以新固定 head 为准。 + +新增 `DOM-SOURCE` 隔离验收项。Linux 在静态 release candidate 生成后实际执行 +checked 系统来源借用和镜像导出,必须产生 pass,skip 使该步骤失败。三平台先一次 +`mcpp test --no-run` 构建,再让 xdev 用已建 artifact 执行,避免每个测试程序反复 +启动完整构建图。完整 CI、实际启动/生态、最终自审、合入与发布仍待完成。 + + +本批产品构建 `domain-wave-final-build.log` 29.57 s 通过;macOS 同类旧 header 场景的 +本地实际 S1–S5 全部通过,`sysroot-pinning-final.log`。owned entry 的 install/use 四种 +实际替换场景通过,`entry-owned-replacement-final.log`。命令参考已重新生成,help parity +检查通过;此后新增 config proxy 说明需再次生成。 + +最终自审发现并继续修复两项:同 destination 重装叠加 mount,后续 remove 会露出旧 +payload;native mixed-home export 没有规范借用来源、按 target 误保留未导出的版本。 +前者改为有精确旧 tree 回滚的 replacement 事务,后者统一按 checked closure 规范 +image-owned metadata,并保留 ELF/alias 所需的精确逻辑 slot。新增实际回归仍待编译和 CI。 +私有域生命周期已接确认后删除、双向安全复制和同域 fork;跨域 fork 仍需闭包重建。 + + +最终集成构建 `final-wave-build-2.log` 5.78 s 通过(首次缺少 HomeView import,已修)。 +本批针对性回归 **13 个程序 pass、0 fail、17 具体 case skip**,覆盖 replacement 事务、 +RootView、domain/source、entry、materialize、observe/network;skip 保留为缺隔离证据。 +xdev 全部 **6 程序 / 22 case pass、0 skip**。自动生成命令参考/help parity、27 条 +文档命令路径与 NDJSON、平台 header lint、SubOS 删除 lint、CI YAML 和 diff whitespace +检查通过。旧完整回归 107 程序的证据保持独立;新固定 head 的 CI 和实际生态仍待完成。 + + +追加提交 `0c9f395f` 已普通 push,新 head 的五个主 CI workflow 已启动。PR 描述同步到 +当前行为和验收状态。本地静态 dist(GCC 16.1 musl)构建 1m05s 通过;使用该静态 +候选执行 `RootExport.OwnsBorrowedClosureAndOmitsOtherScopeVersions` 实际通过,0 skip, +证明 native mixed-home 导出的 owned metadata、精确逻辑 slot、仅作用域版本及来源不变。 +证据为 `final-static-build.log` 和 `mixed-source-static-local.log`。 + +复核 Part 2 §4 / §16:跨前缀实例迁移未被要求,不是本方案交付阻塞;原约束是仅相同 +前缀共享 payload。新前缀用 package-coordinate producer 重新制作,直接搬移旧绝对路径 +payload 拒绝并保留用户数据。同域 fork、确认后删除、双向复制仍需本次实际 CI 验收。 + + +接口复核确认 stdout 全程由 interface 的 StdoutCapture 和子进程 stream framing 保护, +没有原始输出污染 NDJSON;但 SubosEvents 读外层日志、switch_subos 越过私有域全局激活 +限制以及删除成功 DataEvent 丢失确实存在。已使用严格 descriptor 选择实际 home、在共享 +use_global 路径拒绝域激活并补回父 stream 的 subos_removed;真实 domain fixture 加入 +NDJSON 单结果、错误日志来源拒绝、两次复用同一 session 的 exec 断言。 +产品构建 29.94 s 与 interface protocol 回归通过;domain producer 重新编译通过但本机 +bwrap 仍 skip。原运行中的 ARM workflow 已通过;新接口波次需要追加提交验收。 + +session join 只通过 Unix socket / SCM 传递命令,由原 session_init 在原 namespace 内 +fork/exec;新 producer 不是跨 sibling userns setns。因此没有为假设的 namespace 权限 +问题改代码,保留实际 session 复用测试验证。 + + +`0c9f395f` 的 macOS unit/xdev 失败为 RootStoreClosure 两个新断言使用原始 `/var` 路径, +而产品 closure/RootView 已 canonical 为 `/private/var`;产品准备本身成功。测试统一 +canonical home/system,相关两个程序本地回归通过(真实 namespace case 仍 skip)。 +接口波次已追加为本地 `c89bc054`,其 26 个 interface protocol 用例通过;后续 macOS +路径测试修复继续单独追加,等待本批 Linux/Windows 现场后一起普通 push。 + +`0c9f395f` 的现场 CI 结果已收齐:ARM 通过;macOS、Windows、Linux root、Linux +unit 有失败,ASAN 尚在运行。此 head 不能合入。追加修复按失败证据收口: + +| 现场 | 原因与修复 | 尚需的证据 | +|---|---|---| +| Windows | 两个测试比较原始分隔符 / 使用无 drive 的 `/xlings`;改为规范化路径和平台绝对 recorded home。junction 的可读目标拼写不相等时,继续用文件对象 identity 证明归属,拒绝未知条目 | Windows 新 head 实际运行 | +| Linux worker | 私有 `/home` 遮蔽开发版 ELF loader/RUNPATH;私有 `/tmp` 遮蔽外部索引 symlink 目标。补精确 RO runtime 与 canonical 目标挂载 | 原 Lua / system-layer 隔离断言实际通过 | +| Linux root closure | fixture 在 RO `/` 下挂不存在的 `/root-view-result`,bwrap 尚未启动就失败;结果目录迁入已有私有 `/run` | broker 成功、inode 替换后 remove、refresh 失败 125 的实际用例 | +| Linux root 性能 | 300 payload build 约 76 ms;完整校验后切换 13 ms 超过 10 ms。减少重复 syscall、路径比较与 manifest 读取,保留全量库存与目标校验,预算不变 | 新 head root CI 的完整 checked switch 测量 | + +无感升级审查另发现私有 `bin/xlings` 固定于创建时的 source 路径/字节,owner 升级 +不能让它更新。创建及 runtime 共用 managed mirror 刷新:v2 证明绑定 owner/private home、 +domain marker 和 entry SHA;事务锁串行化 pending/ready;独占 snapshot 同时通过 digest +和 marked-client 检验,再由现有 `entry_binary::replace_with` 发布并修复 stale shim。 +恢复只接受日志授权的旧/新字节;未知、损坏或无归属 staging 保留并拒绝。新增六个 +filesystem 回归覆盖 source 路径变化、同路径升级、WAL 恢复、legacy 证明和拒绝无写。 +两轮只读复核未发现其他明确 P1/P2;构建与新 head 验收仍是独立门禁。 + +本批产品最终构建通过;108 个测试程序一次构建通过。dispatcher 六个实际回归 +全部 pass。300 payload 生成 median 23.034 ms,完整 checked switch median 4.626 ms, +两项预算均通过;链接被改成 regular file / 陌生目录也仍拒绝。受影响的注册 case +49 pass、8 skip、0 fail,另 legacy shim 用例通过。skip 全为本机无 bwrap 的真实隔离 +用例,必须由固定新 head CI 补齐。两项安全 lint 与 whitespace 检查通过。 + + +最终 review 的追加验收修正(尚待下一固定 head CI): + +- `a55deb67`:domain exec/use 用外层已解析的 Mount 保留宿主来源;`--` 后命令 argv 不变。 + 产品 dev 构建及 domain fixture 编译通过;namespace 用例本地 skip,未记通过。 +- `64f3e718`:两个客户端 fixture 使用隔离 xim/provider 及实际候选 bytes,符合共享 entry + 的所有权;remove 三场景本地通过。macOS 默认 Bash 的 UTF-8 fixture 改为 indexed arrays。 +- `da383ed4`:ROOT-SELF-UPDATE 走正式 hooks、真实 HTTP candidate archive;严格验证旧/new + payload 版本、generation 切换、stage0 SHA/inode 保留及 rollback,任何失败硬返回非零。 +- `9a24c7f9`:static candidate 的四类 SubOS 性能预算,以及完整 generation 校验预算 + 被 CI 显式执行且必须 pass。相同 blocking runner、交错配对、微秒计时和完整环境隔离; + 本地编译通过,真实 bwrap 不可用,四项 skip 明示,不算性能验收。 +- `a0d0d725`:测试程序已构建成功时,失败直接报告,不再调用所有 build.ninja 重编译。 + `4daee595` 的 Linux 单元测试在 05:21 返回,重复编译把 job 拖到 05:29 且淹没现场。 + +`4daee595` 的实际 root closure 重绑/rollback namespace 回归通过。剩余 worker full +观测失败已定位为同一线程分别创建 exec/network 两个 NEW_LISTENER,内核拒绝第二个; +私有域 backend 错判为 resolve_owner_home 对非 shim 也回退到调用者 entry。下一波分别 +复用单一通知 listener 并先分类真实 shim;不削弱审计、网络隔离或扩大宿主挂载。 + +发布前真实 CN 旧版 home 已准备:正式 `2026.10.8.1` quick install,明确设置镜像 CN, +安装 GCC 16.1.0 / mcpp 2026.10.5.3 / glibc 2.44.3;原生 C 程序编译运行和 doctor 通过。 +独立 mcpp registry 同样设置 CN,`mcpp new cn-ecosystem`→build(38.48 s)→run 成功, +使用实际 `import std` 模块 C++23。旧 payload 4448 条字节/链接/权限及四项用户数据 +sentinel 已留快照,待正式候选发布后执行真实 self update 对比;尚未把候选包冒充发布。 + + +最后三项根因修复与本地证据: + +- `588586d9`:worker 仅绑定 exact canonical recipe/source;现有 RO home 的 symlink + 自然解析到该目标,不再穿过 RO symlink 再绑定一遍。未知宿主 sibling 仍不可读。 +- `c58ac9a8`:Ports 先由 ShimClassifier 验证候选为 actual dispatcher,再查询 owner; + 普通 system bwrap 仍真实 probe,不因当前 `/xlings/bin/xlings` 调用者归属被排除。 +- `b70e8cc7`:worker 和 session-init 共用一个 exec/network notification listener, + 一次可信 pre-filter helper/SCM 交接;typed notice 保留所有 syscall 和 ABI checks。 + exec 审计只读 filename,审计完成后才 CONTINUE;失败拒绝、终止,native 返回 125。 + full + proxy/NAT 回归要求同一真实日志 row 的 exec path、net-attempt 和 mode 证据。 + +两轮专项只读复核未发现本批明确 P1/P2。产品 dev 构建 31.49 s、dist static 45.84 s +通过;108 程序构建 118.705 s 通过。kernel seccomp 四个真实用例通过,单 FD 同时 +阻塞 exec 与 datagram,net 拒绝及 exec allow/deny 实测通过。另针对性 XTEST 5 pass、 +16 skip、0 fail;跳过仅因本机无法创建 bwrap namespace,不能据此宣称 namespace +组合验收完成。schema/YAML parse、两项 safety lint、whitespace 检查通过。 + +`e8fbdd01` 后续修复与有限复验:backend 探针显式 `--unshare-user`,缓存区分 +user/mount namespace、凭据及 seccomp 状态;动态 worker 保留 exact canonical 与 +ELF 声明的解释器/RPATH 别名,只在被私有视图遮蔽时补 RO 绑定。新动态入口回归 +使用目录与文件双重 loader symlink,要求 dev/locked 启动成功、loader 不可写、 +相邻宿主文件不可读;宿主启动基线已实际通过,真实隔离部分本机明确 skip。 + +本批 dev 30.81 s、dist static 24.01 s 构建通过;109 个测试程序一次编译通过 +(126.570 s)。五个相关程序全部 exit 0:注册用例 6 pass、7 namespace skip、 +0 fail,另四个实际 kernel seccomp 用例通过。安全 lint 与 whitespace 检查通过。 +镜像 self-update fixture 动态选取官方 Linux 的真实前序版本,兼容发布后 latest +已与候选相等的情况;两种索引现场的前序选择与 shell 语法检查通过。仍需完整 +固定新 head 的隔离、性能、镜像升级和各平台 CI,尚未合入、发布。 + +ASAN 的 300-payload 工作量保留全部 projection、generation 与完整切换断言;只在 +最后将 instrumented timing budget 标为明确 skip,不把该测量当作运行时性能证据。 +单独 inventory 的未知目录/替换文件拒绝用例继续受 ASAN 检验。静态性能车道依旧 +强制两项 generation case 实际 pass,build ≤1 s、checked switch ≤10 ms 阈值未变。 +该调整后的普通构建复验两项实际 pass:生成 median 22.203 ms、完整 checked switch +median 3.561 ms,无 skip;ASAN 的新 head 结果待 CI。 + +发布路径亦遵循“不改写提交记录”:`bump_index.sh` 在已有 bot branch 上合并最新 +main、追加 bump 并普通 push;不存在则从 main 创建,merge 冲突或远端更新拒绝时 +停止,保留旧历史。token 由 credential helper 提供,不进入 remote URL。临时 bare +仓库实测连续两次发布保留 previous/main ancestry、重复版本不动 tip、一个 open PR +持续更新;缺分支、检查失败、冲突拒绝及无 token 输出均通过,尚未触发真实发布。 + + +`f1fd0b19` 的完整现场进一步修复:recipe 发布在宿主安全创建 `logs/recipes`, +拒绝重定向符号链接,并独立报告发布失败;没有给 worker 扩大写挂载。实际回归要求 +fresh home 输出落盘、stdout 不混入,恶意 log symlink 保留外部文件和时间。 + +Linux kernel 的 parent-UID0 mapping 检查确认嵌套失败原因。域管理控制器统一映射 +为非零 UID/GID 1000,仍映射同一宿主 owner;包括构建、带系统来源的只读 Must worker +和 runtime 管理。实际 rootfs provider 仍 UID0,所有子执行清 capabilities。 +DOM-SOURCE 保留只读/导出断言并检查实际 worker UID1000/CapEff0;domain exec 检查 +实际 root UID0/CapEff0。仅改 runtime 会留下 constructor 的同类故障,故两者统一处理。 +本批尚待构建与固定 head 的实际 namespace 验收,尚未合入、发布。 + +本批复验完成:dev 构建 29.82 s、dist static 23.01 s 通过;109 个测试程序一次 +编译通过(108.423 s)。四个相关程序均 exit 0,注册用例 5 pass、10 skip、0 fail。 +所有本机缺 bwrap 或静态入口的实际场景仍明确 skip;日志发布、嵌套 source worker +与实际 root 的身份断言等待下一固定 head CI,未把 skip 当作通过。两项安全 lint、 +whitespace 通过;mcpp.lock 仅重排且语义相同,已恢复。全部源码更改以追加提交、 +普通 push 保留记录。 + + +`476fc244` 现场 196 pass、3 fail、4 skip 的剩余修复:writable worker 仅为经过 +declared store、coordinate、canonical 校验的单 package 父目录准备挂载点;private +shadow mount 保持不变,相邻 payload 写入仍拒绝。HomeLayer fixture 的 self init +本来在 layer 声明前加载 index,legacy undeclared loader 合法执行顶层 Lua;现在 +从首次 metadata 读起声明同一实际系统层,并在 init 前后及 install 后验证未越界, +不删除已出现的文件,也不修改 undeclared recipe 兼容路径。 + +动态开发入口需要根内未声明的 GCC runtime,产品拒绝正确;DomainProducer 全生命周期 +改由实际静态 candidate 验收。Linux 静态步骤分别运行 source/export 与 domain producer, +三项完整 case 各恰好一条且必须 pass,任何 skip/missing/fail 都失败;所有 UID0/CapEff0、 +mount、session、NDJSON、用户数据和只读来源断言保留。本批尚待构建与固定新 head CI。 + +本批 dev 30.16 s、dist static 22.73 s 构建通过,109 测试程序一次编译通过 +(106.021 s)。三个相关程序 exit0,注册用例 5 pass、8 capability skip、0 fail。 +两项安全 lint、CI YAML parse 和 whitespace 通过;lock 仅重排且语义相同,已恢复。 +首次 payload 隔离、带来源 metadata 和静态 domain 全生命周期仍须新 head 实际 pass。 +未将本机 capability skip 计作完成证据,全部更改继续追加 commit / 普通 push。 + + +最终审查补充:payloadless config 的 has_hook 仅 Load/读取函数表,移除多余 +install_dir,不为 metadata 查询创建 package 父目录或 shadow。新增 dev scope 下 +显式版本与裸坐标两种卸载回归,要求各 uninstall 在独立 hook log 中实际一次。 + +机器 /etc 的目录写入从 filesystem root 逐级 openat(O_DIRECTORY|O_NOFOLLOW) 锚定, +目录创建、factory 链接和独立账号文件通过该 FD 操作;passwd/group 同一锁定 regular +FD 检查 nlink=1 后读写。合法 generation→payload factory 叶链接保留,目的名称改用 +lexically_relative,source 解析不再制造 ../ 目的路径。既有机器账号文件不读取 factory +默认值;未知账号 symlink/hardlink 明确拒绝并保留。fill/refresh 返回 expected,失败 +沿现有 generation 回切及 stage0 recovery 路径传播。外部 sentinel、嵌套 symlink、 +root 父目录、var、账号共享 inode 和路径置换均有真实 filesystem 回归,待本批复验。 + +已核实 146 required(0 planned / deferred)以及 actual passing-evidence 门禁;门禁 +存在不等于尚未执行的需求已通过。默认观测仅记录命令路径和环境名、不保存 argv +值;草案 R12 的 raw opt-out 尚未提供。PR ≤15 min 是当前未达到的墙钟目标,不能 +由静态产品性能预算 pass 推导满足。无 policy/无借用系统层的 recipe 保留 legacy +执行,macOS/Windows 的 dev 是 advisory;Must 无能力拒绝。旧 entry 不认识新的 +min_client,doctor 核实实际入口;不声称旧版能追溯执行新 schema。 + +本批有限复验完成:dev 33.92 s、dist static 45.90 s 构建通过;109 程序一次 +编译通过(267.721 s)。四个相关程序均 exit0,注册用例 18 pass、7 capability +skip、0 fail;其中 rootfs 13 项全部实际通过,含四组新增真实文件系统边界回归。 +模拟 release 删除 triple target 后,实际 retained xdev ci plan 与四个 candidate +验收程序的 listing 均正常启动。两项 safety lint、Linux CI YAML/脚本语法、 +whitespace 通过;lock 重排语义一致,已恢复。真实 namespace 卸载、静态 domain、 +性能、boot/self update 与 ASAN 仍须下一固定 head CI,不以本地 skip 计完成。 + +## 21. Payloadless publication 根因修复与有限复验 + +CI `8e8638fc` 唯一失败的实际 uninstall log 有一次 hook marker,错误链已定位到 +missing old payload 的 publication,而不是 hook 未执行。最终补丁保留现有目录 +事务、no-replace 首发、unknown/nonregular 拒绝;只对 validated selection 合成 +默认 XVM remove,hook 真失败非零。共享 dev/legacy 回归覆盖显式/bare once、 +9.9.9拒绝且旧日志不变、唯一原始 hook failure 不吞。 + +开发构建30.31 s、dist/static38.61 s通过,109测试程序构建303.617 s通过。 +新增无policy真实CLI专项1 pass;本机namespace能力缺失,dev worker验收待新head CI。 +ARM失败诊断只增加原始status/stdout/stderr、shim/provider traces及always保存已有 +archive;精确候选版本匹配和原失败退出码保留,不用诊断重跑放宽验收。 + +最终 xdev 有限复验:e2e/test_lua_worker exit0,注册用例3 pass、7本地 sandbox +capability skip、0fail;unit/test_xim_install exit0,传统gtest63 pass。安全删除lint +13标记、platform header lint、diff-check、ARM YAML/bash/Python语法均通过。 + +## 22. 静态 source fixture 的执行入口修复 + +固定`68399f72`的macOS(147/0/2)、Windows(105/0/44)、ARM cross/native、 +Linux root完整通过;Linux主单元、静态release与candidate cold-home阶段通过。 +ASAN实际107程序pass、0fail,1771.41s,其中build1711.75s、run57.59s;没有 +sanitizer错误,instrumented timing明确不作预算证据。 + +首次static-domain gate长时间停在source用例。只读源码和实际GTest ELF marker +显示具体递归路径:测试直接调用producer::run,后者把当前GTest image当CLI刷新, +version_of(--version)重新运行整套GTest并再次刷新。尚待该CI最终日志补齐现场, +不将推断标为进程树实证。fixture现显式prepare/refresh静态candidate,使用与 +production相同的checked source Facade和command,testkit执行每个操作上限120s, +完整记录退出码/signal/output。三个硬验收和所有只读/元数据/导出断言保留。 + +有限复验:相关2程序编译8.695s通过;实际静态RootExport1pass,source namespace +因本机缺bwrap1skip,不能替代CI实际隔离pass。客户端产品源码未改变。 + +## 23. 系统模板已挂载不等于目标 home 已配置 + +固定`2d1ef97a`:macOS147pass/0fail/2skip,Windows105/0/44,ARM cross/native和 +Linux root完整通过。Linux主单元204/0/5,static release和cold-home通过;静态 +source用例执行2.932秒后因const JSON缺字段断言SIGABRT,随后性能和下游未执行。 +ASAN尚待该轮结束;其他平台green不替代source gate。 + +日志确认producer初始化与创建的前置断言没有报错,随后测试读取借用登记字段 +中止;没有栈或home JSON现场,不能断言具体缺失key已被直接采集。源码审查发现 +`resolve_base_package_`仅看payload目录是否存在。checked source facade已把只读 +slot挂在logical prefix,因此原路径跳过installer,没有运行config和生成目标home +借用登记;与最早const读取`versions/source-member`缺字段的现象吻合。 + +修复对已存在base先调用`borrowed_mount`验证authority:验证失败拒绝,确为只读借用 +则在同进程调用既有`cmd_install`完成checked borrow/config和持久登记。作用域与原 +missing base自动安装一致,在新SubOS创建之前,不提前切到尚不存在的scope。 +来源payload保持只读,重复配置仍由既有configured verdict决定。测试保留所有原 +断言,同时先检查JSON pointer存在并输出完整受控fixture JSON,使失败能正常保存 +现场,避免abort。下一固定head仍须三个原静态隔离场景实际pass。 + +本批有限复验:dev31.95s、static/dist23.83s构建通过;相关2程序构建36.408s通过。 +静态source/export程序实际1pass、1本机bwrap能力skip、0fail,未将skip作为隔离 +通过。既有legacy fork shell四场景全部通过(local独立性、missing base自动安装、 +equals参数、missing source拒绝)。删除/平台header lint和diff-check通过;lock +重排语义一致已恢复。真实借用config和全部静态门禁待下一固定headCI。 + +## 24. 系统来源实际通过;空模板的 runtime grant 目标修正 + +固定`77431da7`:macOS147pass/0fail/2skip、Windows105/0/44、ARM cross/native、 +Linux root完整通过;Linux主单元204/0/5、static release和cold-home通过。 +ASAN实际107程序pass、0fail,1572.62s(build1521.52s/run50.05s),无sanitizer错误。 +静态source实际两个pass/0skip: +`DomainSourceProducer.ReusesOnlyCheckedReadonlyBytesAndExportsAnOwnedImage` 3876ms, +`RootExport.OwnsBorrowedClosureAndOmitsOtherScopeVersions` 56ms。§23产品修复已有 +真实namespace、只读来源、完整导出与未知字段保留的执行证据。 + +随后`DomainProducer.InstallsAtLogicalPrefixInsideANamespaceAndPreservesHostData` +在显式shell file grant阶段退出125,bwrap报`Can't create file at /bin/sh`。 +原grant未在重放中被改名;fixture的空template没有/usr/bin/sh,/bin等merged-usr +链接指向不可创建runtime mountpoint的只读投影。后续namespace FD错误是启动失败 +结果,不是另一处broker/FD根因。性能和发行版下游因此未执行。 + +最小调整只改fixture:精确核验bin/lib/lib64原始links后,仅移除本次Home自有的 +已知link并创建机器目录,按实际shell/loader/library literal目标创建regular占位。 +未知条目不替换,/usr和generation/payload不写;仍按原单文件RO grants实际执行, +不挂任何whole host目录。UID0/Cap0、exec/use两组 ./与~/mount source及`--`后argv +原样断言保留。产品源码、scope/closure和权限不变。相关程序编译6.771s通过;本机 +无bwrap的实际namespace明确skip,不计验收pass,等待下一固定head硬门禁。 + +用户最新要求:技术验收和综合自审完成后先汇报,用户review决定是否合入;本轮 +不合入、不发布。发布/CN latest实测保留为获准后的步骤,不冒充已完成。 + +## 25. 私有域复制/删除的 role 路由与独立 CI 验收 + +`4793f887` 的 Windows、macOS、ARM64 和 Linux root 全流水线通过。 +Linux 静态 DomainSourceProducer 与 RootExport 两例实际通过,DomainProducer 已通过 +运行时单文件授权、UID/capabilities、相对挂载、session 复用等断言,随后在 `cp` 和 +未确认 `remove` 的 outer role guard 失败;性能及发行版下游尚未执行,ASAN 仍运行。 +这不能报告为当前 head 达到合入标准。 + +`cp` 已有 physical producer 的 beneath-copy 适配,`remove` 已有控制目录所有权预检、 +用户确认、namespace 内实际删除及仅清理已证明控制数据的适配。错误在于调用这两条 +路径前仍使用一律拒绝 private domain 的 outer role guard。现在显式对实际 physical +HOME 调用同一个 strict kind/role/roles::check,并将 Remove 检查放入公共 `remove()`, +让 CLI 和 interface 都保护 running root、boot entry;其他未经适配入口仍拒绝 domain。 +用户确认退出 2、未知控制文件拒绝、共享 payload 与日志保留不变。 + +fixture 的复制结果、escape symlink 和 fork 结果三处改读 `rootfs/tmp`;`root` 是 +immutable generation 指针,不能用于机器用户数据。新无 namespace 依赖的回归用例 +通过实际 CLI/interface 验证 private scope copy、未确认删除、producer boot-entry +保护、损坏 role 状态和未知 outer 控制文件拒绝。已有损坏 boot 状态回归新增 CLI 与 +interface 删除断言,均保留 instance。 + +本地开发构建 31.42 s、静态构建 25.77 s;两组 SubosState 共 9 例实际 pass、0 fail、 +0 skip(private adapter 539 ms);legacy fork 四个场景实际 pass。完整 DomainProducer +在本地因无可用 bwrap 明确 skip,必须等新 head 的静态 CI 实际 pass。 +删除 lint、平台头文件 lint、diff check 通过。 + +CI 先保存通过 cold-home 的候选 archive,再分别执行 domain 与六项性能预算。 +保留三个 domain 与六项性能实际 pass 的原断言,任何失败/跳过/缺失仍在最终 hard +verdict 将 build job 置为失败。下游基于已验证候选是否存在继续执行,避免早期失败 +遮蔽 E2E、isolation、Arch 和 distro 的独立结果。修改的 YAML/bash 解析通过,真实 +hard-verdict shell 对 125 个 outcome 组合验证仅全部 success 返回 0。 + +仍保持 draft,后续全部追加提交、普通 push。按用户最新要求,全部必需 CI 通过并 +完成最终审查后先报告交给用户 review,未经用户确定不合入、不发布。 + +## 26. DomainProducer 已通过;独立验收暴露的 Linux 问题收口 + +固定 `2b802853` 的三个静态 domain/source/export 用例实际通过,包含用户贴出的 +DomainProducer 未确认 remove 退出 2,以及 copy/fork/已确认删除/宿主用户数据保留。 +macOS 147/0/2、Windows 105/0/44、ARM cross/native、Linux root 全流水线通过。 +ASAN 107 程序 pass、0 fail,1824.23s(build1763.48s/run58.69s)。 +Linux E2E 为 131 pass、9 fail;热/冷 exec 额外约206ms失败,isolation/system layer、 +Arch 和三个 distro 分片失败。执行报告146 required全部有声明,但仅123有通过证据、 +23未验证;因此这一 head 不具备合入条件。continue-on-error 的 step conclusion +曾被误读为性能通过,已更正;实际 outcome、日志和案例才是验收依据。 + +本批产品修复: + +- session-init 在 fork 前接入 SIGCHLD self-pipe,main/joined child 恢复信号, + supervisor 与 init 先排空信号再 reap,使退出唤醒不会丢失。200ms只作TTL/超时tick。 +- syscall trace 确认每个极小IPC包也分配并清零1MB。Linux先peek实际packet长, + 按长度和上限分配,接收仍保留原FD/CLOEXEC规则;数据或FD截断关闭已接收FD并拒绝。 +- ldconfig 使用私有tmpfs /run,先精确RO闭包alias,再挂独占RW cache staging, + 非递归将 /run 只读。机器自己的 /run和payload不改变,running root直跑规则不变。 +- system/multi/root HOME 不交还 SUDO_UID;只有 user/custom/portable HOME可以。 + install --system 在创建目录前要求root,移除会截断未知文件的固定write probe。 +- 缺 patchelf 的首次安装在同一个 InstallPlan 上加 Build-kind 顺序约束,工具自己的 + 闭包先于工具,其他节点后于工具;不改payload runtime_deps,不递归install或二次resolve。 + 自动注入工具不占用户 install_targets 的槽位,显式工具请求也遵守顺序。 +- 发布前将 installed[] 排序,与已有writer规范一致,避免多版本成功保存被误判为失败。 + 缺失旧注册不能证明任何旧asset,仍不能授权覆盖;incoming缺注册继续拒绝。 +- install/use 共用精确file destination选择:新选中的普通文件取消旧冲突active binding, + 保留installed记录和目录claims。共享目录继续由materializer无损unwrap,不写payload。 +- 只有提交后的已删除asset路径才清理空父目录;native rmdir拒绝普通文件, + symlink/nonempty/固定sysroot形状不移除。doctor对已登记同payload同文件名的 + dangling legacy路径构造准确旧link证明,仍由materializer复查和事务替换。 + +本地复验:先前9个失败E2E全部实际pass,包括共享destination、双方向目录unwrap、 +少asset版本切换、另一个SubOS损坏active的恢复,以及删除确认/未知数据/GC拒绝。 +精确更新两个内容断言,仅排除3个已知bookkeeping文件,未宽泛排除未知文件。 +SubOS名称预检保留并恢复native明确诊断;AC12新增默认home用户数据不变断言。 +subos_events改用本已有fixture index,避免生命周期断言依赖网络同步。 +Ubuntu20隔离容器的系统entry+新alice HOME,CN首次安装xz:实际patchelf→glibc→xz +配置,退出0;xz及liblzma均打印5.8.3。Arch正式makepkg/pacman仍需新CI实际通过。 + +针对性unit实际:session wakeup+packet/FD边界3pass,tool-order3pass, +materializer10pass,HomeContext12pass,cache5pass/1本机bwrap skip。 +补充隔离root容器:实际static ldconfig生成、/run禁止其他输出、来源字节与机器用户 +文件保留、用户替换cache拒绝,该case实际pass(130ms);这不替代CI非root隔离验收。 +本批最终dev与dist构建通过;delete/header lint、bash parse和diff-check通过。 + +本地非instrumented容器优化后cold额外中位5.035ms通过10ms预算,hot额外5.961ms +仍超过5ms预算;不放宽门槛、不把该失败报告为通过。ptrace采样只作定位,不作预算证据。 +新固定head仍要求三个domain、六个静态性能用例、全部平台与146需求真实执行验收。 + +按用户再次明确的要求:全部追加提交、普通push;达到技术标准并综合自审后先汇报 +给用户review决定是否合入,本轮不合入、不发布。 + + +## 27. 8cf6ebd0 全流水线结果与后续实际失败修复(2026-10-09) + +该固定 head 已跑完,尚未达到合入标准。macOS、ARM64、Linux root 全流程通过。 +Windows 唯一失败是 RunningRoot cache 测试把 POSIX 分隔符写死;修正为 native +filesystem 路径拼接,保留该 case,不跳过 Windows。 + +Linux 主 job 实际通过:107 个测试程序 pass,XTEST 216pass/0fail/5skip; +三个静态 domain/source/export case 均实际 pass,DomainProducer 16355ms,包含用户 +报告的未确认删除退出2、copy/fork/确认删除与 host 数据保留;六个静态性能 case +均实际 pass。非 instrumented 中位额外耗时:shim 1384us、hot exec 4174us、 +cold exec 6354us、root entry 4357us;300 payload generation build 65925us、 +checked switch 9037us。未修改预算。 + +ASAN 109 个测试程序通过,1949.87s(build1891.64s/run55.87s)。旧 E2E 报告 +141pass/0fail/0skip(包含 wrapper 汇总行,非141个独立 case),Arch、isolation、 +plan-distro、QEMU boot、WSL job 通过;未读取 WSL 实际环境日志,不以 best-effort +job green 断言 WSL1 实际执行。两项 distro 仍失败:rootfs_instance 的 scope +metadata/ELF closure、rootfs_image 的 shell builtin `true` 取值。最终需求报告为 +146 required、133 verified、13 unverified,两个明确例外外仍有11项阻塞。 + +### 27.1 scope 发布使用既有 canonical writer 的一致性规则 + +真实 core 日志显示:active-only 的 gcc-specs-config 旧登记在 Config 内存中存在, +installed[] 缺失;既有 writer 在保存时补齐 installed[],严格读回对比因此拒绝。 +现在注册批处理与 removal 发布都先通过唯一的 SubosWorkspace JSON writer/reader +做 canonical normalization;严格写后读回检查保留,未把失败变为警告。 +新增 legacy active-only + 无关新登记回归,在修复前实际失败;修复后完整 xvm +220 cases 实际通过(固定依赖验证621ms)。 + +### 27.2 shared payload 闭包与 root runtime datasets + +完整隔离复现先发现 CI 尾部摘要掩盖的更早错误:libxpkg 0.0.61 无条件把 installing +SubOS 的 lib 目录加到 shared payload RPATH。精确 root closure 正确拒绝这种路径, +不能靠扩大 HOME 挂载解决。上游 libxpkg #47 移除该隐式 fallback,保持 self 与 +resolved runtime dep 库目录、显式 deps override、build dep 排除。新回归在修复前 +对两个不同 scope 都失败;修复后4个测试程序通过,executor 97 cases pass。 +上游 CI 已通过。该 PR 保持待 review,未合入、未发布。 + +客户端候选依赖暂固定为 upstream commit 7c202104625a072b5e6553603cc18859c3e29b47, +不是本地 path override;正式版本交付仍待上游 review 和发布。固定 git 来源的 +static dist 构建51.36s通过,后续 root share 增量构建24.23s通过。 + +真实 Luban core 新 HOME 创建及 GCC 编译/执行通过。完整 rootfs fixture 接着通过 +core/cache、144 ELF executable 闭包、无 shell shim、fetch=layer,但 desktop +失败:root view 未提供已登记的 /share EGL vendor JSON。现在 scope facade +补入 share,root projection 合并 scope share 与 usr/share 的子目录;相关13个 +unit case 通过。相同真实 desktop fixture 在修复前 no-display,修复后 Mesa +llvmpipe 实际渲染 PIXEL=336699、RESULT=ok,全部 GL 对象来自该 HOME。 +完整 rootfs fixture 的新二进制复验仍在运行,不能以单个渲染探针替代全流程。 + +### 27.3 镜像夹具必须真正制造并观察损坏 + +`command -v true` 返回 shell builtin,改为复制 /usr/bin/true。后续 S4 的 getconf +带 payload RPATH,移除 /usr 的 libc link 不能证明程序失败。改用 S3 的 host ELF, +移除损坏 generation 的 libc link 和 derived cache。真实探针已观察到找不到 +libc.so.6、broken,再由 static xlings rollback 得到 restored。完整镜像流程仍 +在运行,self update 与 multi 布局尚不能据此宣布通过。 + +本地 Docker 复验用私有测试 HOME;repo、bwrap 来源只读。最初临时目录 mode700 +导致清 capabilities 后不能穿越 ancestor,以及本地 index URL 不随 image 迁移, +均记为本地夹具问题;修正夹具后用正式索引继续。未改变 host sysctl/AppArmor。 +最终客户端新 head 的五平台 CI 与146需求 hard gate 仍须重新验收;本轮只追加 +commit/普通 push。达到技术标准后先向用户汇报 review,不合入、不发布。 + + +## 28. 完整 rootfs 通过与镜像剩余验证(2026-10-09) + +49a6f0f6 已普通 push,PR共139 commits,最新main仍c55d89aa。固定head的 +macOS与ARM64 CI已完整通过;Linux主job的unit与跨平台脚本通过,release和下游 +仍运行;Windows、root、ASAN仍待完成,不以局部green作全流程结论。 + +最新二进制的完整 rootfs_instance fixture 实际退出0,全部9阶段通过,包括 +144个可执行文件闭包检查、无shell shim、逻辑loader/cache、fetch=layer和 +Mesa离屏渲染。完整镜像fixture实际通过S1–5,multi/self update仍待收口。 + +本地 root Docker 的 controller UID1000 嵌套UID map被拒绝;普通用户Docker的 +基础bwrap探针也被UID map拒绝,不能作为CI普通用户的替代环境。试验UID0能通过 +嵌套探针,但保留controller capabilities,明确不采用:该改动未commit/push, +已撤回,原UID1000与隔离边界保持。该试验镜像运行不计最终验收证据。其后 +实际root缓存运行还遇到nested proc mount EPERM,未静默回退或声称通过。 + +独立网络问题:namespace producer的clearenv丢失宿主标准HTTP(S)/ALL_PROXY及 +NO_PROXY设置;实际下载曾403/低速失败。现在只转交8个标准大小写代理变量,仍 +清理宿主scope环境;本地同一下载流程已完成实际资源传输及校验,完整multi仍 +因上述namespace环境问题未通过。最终static增量build通过,完整acceptance仍 +须由支持普通用户namespace的CI验证。正式依赖#47仍未合入/发布;不合入641。 + + +## 29. b3dd5009 完整发行版通过与F12证据接线(2026-10-09) + +固定head b3dd5009、141 commits:Linux主job107程序pass、XTEST216pass/0fail/5skip。 +额外三个静态domain/source/export与六个性能case实际pass/0skip;DomainProducer +16416ms,包含未确认删除退出2与host数据保留。硬门禁核对candidate/domain/perf +三个真实outcome均success。性能额外中位shim1408us、hot4133us、cold6542us、 +root entry4257us;300 payload build67646us、checked switch9598us,预算未放宽。 + +macOS、Windows、ARM64、Linux root完整通过;macOS154pass/0fail/2skip, +Windows111pass/0fail/45skip。Arch、isolation、QEMU boot、旧E2E完整通过; +旧E2E报告141pass行(含wrapper行),不是141个独立用例。 + +两个此前失败的distro实际完整通过:rootfs_instance216509ms,全部9阶段, +包含144可执行闭包、no-shell shim、core/cache、fetch=layer与desktop render。 +rootfs_image216373ms,全部7阶段,含single、多前缀/xlings、glibc损坏回滚、 +patchelf缺失拒绝、self-update真实HTTP下载与rollback。before为正式.1、root.gen/2; +after为候选.2、root.gen/3;rolled恢复.1与root.gen/2,stage0字节/inode不变 +断言通过。这是候选验收,不能当作.2正式发布后的CN升级证据。 + +最终报告143/146 verified、3 unverified;Windows quoting在独立Windows硬门禁 +验收,WSL1为明确best-effort例外,唯一剩余硬阻塞是F12。主测试因可用system +bwrap跳过;专用isolation-fix脚本实际运行、原始uid-map错误/最小权限提示/ +root-owned修复均通过,但缺covers,汇总正确拒绝计入。现在补F12与DOC-ISOLATION +metadata,CI要求该suite metadata完整;前提不符不再exit0冒充pass,raw uid-map +断言无论fallback退出码都检查。原CI执行记录的真实report重放:补metadata前 +F12 passed_by为空且退出1,之后有实际脚本passed_by并退出0;不以重放代替 +追加提交后的新CI。前提不符的负对照:旧guard退出0、新guard退出1。 + +此时ASAN仍运行,未跳过/取消;完成后再追加push,保留该固定产品head的证据。 +libxpkg #47的0.0.62候选head838fe5d CI通过;现有三平台索引引用同一源码包, +Linux预编译包仅补充构建证据。源码候选已准备,尚未合入/发布。 +本轮继续只追加commit、普通push;达标后先由用户review,不合入、不发布。 diff --git a/.agents/docs/2026-10-09-pr-641-self-review.md b/.agents/docs/2026-10-09-pr-641-self-review.md new file mode 100644 index 000000000..3976d4bce --- /dev/null +++ b/.agents/docs/2026-10-09-pr-641-self-review.md @@ -0,0 +1,63 @@ +# PR #641 自我 review(Part 1 / 2 / 3 综合,2026-10-09) + +- 范围:PR #641 全部 165 个提交(423 个文件,+49,571 / −5,257),重点是 Part 3(C43–C53)以及它与 Part 1、Part 2 的接缝。 +- 依据:三份设计文档、`tests/requirements.toml`(163 required / 3 deferred / 0 planned)、三平台 CI、本地全量测试。 +- 结论放在最前面;每一条发现都写清已修复还是保留,以及证据。 + +## 0. 结论 + +Part 3 的十一项(C43–C53)全部落地,没有用"文档声称"代替实现的地方。三处没有做到设计原意,分别是: + +| 项目 | 现状 | 理由 | +|---|---|---| +| vz 承载的 VMM | 承载侧完整(契约、生命周期、授权),由假助手验证;签名的 `xlings-vm` 助手本身没有交付 | 需要 Virtualization.framework 的 entitlement 签名,且托管 macOS 无法嵌套虚拟化。拒绝信息如实说明"尚未发布" | +| Windows 上加入运行中的会话(join) | Windows 有 Job Object 进程范围(整树、超时 124、退出码),没有 supervisor | 需要基于 CreateProcess 的 supervisor,这是下一步,没有对外声称已完成 | +| openkal 接入产品代码 | 两个问题都有实测结论;没有把 `kal_*` 接入 xlings | 目前没有任何一项可移植功能会因此变好;第一项有意义的接入随 Luban 内核一起做(§19 C53) | + +## 1. 自我 review 中发现并已修复的缺陷 + +| # | 发现 | 怎么发现的 | 修复 | +|---|---|---|---| +| R1 | C45 的"payload 已不存在"检查让 `subos export` 自己提交的代被拒(导出镜像的链接指向逻辑路径) | 静态车道 RootExport | `b7040ad`:提交只校验树;回滚才校验 payload | +| R2 | 3000 payload 的切换在 dev 构建下 11.1 ms | Linux-root 车道 | `94041bd`:解析改为不用流,payload 只 lstat 一次,dev 构建 3.0 ms | +| R3 | 承载的终端命令依赖 guest 里的 `/usr/bin/env`,而镜像没有用户态 | 假 wsl.exe 的生命周期测试 | `__carrier-env` | +| R4 | interface 的 `list_subos` 有一份自己的副本,缺少 `kind` | C48 测试 | CLI 与 interface 共用一个发射函数 | +| R5 | `luban-init` 作为根包目标时链接了整个前端(6812 个符号) | `nm` | 改为独立包,链接后 0 个前端符号 | +| R6 | 模块中的 `inline constexpr` 变量只在使用它的翻译单元中发射,只链接 modules 的二进制无法链接 | 单独链接 `luban-init` | 去掉 `inline` | +| R7 | macOS 会话从 `/proc/self/exe` 取自身路径 | macOS 车道 125 | 改用 `platform::get_executable_path` | +| R8 | `subos start` 在 macOS 上仍然拒绝("needs Linux") | macOS 车道 | 有会话宿主的平台(Linux、macOS)都允许 | +| R9 | 记录"SubOS 在哪里运行"的文件读不出来时被当作本地处理 | 按 AGENTS.md 的"读不到不等于空"规则逐条检查 | 改为拒绝(新增测试) | +| R10 | vz 拒绝时给出的路线指向一个索引里不存在的包 | 自查用户可见文案 | 文案说明"尚未发布" | +| R11 | `lint_platform_headers.sh` 从来没有接入 CI | C43 | 所有 `tools/lint_*.sh` 都进 CI | +| R12 | 测试从调用方继承了 `XLINGS_SUBOS_MODE`(在 SubOS 里运行测试会失败) | 本地运行 | 测试显式设置该变量 | +| R13 | macOS 的 bsdtar 列表格式与 GNU 不同 | macOS 车道 | 测试同时识别两种格式 | +| R14 | openkal 探针放在 `tests/` 下,被 `mcpp test` 当作测试 | macOS 车道 | 移到 `tools/` | + +## 2. 分角度检查 + +**架构。** 依赖方向由 lint 强制:`luban → modules`,`modules` 不依赖 `luban` 和前端。后端分派已移出 core。工具解析只有一张表,提权只有一个入口。矩阵由各实现的声明生成。 +遗留:`src/core/subos/sandbox.cpp` 仍有三处按后端判断的连接代码(bwrap 的 seccomp 过滤器、broker socket 的挂载、root view),都属于连接而非策略,下一步可以移进各实现的 `launch`。 + +**稳定性。** H1/H2/M1/M2 已修复并各有测试。持久化的刷盘顺序由 trace 断言。保留与释放有 CLI 级测试。凡是读不到的状态,一律当作"仍被引用"或"拒绝",没有一处当作空。 + +**简洁。** 没有新增顶层名词;承载没有新协议,复用 NDJSON interface;Intent 没有引入第二套策略语义(872 个用例的 golden 证明逐字节一致)。 + +**用户体验。** 用法没有变化。拒绝时都给出确切的路线,agent 模式不会停下来提问。`subos list` / `status` / `doctor --isolation --json` 都显示承载、ABI 和视图。 + +**兼容性与无感升级。** `instance.json` 只追加字段,旧客户端忽略。旧的代没有 inventory 时回退到完整遍历校验。`links.tsv` 格式不变。保留账本只在第一次需要时创建。 +策略文件的最低客户端版本提到 `2026.10.9.1`:`2026.10.8.2` 从未发布,没有任何已发布客户端写过这个值。 +`list_subos` 在某个 SubOS 的种类读不出来时,现在返回 1 而不是 0。这是按"读不到不等于空"做的有意改动。 + +**跨平台。** Linux 零行为变化,由 golden 证明。macOS 第一次有了会话宿主(分帧的流式 socket、`/tmp` 短链接),三平台 CI 实测。Windows 有 Job Object 范围和 E2E-08(有 WSL2 时测生命周期,没有时测拒绝及其路线)。 + +**一致性。** 需求 ID 中 163 项 required、3 项 deferred(都写了原因)、0 项 planned。macOS/Windows 会话由各自的工作流把关,Linux 报告中明确记为"由其他工作流把关"。 + +## 3. 交接时遗留问题的现状 + +| 交接遗留 | 现状 | +|---|---| +| F12 证据接线 | `24cb3ae9` 的 CI 已全绿 | +| libxpkg 0.0.62 依赖链 | #47 已 review 并合入(`912720f`),客户端固定到 main 上的该提交。三平台源码索引的 0.0.62 条目仍待发布(客户端走 git rev,不依赖这一步) | +| `--observe-raw`、PR 关键路径 ≤15 分钟 | 不在 Part 3 范围内,仍未做 | +| macOS / Windows 的隔离只是 advisory | 由承载提供强隔离:wsl2 已可用;vz 等待助手发布 | +| 私有域删除时内层成功、外层失败的恢复 | 未改动,仍是已知限制 | diff --git a/.agents/docs/2026-10-09-subos-architecture-design-part3.md b/.agents/docs/2026-10-09-subos-architecture-design-part3.md new file mode 100644 index 000000000..56c9755cc --- /dev/null +++ b/.agents/docs/2026-10-09-subos-architecture-design-part3.md @@ -0,0 +1,612 @@ +# SubOS 总体架构设计 Part 3:内容 × 视图 × 承载 —— 跨平台承载、内核无关与 Luban 分层 + +- 承接:`2026-10-05-subos-architecture-design.md`(Part 1)、`2026-10-06-subos-architecture-design-part2.md`(Part 2)。 + 本文**修订** Part 2 的 §3.1(呈现方式 → 视图,并新增"承载"维度)和 §13(跨平台),以及 Part 1 的 §17 与附录 A + (平台抽象层的接口形状、macOS / Windows 路线)。其余章节不变。 +- 依据:PR #641 head `24cb3ae9` 的代码 review(2026-10-09),三轮维护者讨论,以及 `mcpplibs/openkal` 0.15 的 `SPEC.md`。 +- 交付:**不在 #641 中**。#641 合入后,作为后续的一组 PR,checkpoint 从 C43 开始编号(§13)。 +- 状态:设计草案第一轮,本文不含代码改动。§1 是已确认的决定,§15 是本轮需要 review 的问题。 + +--- + +## 0. 一句话 + +**SubOS 是唯一的名词。** 一份 SubOS(内容)可以用不同的**视图**呈现(PATH 叠加 / 沙箱 / 根 / 整台机器), +也可以跑在不同的**承载**上(宿主内核 / WSL2 / 轻量 VM / Luban 自己的内核)。payload 的目标 ABI 决定它需要哪种承载。 +凡是和内核相关的语义,都对齐 openkal:Linux 只是一种实现,以后的 Luban 内核是另一种。 + +由此: + +- **各平台的体验一致,指的是用法一致**:命令、参数、事件、退出码、policy 都相同。实现上可以借助平台现成的技术 + (Windows 用 WSL2,macOS 用 VZ),但 xlings 不和某个平台的隔离机制绑定。 +- 在 Windows 上,**是 xlings subos 以 WSL2 作为承载**,而不是"让用户到 WSL2 里去运行 xlings"。 + 用户面对的始终是 Windows 上的 `xlings subos …`。 +- **Luban OS = machine 视图 × native 承载**,也就是 R 模式,加上内核、安装器/ISO 和发布通道。 + R 模式本身已经是"以 xlings 作为系统包管理器的发行版"的雏形。 +- 代码上,`luban/` 与 `modules/` 都是顶层目录:只属于"一台机器"的部分放在 `luban/`(模块名以 `luban.` 开头), + xlings 与 Luban 共用的部分放在 `modules/`(模块名继续以 `xlings.` 开头)。 + +--- + +## 1. 本轮确认的决策(维护者,2026-10-09) + +| # | 问题 | 决定 | +|---|---|---| +| E1 | 多平台体验一致的含义 | **用法一致**,不要求各平台机制相同;能用平台技术(如 WSL2)支撑的就用 | +| E2 | Windows 上的完整功能 | **xlings subos 基于 WSL2 实现**:用户不进入 WSL,也不直接在 WSL 里使用 xlings | +| E3 | 内核 | 早期使用 Linux 内核,长期可能有自己的内核;**内核相关语义尽量对齐 openkal,做到内核无关** | +| E4 | Luban 的代码归属 | 顶层新增 `luban/`,与 `modules/` 并列,模块名以 `luban.` 开头;xlings 与 Luban 共用的放在 `modules/` | +| E5 | `modules/` 的命名 | 共用包继续以 `xlings.` 开头 | +| E6 | hosted 形态是否默认带 Luban 承载 | 维护者:"选最合适的设计"。本文的选择:**按 ABI 懒创建承载**(§5.6) | +| E7 | Linux 宿主上 xlings 的定位 | 维护者的修正:xlings 在 Linux 宿主上**几乎从不依赖宿主环境**,工具的运行时全部来自 xlings 生态,只是看起来"直接装在宿主上"。这和"宿主的包"不是一回事(§3.2) | + +--- + +## 2. 现状(#641 head `24cb3ae9` 的 review 结论) + +### 2.1 上层模型可以跨平台,执行层是按 Linux 设计的 + +| 层 | 模块 | 可移植性 | 依据 | +|---|---|---|---| +| 模型与生命周期 | model、manifest、roles、userdata、home_view | 好 | 纯数据和文件系统;`roles::check` 与平台无关 | +| 策略 | policy、policy_store | 好 | 纯数据,未知字段一律拒绝,Must/Should 降级模型通用 | +| 宿主能力 | caps、gates | 差 | `Caps` 的字段写死为 `bwrap/proot/userns/landlock_abi/pasta/seccomp`;`gates.cpp:60-73` 中 macOS/Windows 那一行是手写的 "not implemented" | +| 规格编译 | `spec::compile` | 中 | 输出 `SandboxSpec` 是 Linux 字段的并集(`unshare_*`、`MountKind::Proc/Dev`、`pasta_bin`、`landlock_rw`、`proot_root`);按 `caps.platform == "macos"` 字符串分支(`spec.cpp:209`) | +| Provider | provider | 中 | 纯函数、可做 golden 测试;但接口是"返回 argv",默认后端一定是外部 helper 进程 | +| 后端分派 | `src/core/subos/sandbox.cpp:858-868` | 差 | 分派写在 core 的适配层,加后端要改 core | +| Session | session | 差 | `kSessions = platform::is_linux`;使用 `SOCK_SEQPACKET`(`process.cpp:128,359`),**macOS 的 AF_UNIX 不支持**;`Launch` 里是 `int` fd | +| 两套执行路径 | `sandbox.cpp:915-948` | 差 | macOS/Windows 设置环境变量后直接 `run_shell`:没有 supervisor、broker、审计、join、timeout | + +### 2.2 依赖工具 + +- **SubOS 不依赖 brew**(产品代码里出现 brew 的只有 `platform/unix.cppm:110` 的一处注释)。 +- bwrap、proot、pasta 由 `caps::locate_*` 按 payload → runtimedir → host 的顺序查找,并记录来源。 +- `root_cmd.cpp:947/951/1038` 通过 `host_tool_` 直接调用宿主的 `tar`、`mkfs.ext4`,用 `as_root_` 走 sudo。 + **工具解析有两套,没有统一入口。** + +### 2.3 root 视图与 generation(review 发现,需要在本设计中解决) + +| 编号 | 问题 | 位置 | +|---|---|---| +| H1 | `remove` 判断 payload 是否还被引用时只看 workspace(`installer.cpp:1586`),不看 `root.gen/*`;`rollback` 前的校验 `owned_generation` 只核对链接字符串,不检查目标是否存在。payload 删掉之后再回滚,`/usr` 里的链接会悬空。**违反 Part 2 §6.3 "旧一代引用的 payload 不会被 GC"**。这是读代码得出的推断,尚未复现 | `rootfs.cpp:121` | +| H2 | `commit` / `switch_to` 没有 fsync 文件和目录。原子性只对并发读者成立,掉电后指针可能指向元数据为空的一代 | `rootfs.cpp:314-386` | +| M1 | `rootfs::prune` 在产品代码里没有调用方,generation 只增不减;`current()` 读不懂指针时,`prune` 可能删掉指针真正指向的那一代 | `rootfs.cpp:395` | +| M2 | `switch_to` 每次都完整遍历一代的树(`owned_generation`):300 个 payload 时 9.598 ms,预算 10 ms,余量只有 4%,链接越多越不够 | — | +| — | root 视图直接使用 `fs::create_symlink` 和 `rename`,generation 的语义被写死在 POSIX 符号链接上 | `rootfs.cpp` | + +--- + +## 3. 核心模型(修订 Part 2 §3.1) + +### 3.1 三个维度 + +| 维度 | 取值 | 含义 | +|---|---|---| +| **内容**(SubOS) | workspace、home、policy | 一份 xlings 生态的用户态。**唯一的名词** | +| **视图**(View) | `overlay` · `sandbox` · `root` · `machine` | 进程以什么方式看到这份内容 | +| **承载**(Carrier) | `local` · `wsl2` · `vz` · `native` | 这份内容跑在哪个内核上,以及宿主 xlings 怎么到达它 | + +**ABI 规则**:一个 payload 的目标 ABI(取自它的平台块:`linux` / `macosx` / `windows`,以及以后的 openkal 目标) +决定它需要哪种承载。 + +| payload 的 ABI | Linux 宿主 | Windows 宿主 | macOS 宿主 | Luban OS | +|---|---|---|---|---| +| linux(ELF) | local | wsl2 | vz | native | +| windows(PE) | — | local | — | — | +| macosx(Mach-O) | — | — | local | — | +| openkal | local | local | local | native | + +一个 SubOS 的 ABI 由它的 workspace 决定,在创建时可以显式指定(§10)。 + +### 3.2 视图(Part 2 的"呈现方式"改名并补齐) + +| 视图 | 进程看到的 | 现有对应 | +|---|---|---| +| `overlay` | 宿主的根;SubOS 的 `bin/` 在 PATH 前面,工具的运行时(loader、库、sysroot)来自 xlings 生态 | 默认的 shell 级进入;全局 SubOS | +| `sandbox` | overlay 加上隔离(宿主文件、网络、进程、身份) | `--sandbox` | +| `root` | SubOS 本身就是 `/`(`/usr` 是 generation 投影) | `--rootfs` 实例、导出的镜像 | +| `machine` | `root` 视图成为整台机器的根,由 stage0 启动 | R 模式、Luban OS | + +**overlay 视图不是"宿主的包"**(E7):内容来自 xlings 生态,只是投影到了宿主的 PATH 上。所以在 Linux 宿主上, +overlay 和 root 的区别只在于"谁是 `/`",而不在于"是谁的包"。 + +### 3.3 用三个维度描述所有形态 + +| 形态 | 内容 × 视图 × 承载 | +|---|---| +| Linux 上默认的 xlings(U/C/P/S/M) | 全局 SubOS × overlay × local | +| `subos use --sandbox` | SubOS × sandbox × local | +| `subos new --rootfs` | SubOS × root × local | +| R(multi / single) | `default` × machine × local | +| Windows 上 Windows 原生工具的 SubOS | SubOS × overlay(home 重定向)× local | +| **Windows 上 Linux 的 SubOS** | SubOS × sandbox / root × **wsl2** | +| **macOS 上 Linux 的 SubOS** | SubOS × sandbox / root × **vz** | +| **Luban OS(ISO)** | `default` × machine × **native** | + +**不变量**:同一份内容声明,在每一种承载上得到的 root 视图完全一致。这是 Part 2 §3.1 不变量的推广,并以 VIEW-CARRIER-EQ 进入测试(§12)。 + +### 3.4 术语 + +- **宿主(Host)**:承载者,即 Ubuntu / macOS / Windows。Luban OS 上没有宿主(或者说宿主就是内核)。 +- **"嵌套的 SubOS"**:root 视图里运行的 xlings 看到的是 machine 视图(Part 2 §3.3 不变)。 +- **不新增 "System" 这类名词。** "这个 SubOS 是一个根"或"这个 SubOS 是这台机器"都是视图。 + Part 2 §3.4 的 `kind` / `role` 保持不变:`kind=rootfs` 表示该 SubOS 支持 `root` 视图,`role=host` 表示它当前是 `machine` 视图。 + +--- + +## 4. 与 openkal 的关系 + +### 4.1 边界 + +openkal 0.15 规定了程序与运行环境之间的 ABI(`kal_*`),实现按平台分为不同的包(`openkal-linux` / `-macos` / `-windows`)。 +与 SubOS 直接相关的几条: + +- 文件系统基于能力:没有隐含的工作目录。程序只能看到调用方授予的预打开目录,数量、顺序、名字都确定,第一个就是工作目录(SPEC §7.13)。 +- `kal_process_spawn` 只传调用方明确授予的三个流和目录,**连调用方自己继承来的句柄也不传**;`kal_spawn.job` 表示"一组一起结束的进程"。 +- 强制隔离被明确留给环境:"the confinement of one that does not [use only its preopens] is the environment's responsibility"(§7.13)。 + 权限和身份**不在规范范围内**(§11 第 6 条)。平台依赖属于各个包自己(README 规则 2)。 + +所以分工是: + +``` + SubOS(环境的构造者与守卫) + 决定:授予哪些目录、流、网络端点、环境变量,属于哪个 job + 强制:对不只使用 kal_* 的程序,由承载上的隔离机制兜底 + ──────────────────────── openkal ABI(程序收到什么)──────────────────────── + 程序:只使用 kal_* 的程序,在任何实现上看到相同的环境语义 +``` + +### 4.2 对齐方式 + +| SubOS 概念 | openkal 语义 | Linux 实现 | 以后 Luban 内核上的实现 | +|---|---|---|---| +| 文件系统授权 | 有名字、有顺序的预打开;第一个是 cwd | bwrap bind / drvfs / virtiofs | `kal_process_spawn` 的授权由内核强制执行 | +| 进程范围 | `kal_spawn.job` | pid ns(以后加 cgroup) | 内核原生的 job | +| 网络 none / proxy | 只交出指定的端点(`openkal.net` 只认地址和端口) | netns + 中继 | 不授权就无法访问 | +| 终端 | `openkal.terminal` | 现有实现 | 同左 | +| root 视图 / generation | 不在 openkal 范围内:定义为**版本化的"逻辑路径 → payload"映射表**(§7.2) | 符号链接树 + `rename` | 内核的命名空间表 | +| 身份、exec 观测、设备、句柄传递 | openkal 刻意不定义 | 留在 `modules/platform` 的内核特性适配中 | 由那个内核的扩展提供 | + +### 4.3 `modules/platform` 的演进 + +- **可移植的部分逐步改为调用 `kal_*`**:spawn + job、相对路径 fs、stream、net、terminal、time、random。 + 每换一项,就用 openkal 的 conformance 测试替代 xlings 自己的一部分跨平台测试。 +- **openkal 不覆盖的部分**(隔离、挂载、身份、文件所有权、句柄传递、ELF、Windows 硬链接 shim) + 留在 `modules/platform` 里作为"内核特性适配",按 openkal 的风格声明:每个平台都声明,不支持的返回 unavailable。 +- **接入前要先确认两点**(C53 的前置条件): + 1. README 规则 3 要求一个镜像里只有一个 C 运行时。xlings 能否在现有 C 运行时下(Linux musl / macOS libc / Windows MSVC CRT) + 只链接 `openkal-` 的实现,还是必须整体切换到 `openkal-musl`(这在 Windows 上意味着工具链从 MSVC 换成 Cygwin 式环境)? + 2. 跨进程传递句柄(SCM_RIGHTS / `DuplicateHandle`)是否在 openkal 的规划中。不在的话,它留在内核特性适配里。 + +--- + +## 5. 承载(Carrier) + +### 5.1 接口 + +```cpp +// modules/carrier +struct CarrierCaps { bool supported; gates::Enforced enforced; std::string reason, route, evidence; }; +struct Carrier { + std::string_view name; // local | wsl2 | vz | native + CarrierCaps (*probe)(const HomeView&); // 可用吗?缺什么?怎么启用 + std::expected (*ensure)(const HomeView&, Elevation&); // 按需创建 / 启动 + std::expected (*control)(const Endpoint&); // 控制通道:NDJSON + std::expected (*terminal)(const Endpoint&, std::span argv, + const Stdio&); // 终端通道:交互或批处理 + std::expected (*grant)(const Endpoint&, const HostPath&, Mode, std::string_view name); + std::expected (*stop)(const Endpoint&); // 空闲时停止 +}; +``` + +**宿主 xlings 与承载里的 xlings 之间不定义新协议**: + +- **控制通道**沿用现有的 NDJSON interface(`xlings interface`)。承载里运行的是 `xlings __carrier-agent`, + 它就是那一侧的 interface 服务端。 +- **终端通道**直接在承载里执行对应的子命令(例如 `xlings subos use `),终端由承载的启动器负责。 +- 不需要跨承载传递文件描述符。 + +### 5.2 `local` + +即今天的行为:内容、视图、隔离都在宿主内核上完成。Linux 宿主上所有视图都由 `local` 提供; +macOS/Windows 上,本机 ABI 的 SubOS 由 `local` 提供 overlay 视图(home 重定向)。 + +### 5.3 `wsl2` + +**形态:每个 xlings home 一个承载发行版,不是每个 SubOS 一个。** + +| | 每个 SubOS 一个发行版 | **每个 home 一个(采用)** | +|---|---|---| +| store、引用计数 | 每份 ext4 各存一份,重复 | 共享,和 Linux 宿主的模型一致 | +| generation / rollback / GC | 分散 | 由承载里的 Linux xlings 统一管理 | +| SubOS 之间的隔离 | WSL 的发行版边界(所有发行版共用同一个 VM 和内核,并不更强) | Linux xlings 的 ns + policy,和 Linux 宿主一致 | +| 启动开销 | 每个都要冷启动 | 只启动一次,空闲时停止 | + +需要单独导出或分发的 SubOS,可以显式指定 `--carrier-instance dedicated`,单独占一个发行版(例外情况)。 + +**设计细节**(WSL 的行为需要在 C49 中逐条实测): + +- **创建**:`wsl --import xlings- \carriers\wsl2 --version 2`。 + - 按用户注册,不需要管理员; + - rootfs 是官方 `subos:luban-tiny` 的 `export --tar` 产物,作为 xpkg 分发; + - `` 由 home 的路径派生,所以同一台机器上可以有多个 home。 +- **承载发行版本身就是一个 machine 视图的 Luban(tiny)**,承载里的 home 位于 `/xlings`(Part 2 §4 的系统域,multi 布局)。 + 这样它和官方镜像、导出镜像共用同一个前缀域,payload 可以直接复用。 + Windows 侧的 xlings 是它的远程控制端;用户在 Windows 上看到的 `subos list`,就是承载里的 SubOS 加上本机的 SubOS。 +- **与 Windows 隔离**:承载里的 `/etc/wsl.conf` 写入 + `[automount] enabled=false`、`[interop] enabled=false`、`appendWindowsPath=false`。 + interop(从 Linux 里执行 `.exe`)是逃逸到 Windows 宿主的通道,**必须关闭**,并在 C49 里用负对照测试证明它确实被关掉了。 +- **授权映射**:项目目录不靠自动挂载,而是按授权在承载里以 root 挂载:`mount -t drvfs 'C:\proj' /grant/`。 + 这和 openkal 预打开的语义一致(§6.1)。授权的生命周期与 session 相同。 +- **控制通道**:`wsl.exe -d <名字> -u root --exec /xlings/bin/xlings __carrier-agent`,NDJSON 走标准输入输出。 +- **终端通道**:`wsl.exe -d <名字> -u root --exec /xlings/bin/xlings subos use …`。 + 交互时由 wsl.exe 负责终端;退出码、Ctrl-C 透传。环境变量由协议显式传递,不依赖 `WSLENV`。 +- **生命周期**:第一次需要时创建(§5.6);按需拉起;空闲后 `wsl --terminate`;`subos remove` 删除最后一个承载里的 SubOS 时**不**自动注销承载。 +- **用户数据**:承载的 vhdx 里装着 SubOS 的 home,**属于用户数据**。注销承载(`--unregister` 加删除 vhdx)只能由经过确认的删除命令触发, + 并按 AGENTS.md 的"SubOS user data"规则写入 `destructive.ndjson`。 +- **前置条件**:首次启用 WSL 需要一次管理员权限(`wsl --install --no-distribution`),通过 `Elevation` 接口(§6.4)完成并明确告知用户。 + WSL1 是系统调用翻译,没有 namespace,`probe` 报告 unsupported。 +- **失败时**:WSL 不可用时,按 Must/Should 规则拒绝或降级,并给出启用路线。Windows 本机 ABI 的 SubOS 不受任何影响。 + +### 5.4 `vz`(macOS) + +形态与 `wsl2` 相同:每个 home 一台轻量 VM,里面是 machine 视图的 luban-tiny,home 位于 `/xlings`。 + +- 控制通道和终端通道走 vsock 上的同一套 NDJSON interface。授权通过 virtiofs 共享目录实现。 +- 客户机的内核和 initrd 作为 xpkg 分发(例如 `xim:xlings-guest-kernel`),不依赖 Lima、Colima 或 brew。 +- **需要验证**:Virtualization.framework 要求带 `com.apple.security.virtualization` entitlement 的签名。 + 待确认 ad-hoc 签名在本机是否够用;不够的话,改为由一个带签名的小 helper(`xlings-vm`,作为 payload 分发)来承担 VMM。 +- Apple Containerization(每个容器一台 VM)作为远期选项,取决于它的系统版本要求。 + +### 5.5 `native`(Luban OS) + +machine 视图直接跑在 Luban 自己的内核上。早期就是 Linux 内核,等同于 `local`。 +以后换成自有内核时,只替换 `modules/platform` 的内核特性适配和 `confine` 的实现(§4.2),上层不变。 + +### 5.6 选择规则(E6) + +1. Linux 宿主:所有 SubOS 都用 `local`,**行为和现在完全一样**。 +2. macOS / Windows:默认的 SubOS 是本机 ABI 的 overlay(`local`),**永远不会因此启动 VM**。 +3. 以下任一情况第一次出现时,为这个 home 创建承载(`wsl2` / `vz`): + - `subos new … --rootfs`; + - SubOS 的 ABI 是 linux; + - 用户显式指定 `--carrier`; + - 在 macOS/Windows 上,SubOS 的 policy 要求 Must 级别的强隔离,而 `local` 无法满足。 + 之后按需拉起,空闲时停止。 +4. 承载不可用时,按 Must/Should 规则处理:Must 级别拒绝(exit 125)并给出启用路线,Should 级别降级并说明原因。 + agent 模式下不会停下来提问,结构化错误中带上启用承载所需的确切命令。 + +--- + +## 6. 执行层的抽象 + +### 6.1 Intent IR + +把 `SandboxSpec` 中"要什么"的部分拆出来,成为平台无关的中间表示。**文件系统授权按 openkal 预打开的语义定义。** + +```cpp +namespace xlings::subos::intent { +struct Grant { std::string name; HostPath source; bool writable; }; // 有名字、有顺序;grants[0] 是 cwd +struct Fs { std::vector grants; bool hide_host; std::optional root; }; +struct Net { policy::Net mode; std::string proxy; bool host_loopback; std::vector publish; }; +struct Proc { bool own_job; bool kill_on_close; std::optional limits; }; +struct Dev { std::set> grants; }; +struct Ident { policy::Identity mode; std::string user; std::string tz; }; +struct Obs { policy::Observe level; }; +struct Intent { Fs fs; Net net; Proc proc; Dev dev; Ident id; Env env; Obs obs; + std::map> needs; Command cmd; }; +std::expected lower(const policy::Policy&, const Request&); // 纯函数 +} +``` + +- 同一份 Intent 可以翻译成 bwrap 的 bind、承载里的 drvfs/virtiofs 挂载,以及以后 `kal_process_spawn` 的授权。 +- 现有 `SandboxSpec` 变成 linux-ns 这个实现自己的计划(Plan)。 +- 环境变量的平台翻译(Windows 用 `USERPROFILE/APPDATA`,POSIX 用 `HOME/XDG_*`,`spec.cpp:593-613`)移到 Ident/Env 的翻译函数里。 + +### 6.2 Confine SPI、注册表与选择器 + +```cpp +// modules/confine +struct Implementation { + std::string_view name; // linux-ns | linux-landlock | linux-proot | home-redirect | … + std::map (*probe)(const HomeView&, const Ports&); // 按维度声明:Kernel/Advisory/None + 证据 + std::expected (*plan)(const intent::Intent&, const ProbeResult&); // 纯函数,golden 测试 + std::expected (*launch)(Plan&, LaunchCtx&); // 唯一有副作用的一步 +}; +``` + +- 外部 helper 类的实现(bwrap、proot)在 `launch` 内部生成 argv,**golden argv 测试保留**。 + API 类的实现(进程内 namespace、以后的 `kal_spawn`)不经过 argv。 +- **选择器**:按 Intent 中的 Must 维度挑第一个能满足的实现,Should 维度记入 `degraded`。`--sandbox ` 指定实现。 +- **`gates::probe` 改为汇总各实现的 `probe()`**,`subos status` 的矩阵和文档中的矩阵都由此生成,不再手写。 +- `src/core/subos/sandbox.cpp` 中的后端分派删除,core 只负责把 Request 交给选择器。 + +### 6.3 Session:三个接口 + +| 接口 | 职责 | Linux | macOS(本机 ABI) | Windows(本机 ABI) | +|---|---|---|---|---| +| `Transport` | 消息 + 句柄 | SEQPACKET + SCM_RIGHTS | STREAM + 长度前缀 + SCM_RIGHTS | 命名管道 / AF_UNIX + `DuplicateHandle` | +| `ProcessScope` | job、整树终止、等待、资源限制 | pid ns(以后 cgroup) | 进程组 + kqueue | Job Object | +| `ExecTracer` | exec 审计 | seccomp user-notify | 不提供(Advisory) | Job Object 新进程通知 | + +- `Launch` 中的 `int` 改为 `platform::Handle`(跨平台、只能移动的句柄)。`refresh_root` 这类 Linux 专有能力, + 作为 linux-ns 实现的扩展,不放在通用的 Launch 中。 +- **macOS/Windows 本机 ABI 的 SubOS 也走 supervisor**,从而获得 join、`--timeout`、统一退出码、审计和 broker。 + 隔离等级仍然如实报告为 Advisory。 +- 优先级低于承载(§13):完整功能由承载提供;这一步是让本机 ABI 的 SubOS 在用法上与其他形态一致。 + +### 6.4 ToolResolver 与 Elevation + +依赖分三级,越往上越优先: + +| 级别 | 是什么 | 例子 | 规则 | +|---|---|---|---| +| T0 进程内 | 静态链接的库或 OS API | libarchive、namespace/Landlock/seccomp、Job Object、VZ | 首选 | +| T1 自带 payload | xlings 从索引安装、按 sha256 固定的工具 | bwrap、pasta、e2fsprogs、客户机内核、luban-tiny rootfs | T0 做不到时使用 | +| T2 宿主工具 | 系统自带 | `wsl.exe`、sudo / UAC | 必须声明、探测、报告,并给出替代路线;**永远不调用 brew/apt/winget 去安装** | + +```cpp +struct ToolNeed { std::string_view name, package, purpose; ToolTier max_tier; }; +std::expected resolve(const ToolNeed&, const HomeView&, const Ports&); +``` + +- `caps::locate_bwrap/locate_proot/locate_pasta` 和 `root_cmd` 的 `host_tool_` 统一合并到 `resolve`。 +- 立即调整:`tar` 改用 libarchive(T0);`mkfs.ext4 -d` 改用 `xim:e2fsprogs`(T1)。 +- **`Elevation`**:Linux 用 sudo/polkit,macOS 用 Authorization Services,Windows 用 UAC。每次提权都写入审计。现有的 `as_root_` 并入这里。 +- **lint**:在 `modules/` 和 `luban/` 下,用字面工具名启动进程必须经过 `resolve`(`tools/lint_tool_resolve.sh`)。 + +--- + +## 7. Store 与 root 视图 + +### 7.1 `modules/store` 与 GC roots 契约(修复 H1) + +- payload store、引用图、完整性校验从 `xim` / `xvm` / doctor 中抽出,成为 `modules/store`。 +- **GC roots**:所有"固定 payload"的持有者都通过登记 root 来表达: + - workspace 的 `active` / `installed`; + - **每个保留中的 generation**(它的 `links.tsv` 里引用的 payload); + - 正在运行的投影(`running_projection`); + - 承载里的 home(由承载自己的 store 管理,宿主不跨承载收集); + - 已知项目。 +- `is_version_referenced_anywhere_` 改为查询 store 的引用图。读不出来的 root 一律视为"还在引用"(沿用 AGENTS.md 中"读不到不等于空"的规则)。 +- **保留策略**:每个 rootfs SubOS 保留最近 N 代(默认 5,可配置),另外固定当前代、启动项引用的代和正在运行的代。 + `prune` 接入 install / use / remove 之后的路径(修复 M1);`current()` 读不出来时,`prune` 拒绝执行。 + +### 7.2 root 视图:版本化映射表 + +```cpp +// modules/subos/views/root +struct RootMap { std::vector entries; }; // 逻辑路径 → payload 内的目标 +struct RootStore { // 由 StoreLinker 实现 + std::expected commit(const RootMap&, std::string_view reason); // 持久化 + 原子发布 + std::expected switch_to(Gen); + std::vector list(); std::optional current(); + std::expected validate(Gen); // O(1):比对提交时记录的摘要 +}; +``` + +- **Linux / POSIX 实现**:符号链接树 + `rename`,即现有的 `rootfs.cpp`。 + - **持久化**(修复 H2):每一代的文件、目录,以及指针的父目录,在 rename 之前和之后都要 fsync。直接复用 `platform.cppm:295-316` 已有的 sync 原语。 + - **校验**(修复 M2):`commit` 时记录树的摘要(链接数加上 `links.tsv` 的哈希),`switch_to` 时只比对摘要,并检查链接的目标是否存在(这也能挡住 H1 的悬空情况)。完整的树遍历只保留给 `doctor`。 +- **Windows / 自有内核的实现**:不使用符号链接。Windows 上 root 视图只存在于承载里,宿主不需要实现。自有内核用命名空间表实现,语义相同。 + +--- + +## 8. 代码布局与依赖规则 + +``` +modules/ xlings 与 Luban 共用,模块名 xlings.* +├── libs/ runtime/ ui/ testkit/ +├── platform/ OS 边界;逐步基于 openkal(§4.3);src/ 下按 os/ process/ net/ isolation/ fs/ target/ 分目录 +├── store/ payload store、GC roots、完整性/签名 ← 新增 +├── subos/ 内容与视图 +│ ├── model/ model、manifest、roles、ports、home_view、userdata +│ ├── policy/ policy、policy_store +│ ├── intent/ Intent IR、lower() +│ ├── views/ overlay、sandbox、root(RootMap / RootStore、library_cache) +│ └── session/ session、broker、Transport / ProcessScope / ExecTracer +├── confine/ linux-ns、linux-landlock、linux-proot、home-redirect;选择器 ← 从 subos 拆出 +└── carrier/ local、wsl2、vz;native 的接口 ← 新增 + +luban/ 只属于"一台机器",模块名 luban.* +├── boot/ stage0、init、boot.json(现 subos/boot、subos/stage0) +├── machine/ machine 视图:机器的 /etc、sysusers、用户、主机名、服务(现 rootfs.cpp 中的 fill_machine_etc / apply_sysusers 等) +├── image/ disk / ISO、安装器、export --disk +└── release/ 官方分级(tiny/core/desktop)的基础 manifest、发布通道、签名策略、machine 视图下的 self update + +apps/ xdev、gui,以及(C52)luban-init +src/ xlings 前端:索引、recipe、解析、获取、CLI / NDJSON / TUI +``` + +**依赖规则**(`tools/lint_layer_deps.sh`,加入 `xlings-ci-linux` 的 lint 套件): + +1. `luban/ → modules/`;**`modules/` 不能 import `luban.*`**。 +2. `modules/` 和 `luban/` 都不能 import `xlings.core.*`,沿用现有 Ports 的做法。 +3. `src/` 只在与机器相关的命令中(`subos boot`、`export --disk`、machine 视图下的 `self update`)通过一个窄接口使用 `luban.*`。 + 非 Linux 平台的构建不链接 `luban/`。 +4. `modules/store` 只依赖 libs、runtime、platform。 + +**划分标准**:一个东西是否只在 SubOS 成为"一台机器"时才存在。root 视图和 generation 在 Linux 宿主的 `--rootfs` 和各承载里都要用, +所以留在 `modules/subos/views/root`;stage0、启动项、机器的 `/etc`、镜像、发布属于 `luban/`。 + +**`luban-init` 独立二进制**(C52):stage0 拆成一个小的静态二进制。AGENTS.md 规定"stage0 执行路径上不能使用 Config",拆分后由构建本身保证。 +代价:要调整现有"stage0 字节和 inode 不变"的测试,以及 machine 视图下的自升级流程。 + +--- + +## 9. 契约 + +| 契约 | 内容 | 现在的雏形 | 版本号 | +|---|---|---|---| +| Env manifest | 一个 SubOS = workspace + policy + 授权(按 openkal 预打开语义)+ ABI + 承载 | `policy.json` + `instance.json` | `schema` 字段;读取时遇到未知字段拒绝 | +| Root map | 一代 = 映射表 + 摘要 + 元数据 | `links.tsv` + `generation.json` | 同上 | +| Store GC roots | 谁固定了哪些 payload | 无 | 同上 | +| Session 协议 | supervisor ↔ 客户端 | SEQPACKET 上的 JSON | 消息里带 `v` | +| Carrier 协议 | 宿主 xlings ↔ 承载里的 xlings | NDJSON interface | 沿用 interface 的版本协商 | + +宿主、承载、发行版三方只通过这五份契约交互。每份都有兼容测试:上一个 release 写出的文件,新版本必须能读。 + +--- + +## 10. 使用面 + +**不新增顶层名词,只在现有命令上加选项。** + +| 命令 | 变化 | +|---|---| +| `subos new [--carrier auto\|local\|wsl2\|vz] [--abi native\|linux]` | 默认 `auto`,按 §5.6 选择 | +| `subos new --rootfs` | 在 macOS/Windows 上自动使用承载,不再报 unavailable(修订 Part 2 §13) | +| `subos list` / `subos status` | 每一行显示视图、承载、ABI;矩阵按承载显示 Enforced 等级 | +| `subos use` / `exec` / `start` / `stop` / `rollback` / `export` / `remove` | 用法不变,由承载路由 | +| `self doctor --carrier` | 检查承载:是否可用、版本、interop 是否关闭、vhdx 大小、承载里的 xlings 版本 | +| `subos carrier stop\|status` | 运维用,不是日常命令 | + +agent 模式:承载需要提权或首次启用时,不会提问,返回结构化错误,里面带上确切的命令。 + +--- + +## 11. 安全与用户数据 + +- **承载的磁盘(vhdx / VM 磁盘)是用户数据**,适用 AGENTS.md 的"SubOS user data"规则:只有确认过的删除命令才能注销或删除它,并写入 `destructive.ndjson`。 +- **wsl2 承载的 interop 和自动挂载必须关闭**,用负对照测试证明(CARRIER-WSL-INTEROP-OFF)。 +- **授权的生命周期与 session 相同**:session 结束时卸载 drvfs / virtiofs 映射。 +- 每次提权都经过 `Elevation`,写入审计:谁、为了什么、执行了什么命令。 +- 承载里的 xlings 与宿主 xlings 的版本差异由 `self doctor --carrier` 报告。宿主执行 `self update` 时,在承载里也安装同一个 release + (承载是一台 machine 视图的 Luban,走 Part 2 §5 R 模式下的 `self update`,所以可以回滚)。 + +--- + +## 12. 需求与测试 + +| 需求 ID | 内容 | 车道 | +|---|---|---| +| LAYOUT-DEPS | §8 的依赖规则由 lint 强制 | lint | +| TOOL-RESOLVE | 用字面工具名启动进程必须经过 `resolve`;`subos status` 列出工具及其来源 | lint + linux | +| ROOT-GC-ROOT | 回滚到旧的一代后,链接的目标都存在(H1 的负对照:先 remove 再 rollback) | linux | +| ROOT-GEN-DURABLE | commit 过程中被 kill 或模拟掉电后,指针指向一个完整的代(用 fault injection 截断写入) | linux | +| ROOT-GEN-RETAIN | 超过保留数量的代会被回收;读不懂指针时 prune 拒绝执行 | linux | +| ROOT-SWITCH-SCALE | checked switch 在 300 个和 3000 个 payload 时都不超过 10 ms | perf | +| INTENT-EQ | Linux 上引入 Intent 和 Confine SPI 前后,golden argv 逐字节相同 | linux | +| GATE-CONFORM-* | 每个维度一套 conformance 测试;实现要么通过,要么在 `probe` 中声明 Advisory/None;矩阵由测试结果生成 | 全平台 | +| VIEW-CARRIER-EQ | 同一份内容声明在 local 与 wsl2(以及后续的 vz)上的 root 视图完全一致 | linux + windows | +| CARRIER-WSL-LIFECYCLE | 创建、拉起、空闲停止、`subos remove`,以及经过确认的注销 | windows | +| CARRIER-WSL-INTEROP-OFF | 承载里执行 `cmd.exe` 失败;看不到 `/mnt/c` | windows | +| CARRIER-WSL-GRANT | 只有被授权的目录可见;session 结束后映射被卸载 | windows | +| CARRIER-UNAVAILABLE | 没有 WSL 或只有 WSL1 时,Must 级别拒绝并给出路线,本机 SubOS 不受影响 | windows | +| SESSION-NATIVE-SUPERVISED | macOS/Windows 本机 ABI 的 SubOS 支持 join、timeout,退出码与 Linux 一致 | macos + windows | + +Windows runner 只有 WSL1 的问题(Part 2 §17.1 第 5 条):CARRIER-WSL-* 在找到 WSL2 runner 之前,先在本地或自托管机器上执行, +CI 中只作为尽力项报告。**不会因为测不了就把它写成已验证。** + +--- + +## 13. 交付(C43 起,#641 合入之后) + +| C | 内容 | 完成标准 | 前置 | +|---|---|---|---| +| C43 | 布局:新增 `luban/` 顶层;抽出 `modules/store`;拆出 `modules/confine` / `carrier` 的空骨架;`subos` 与 `platform` 内部按目录分层;依赖方向 lint | **只移动文件,行为零变化**,全平台 CI 通过;更新 AGENTS.md 和写死路径的测试(`subos_runtime_*_test.sh`、`lint_platform_headers.sh` 等) | #641 | +| C44 | Store GC roots 契约;generation 登记为 root;保留策略和 prune 接入 | ROOT-GC-ROOT、ROOT-GEN-RETAIN | C43 | +| C45 | RootMap / RootStore 抽象;fsync;O(1) 校验 | ROOT-GEN-DURABLE、ROOT-SWITCH-SCALE | C44 | +| C46 | ToolResolver + Elevation;tar 改用 libarchive;mkfs 改用 e2fsprogs payload | TOOL-RESOLVE | C43 | +| C47 | Intent IR + Confine SPI + 选择器;gates 由 probe 生成;后端分派移出 core | INTENT-EQ,**Linux 零变化** | C43 | +| C48 | Carrier SPI + `local`;`__carrier-agent`(复用 NDJSON interface) | Linux 上通过 local 承载跑完现有 e2e | C47 | +| C49 | `wsl2` 承载 | CARRIER-WSL-*、VIEW-CARRIER-EQ(windows) | C48、C45 | +| C50 | Session 三接口;macOS/Windows 本机 ABI 的 SubOS 走 supervisor | SESSION-NATIVE-SUPERVISED | C47 | +| C51 | `vz` 承载 | VIEW-CARRIER-EQ(macos) | C48、签名验证 | +| C52 | `luban/` 归位:boot、stage0(`luban-init` 独立二进制)、machine、image、release | 现有的 rootfs_instance / rootfs_image / boot 场景全部通过 | C43 | +| C53 | `modules/platform` 分项接入 openkal | 每一项都有 openkal conformance 测试加上 xlings 自己的回归;§4.3 的两点已确认 | §4.3 | + +C44 / C45 修复的是已合入代码中的问题(H1 / H2 / M1 / M2),**优先级最高**,可以紧跟在 #641 之后单独发版。 + +--- + +## 14. 风险 + +| 风险 | 应对 | +|---|---| +| C43 的大规模文件移动干扰进行中的分支 | 只移动文件、一个提交完成,并在 #641 合入后立即做 | +| WSL 的行为随 Windows 版本变化(Store 版与内置版、wsl.conf 的选项) | `probe` 记录 WSL 版本;CARRIER-WSL-* 按版本跑;不满足最低版本时报告 unsupported | +| drvfs 性能差 | 授权只用于项目目录;store 和 generation 一律放在承载的 ext4 里 | +| VZ 的签名要求 | C51 前先做一次签名验证;不行就改用签名的 helper | +| openkal 与 xlings 现有 C 运行时的冲突(README 规则 3) | C53 之前不承诺接入;先确认 §4.3 的两点 | +| Confine SPI 抽象过度 | C47 的完成标准是 Linux golden argv 逐字节不变;没有第二个实现需要的接口一律不加 | +| 承载里的 xlings 版本漂移 | `self update` 同步更新承载;`doctor --carrier` 报告差异 | + +--- + +## 15. 本轮需要 review 的问题 + +1. **§3 的模型**:SubOS 作为唯一的名词,加上视图(overlay / sandbox / root / machine)和承载(local / wsl2 / vz / native)两个维度,以及 ABI 规则。 + 这会把 Part 2 的"呈现方式"改名为"视图",并新增 `machine` 这个取值,是否认可? +2. **§5.3 每个 home 一个承载发行版**,并且承载本身就是 machine 视图的 luban-tiny,home 位于 `/xlings`,是否认可? +3. **§5.6 的懒创建规则**,特别是第 3 条中"在 macOS/Windows 上,Must 级强隔离的 policy 自动使用承载",是否合适? +4. **§8 的划分**:root 视图和 generation 留在 `modules/subos`,boot、stage0、machine、image、release 放到 `luban/`;`confine` 和 `carrier` 成为独立的包。 +5. **C52 把 stage0 拆成独立的 `luban-init` 二进制**,是否接受由此带来的测试和自升级流程调整? +6. **C44 / C45(修复 H1 / H2 / M1 / M2)是否先于其他所有工作,在 #641 之后单独发版?** +7. **§7.1 的保留策略默认值**(最近 5 代,加上当前代、启动项和正在运行的代)。 + +--- + +## 16. 实施计划(维护者确认 §15 全部,2026-10-09;6 改为:全部在 #641 内,以 checkpoint commit 交付) + +### 16.1 多角度拆分 + +| 角度 | 本轮要守住的 | 落在哪些 C | +|---|---|---| +| 架构 | 依赖方向 `luban → modules`,`modules ↛ xlings.core`;后端、承载、工具都是注册的实现,core 不分派 | C43 C46 C47 C48 C52 | +| 稳定性 | GC roots 覆盖 generation;generation 持久化;读不到不是空;prune 拒绝未知指针 | C44 C45 | +| 优雅简洁 | 不新增顶层名词;不新造协议(承载复用 NDJSON interface);没有第二个实现需要的接口不加 | C47 C48 | +| 用户体验 | 用法不变;`subos status/list` 显示视图、承载、ABI;承载不可用时给出确切路线;agent 模式不提问 | C48 C49 C50 | +| 兼容性 | 旧 home、旧 generation(无摘要)可读;`links.tsv` 格式不变;`kind=rootfs` / `role=host` 不变 | C44 C45 | +| 跨平台 | Linux 零行为变化(golden argv 逐字节);macOS/Windows 本机 SubOS 进 supervisor;WSL2 承载;VZ 承载以 probe + 路线交付 | C47 C49 C50 C51 | +| 一致性 | 矩阵由 probe 生成;需求 ID 全部进 `tests/requirements.toml` 并有 `covers` | C47 全程 | +| 无感升级 | 新字段只追加;旧 generation 第一次切换时补写摘要;旧 client 读新文件不受影响 | C44 C45 C48 | + +### 16.2 依赖 + +``` +C43 布局 ──┬─► C44 GC roots/保留 ─► C45 持久化/O(1) ─┐ + ├─► C46 ToolResolver/Elevation ├─► C49 wsl2 ─┐ + ├─► C47 Intent/Confine ─► C48 Carrier/local┘ ├─► 自我 review ─► 合入/发布 + │ └─► C50 Session 三接口 ─► C51 vz ───┘ + └─► C52 luban/ 归位(luban-init) +C53 openkal:§4.3 两点确认后分项接入;确认不了则记录为结论,不阻塞 +``` + +### 16.3 每个 checkpoint 的完成标准 + +- 一个 commit(或一组以 `(C4x)` 结尾的 commit),本地 `mcpp build` + 受影响的 `mcpp test` 通过; +- 新需求 ID 写进 `tests/requirements.toml`,测试带 `covers`; +- 涉及跨平台的改动在 macOS / Windows CI 上实际跑过,不以"Linux 通过"代替; +- 文档(AGENTS.md、Part 3 §19 实施记录)随 commit 更新。 + +--- + +## 19. 实施记录(2026-10-09,PR #641) + +| C | 提交 | 结果 | 验证 | +|---|---|---|---| +| C43 | `f91c481` | 目录分层(platform `os/ process/ net/ isolation/ fs/ triple/`;subos `model/ policy/ sandbox/ views/root/ session/`;core `subos/cmd.cpp root/ domain/`);接口单元不含系统头;`src/` 中全部平台 `#if` 改为 `if constexpr`(剩余 14 处为编译器差异或 runtime 层,带 `platform-if-ok`);新增 `lint_platform_branches`、`lint_layer_deps`;CI 运行全部 `tools/lint_*.sh`(`lint_platform_headers` 此前没有 CI 步骤) | 三平台 CI 编译通过 | +| C44 | `02816a8` | `modules/store`;generation 成为 GC root(H1);保留 5 代 + 释放(M1);读不懂的指针不 prune | ROOT-GC-ROOT、ROOT-GEN-RETAIN(CLI 级:remove 后回滚找得到目标;prune 释放) | +| C45 | `0aacae7` `94041bd` `b7040ad` | 持久化(H2,fsync 顺序可追踪);切换校验改为目录 change stamp + payload 存在性(M2):300 payload 0.2 ms、3000 payload 1.9 ms(优化)/ 3.0 ms(dev);发布新代只校验树(静态车道发现:导出镜像的链接指向逻辑路径) | ROOT-GEN-DURABLE、ROOT-SWITCH-SCALE;静态车道 RootExport | +| C46 | `f36ec0e` | 工具表、提权入口;export/pack 的 tar 改为进程内(libarchive,root-owned,无需 user namespace);`priv_prefix` 删除;`std::system` 清零 | TOOL-RESOLVE;distro 车道 docker import / qemu boot 通过 | +| C47 | `58d0e0c` `86527fa` | Intent IR + `modules/confine` 注册表;core 不再分派后端;矩阵由实现声明汇总 | INTENT-EQ:872 个录制用例逐字节一致;GATE-CONFORM | +| C48 | `898b67d` | `modules/carrier`、`choose`、`subos new --carrier --abi`、按名转发、`list` 统一(interface 的 list_subos 原先缺 kind) | CARRIER-LOCAL、CARRIER-UNAVAILABLE | +| C49 | `a8f6262` | wsl2 承载:每个 home 一个发行版,镜像 = 本版本 Linux 构建 + 声明的机器文件(关闭 automount/interop),`__carrier-env` / `__carrier-grant` | 假 wsl.exe 跑通整个生命周期(发现 guest 无 env(1));Windows 车道 E2E-08(有 WSL2 则走生命周期与 interop-off,无则拒绝并给路线);VIEW-CARRIER-EQ deferred(需要真实 guest 内核) | +| C50 | `482b54c` | macOS:消息 socket 为分帧的 SOCK_STREAM,会话在 macOS 上与 Linux 同一条路径;Windows:Job Object(整树、超时 124) | SESSION-NATIVE-SUPERVISED 由 macOS / Windows 工作流把关;Windows 的 join 需要 CreateProcess supervisor,未声称 | +| C51 | `43ab70f` | vz 承载,经 `xlings-vm` 助手的契约 | CARRIER-VZ-LIFECYCLE(假助手);CARRIER-VZ-HELPER deferred(签名的 Swift 包,托管 macOS 无法嵌套虚拟化) | +| C52 | `f1dd2e2` | `luban/`(boot、stage0、machine);`apps/luban-init` 独立包:0 个前端符号(作为根包目标时 6812 个);发现 `inline constexpr` 模块变量仅由前端发射的潜在缺陷 | LUBAN-INIT(release tarball 上检查) | +| C53 | 本提交 | 见下 | OPENKAL-COEXIST(CI 步骤) | + +### C53:§4.3 两个问题的结论 + +1. **openkal 能否在 xlings 现有 C 运行时下链接:能(Linux 实测)。** `openkal-linux` 0.16.1 直接基于系统调用,与 libstdc++ 在 glibc + 动态构建和 xlings 发布用的 x86_64-linux-musl 静态目标下都能链接、运行(`tools/openkal-coexistence`,CI 每个 PR 构建并运行两种目标)。 + 一个互操作事实:`kal::write` 绕过 C 库的 stdio 缓冲,混用前必须 flush。`openkal-windows` 声明"不使用任何 C 运行时符号", + `openkal-macos` 直接基于内核调用;两者在 xlings 接入时由各自平台 CI 验证。 +2. **跨进程传递句柄:openkal 0.15 不定义**(SPEC §11 第 9 条:"A general mechanism for passing a handle to a context in another + space is not defined by this version")。因此 `Transport`(SCM_RIGHTS / `DuplicateHandle` / 分帧流)留在 `modules/platform`。 + +**本 PR 不把 openkal 接入 xlings 的产品代码**:可移植的部分(spawn、fs、stream)目前没有一项能因换成 `kal_*` 而让 xlings 的行为变好, +为接入而接入只会把一个新依赖放进三平台的发布路径。第一项有意义的接入是 confine 中"按授权启动 openkal 程序"的实现 +(Intent 的 grants → `kal_process_spawn` 的预打开)——它与 Luban 自有内核一起做,那时授权由内核强制执行(§4.2)。 diff --git a/.agents/plans/2026-10-05-subos-architecture-implementation-plan.md b/.agents/plans/2026-10-05-subos-architecture-implementation-plan.md new file mode 100644 index 000000000..dd3cd9d5a --- /dev/null +++ b/.agents/plans/2026-10-05-subos-architecture-implementation-plan.md @@ -0,0 +1,141 @@ +# SubOS architecture — implementation plan (single PR, commit checkpoints) + +- Design: `.agents/docs/2026-10-05-subos-architecture-design.md` (round 7, all + review items confirmed, including §27 items 1–5 on test/CI) +- Issue: #640 +- Base: `v2026.10.4.1` (`4ca6926`) +- Branch: `feat/subos-architecture-640`, one PR, one commit per checkpoint. + Every commit builds, keeps the existing suites green, adds its own tests and + can be reverted on its own. + +## 1. How the work is cut + +The design lists C0 → T1–T6 → C1–C27. This plan keeps that order and states, +for each checkpoint, which perspective forces it to come where it does. The +perspectives the review asked for are the columns of the table in §3; the +dependency graph is §2. + +Principles applied while cutting: + +| Perspective | Rule used for ordering | +|---|---| +| Architecture | New code lands in its final module (`modules/subos`, `modules/guard`, `modules/observe`, `modules/testkit`, `apps/xdev`) from the first commit that needs it. Old code moves in "no behaviour change" commits *before* anything new depends on it, so a move and a change never share a commit. | +| Stability | Security defaults change only after the spec compiler exists (C8): every behavioural change is a change of compiled data that has a golden test, not an edit inside an argv builder. | +| Elegance | One answerer per question: `HomeContext` (home), `policy::decide` (may I), `compile` (what do I get), each interface's `probe()` (what can this host do), `xdev ci plan` (what runs). Nothing is added that a second place re-derives. | +| User experience | The user surface (`--sandbox=`, `subos exec`, `subos status`, exit codes, hints) is defined once and is identical on every platform; a platform that cannot do something says so in the shared hint format. | +| Compatibility | Undeclared instances keep today's behaviour except for the S0 security fixes; `--sandbox` alone means the `dev` preset; `--gpu`, `--cmd`, `--keep`, `--ttl` stay as aliases; `.xlings.json` writers keep unknown keys; layout migrations are one-way and an unknown higher layout is read-only. | +| Cross-platform | Interfaces live in `modules/subos/platform`; Linux implements them, macOS / Windows return `unsupported` with a reason, and the fake provider runs everywhere so flow tests run on all three CI legs. | +| Consistency | Exit codes, event schema, hint format and the agent contract are tables in the design doc *and* fixed by tests (T4). | +| Seamless upgrade | Nothing on disk needs a migration step to keep working. New files (`config/subos//policy.json`, `logs/subos//`, `state/isolation-caps.json`, `run/subos//`) are created on first use; their absence means "defaults". | + +## 2. Dependency graph + +``` +C0 docs + └─ T1 testkit ── T2 xdev test/report ── T3 CI ── T5 coverage map + lane caps + └─ T4 contract scan (needs C13 for the agent half; exit-code half first) +C1 guard + observe ─┐ +C2 modules/subos skeleton (+gpu, graphics, manifest) ─┐ +C3 Ports + HomeView (+userdata, keeper) ──────────────┤ +C4 split subos.cpp (model → module, adapter stays) ───┘ +C5 HomeContext + .xlings-home mode/layout ── C26 deployment mode S / M read path +C6 writer rules (keep unknown keys, policy under config/subos/) +C8 platform interfaces + SandboxSpec + providers (bwrap/proot/home-redirect/fake) + ├─ C10 mount topology (F1, F6) ← C7 write-surface inventory is its L4 baseline + ├─ C11 env / pid / ipc / uts / die-with-parent / new-session / TIOCSTI / probe text + ├─ C17 caps probe + cache + must/should + hints + --no-degrade + │ ├─ C18 Landlock provider + │ ├─ C20 identity neutralisation, net=none, --disable-userns + │ ├─ C21 self doctor --isolation [--fix] (root-owned bwrap + narrow AppArmor profile) + │ └─ C23 net=nat via pasta, --publish (proxy: see §4) + └─ C9 session model (supervisor, session-init, exec.sock + SCM_RIGHTS, lifecycle/ops events) + ├─ C12 subos exec / start / stop / --temp / cp ; interface subos_exec + ├─ C22 observability: exec (seccomp notify), fs, redaction, report, subos_events, XLINGS_TRACE + └─ C15 broker: auto/ask/deny, install --subos, requests/approve, perm events +C13 audience contract (XLINGS_AGENT_MODE, Asker) ── C15 +C14 policy model (presets, overrides, decide, --sandbox=, subos config/status) + ├─ C15, C16 rules + policy packs, C19 --mount / --allow +C25 subos doctor + environment matrix + perf budgets (last: it reads everything above) +``` + +Hard edges (a later item cannot be written without the earlier one): +C8→C10/C11/C17; C9→C12/C15/C22; C13→C15; C14→C15/C16/C19; C1→C3 (userdata +needs guard/observe); C5→C26; T1→every new e2e test. + +## 3. Checkpoints with their verification + +| # | Content | Verification tier | +|---|---|---| +| C0 | Design doc; fix the isolation matrix in existing docs; AGENTS.md: core vs interaction surfaces, `xdev` | docs | +| T1 | `modules/testkit` (`XTEST`-style metadata, `Home::isolated`, process runner with env/timeout/pty, JSON + event assertions, failure capture) | testkit unit tests, 3 OS legs | +| T2 | `apps/xdev test / report` (mcpp NDJSON + legacy sh/ps1/py adapter → one report) | same output locally and in CI | +| T3 | CI: one release build per OS feeding the e2e job; asan off the PR critical path (push to main, nightly, `ci:asan` label); step summary from `xdev report` | measured PR critical path | +| T4 | Contract scan: exit-code table, agent never blocks on input (pty, no input) across the CLI spec | L2 | +| T5 | Requirement-ID map (`tests/requirements.toml`), "uncovered ID fails", lane capability declaration (fixes F13) | `xdev report --coverage` | +| C1 | `modules/guard` (UserConfirmed + Asker port), `modules/observe` (event schema, journal incl. destructive log, redaction) | existing tests | +| C2 | `modules/subos` skeleton; `gpu`, `graphics`, `manifest` moved | existing tests | +| C3 | `Ports`, `HomeView`; `userdata`, `keeper` moved | existing tests | +| C4 | Pure model of `subos.cpp` (counts, candidates, edit distance) moved; adapter stays in `src/core/subos` | existing tests | +| C5 | `src/core/home/`: `HomeContext`, `.xlings-home` `mode` + `layout` | L1 + L5 | +| C6 | Writers keep unknown keys; policy file location | L1 | +| C8 | `FsGate … RootfsRuntime` interfaces + `probe()`; `SandboxSpec`; bwrap / proot / home-redirect / fake providers; spec golden tests | L1 golden | +| C10 | `$XLINGS_HOME` RO, only own subos RW, other instances / logs / state / run hidden (C7 inventory as the L4 baseline) | L4 (F1, F6) | +| C11 | env allow-list, `--unshare-pid/ipc/uts`, `--die-with-parent`, `--new-session` (exec) + TIOCSTI seccomp (interactive), probe text without the sysctl advice | L4 (F3, F4, F7, F8, F12) | +| C9 | supervisor (fork + wait, no more `execvp`), session-init, `exec.sock` with fd passing, lifecycle / ops events, `subos ps` / `subos log` | L3 + L4 | +| C12 | `subos exec` / `start` / `stop` / `--temp` / `cp`; interface `subos_exec` | L2 exit codes + L3 | +| C13 | `XLINGS_AGENT_MODE` / `--agent`, `Asker`, agent errors / candidates / never-blocking | L2 scan | +| C14 | presets dev / private / locked, overrides, merge, `policy::decide`, `--sandbox=`, `subos config` / `status`, unknown security field → refuse | L1 + L3 | +| C15 | client + broker, auto / ask / deny, `install --subos`, `requests` / `approve`, `perm` events | L3 + L4 | +| C16 | ordered rules; policy packs (`type = "subos-policy"` payload carrying `policy.json`) | L1 + L3 | +| C17 | caps probe, cache in `state/isolation-caps.json`, must / should, hint format, `--no-degrade` | L3 (fake caps) + L6 | +| C18 | Landlock provider (ABI ≥ 1, optional net / scope at higher ABIs) | L4 | +| C19 | `--mount src:dst[:ro|rw]`, `--allow display|audio|camera|gpu|ssh-agent|dbus|host-loopback` | L3 + L4 | +| C20 | neutral identity, `net=none`, `--disable-userns` | L4 (F5, F7) | +| C21 | `self doctor --isolation [--fix]`; bwrap recipe change goes to xim-pkgindex separately | L6 (F10) | +| C22 | exec audit (seccomp user notification), fs change list, redaction, `subos report`, interface `subos_events`, `XLINGS_TRACE` | L3 + L4 + L8 | +| C23 | `net=nat` (pasta, `--publish`, `--allow host-loopback`) | L4 (F2, F5) | +| C24 | `fetch=layer` | L3 + L4 | +| C25 | `subos doctor`; environment matrix; perf budgets as assertions | L6 + L7 | +| C26 | deployment mode S; system layer (M) read path | L5 | +| C27 | rootfs — maintainer decision, see §4 | — | + +## 4. 原实施范围记录 + +2026-10-08:以下是初版实施时的范围记录。C27 已由 Part 2 实现;后续完整交付按 +[续行计划](2026-10-08-subos-delivery-plan.md)逐项处理缺项,不以本节作为验收豁免。 + +Recorded here so the PR description and the final report say the same thing: + +- **C27 rootfs** stays out of this PR (the design leaves it to the maintainer; + it needs subuid mapping and a rootfs package format, neither of which the + rest depends on). The `RootfsRuntime` interface exists and reports + `unsupported`. +- **`net=proxy`** is specified but not built here: it needs the in-sandbox + forwarder plus the supervisor bridge, and nothing else depends on it. + Selecting it reports the shared "not implemented on this platform yet" hint + and, under `--no-degrade`, refuses. `nat` and `none` ship. +- **`fetch=layer`** (C24) and **resolving packages from the system layer + (M)** are one mechanism -- a layered payload lookup in xvm's version DB and + the installer -- and both follow the PR (`PERM-FETCH-LAYER`, + `HOME-LAYER-RESOLVE`, deferred with that reason). Until then `layer` is + refused wherever it appears; before this change it was granted and + installed into the shared home. The system layer is found and reported. +- **Policy packages** are selected per instance (`subos config --sandbox + ns:name`); a pack named on a single call is not taken (a call tightens + presets and overrides only). +- **Landlock** ships as an explicit backend (`--sandbox landlock`), never an + automatic stand-in for bwrap: it fences writes and hides nothing. +- **T6** (`xdev lint` / `xdev release`) follows the PR, as the design allows. +- **macOS / Windows** keep home-redirect; every interface reports + `unsupported` with the reason, which is what `subos status` prints. +- Anything in this list that turns out cheap during implementation is pulled + in and this section is updated in the same commit. + +## 5. Release + +Version: the date the PR lands, `N >= 1`; increment against the releases already published that day. +Current candidate: `2026.10.8.2` (2026.10.8.1 is already published). After `release.yml` +publishes the assets: top up GitCode with the local `gtc` immediately (do not +wait for the release workflow's mirror step), bump `xim-pkgindex/pkgs/x/xlings.lua`, +then verify the ecosystem on a real home: quick install, `self update`, +`subos new / use --sandbox / exec`, `install --subos`, mcpp build of xlings. diff --git a/.agents/plans/2026-10-08-subos-delivery-plan.md b/.agents/plans/2026-10-08-subos-delivery-plan.md new file mode 100644 index 000000000..604ffe4f5 --- /dev/null +++ b/.agents/plans/2026-10-08-subos-delivery-plan.md @@ -0,0 +1,64 @@ +# PR #641 完整交付续行计划 + +日期:2026-10-08。关联:#640、#641。基线:main `c55d89a`,本地 rebase 后 `a8c2aba`。 + +设计输入为 2026-10-05 总体设计及 2026-10-06 Part 2。此前实施记录中的 +“推迟”只描述当时的实现,不能作为本次完整交付的验收结论。已完成的实现保留, +缺项逐项记录;没有证据的能力不报告为完成。所有客户端改动继续进入 #641。用户要求保留 PR 提交审查记录: +后续仅追加 commit、普通 push,不 force push,不改写已推送历史。 +协作最多同时运行两个子 agent;构建由主 agent 串行执行,避免共享 target 冲突。 + +## 依赖关系 + +```mermaid +flowchart TD + A[安全边界:输出所有权、严格状态、审计失败] --> B[事务一致性与升级兼容] + A --> C[测试能力与覆盖报告真实性] + B --> D[系统层与 payload 闭包] + B --> E[网络 proxy 与出站事件] + B --> F[broker hook 执行边界] + C --> G[xdev 选择、分片、资源锁与 CI 计划] + D --> H[glibc revision、根 cache、Luban 生态] + E --> I[跨平台能力矩阵与行为契约] + F --> I + G --> J[完整 CI 与性能证据] + H --> J + I --> J + J --> K[综合自审、文档、单 PR 合入] + K --> L[release 资源出现即本地 gtc 补 GitCode] + L --> M[GET 校验、索引更新、CN 真实升级验证] +``` + +## 任务与验收 + +| 阶段 | 工作及依赖 | 验收证据 | +|---|---|---| +| A1 稳定性 / 用户数据 | pack/export 不清理既有输出;专属 staging;generation 输入路径与清理所有权;修复 macOS 指针读取测试 | 已有文件字节不变、失败无残留、lint、Linux/macOS/Windows 测试 | +| A2 兼容性 / 无感升级 | 缺失旧元数据允许默认;损坏/不可读文件拒绝;boot 写入保留未知字段;未知 layout 只读;stage-0 不以默认掩盖坏状态 | 旧布局、新增未知键、错误类型、坏 JSON、不可读输入的回归 | +| A3 审计 / 隐私 | journal 真实返回写入结果;locked 启动拒绝/运行终止;exec 通知先审计后放行;默认不记录参数值 | 实际不可写路径、秘密参数、两阶段通知、跨平台 stubs | +| B 一致性 | root projection 失败传播到 install/use/remove/self init;拒绝成功报告;确定失败后的可恢复状态;policy min_client 与 doctor | 命令退出码及磁盘状态、重试修复、N-1 客户端验证 | +| C 验证真实性 | 区分声明覆盖、执行通过、跳过、隔离证明;rootfs 不再声称未实现;WSL 必须真实运行才报告通过;测试不受 TERM/mirror 污染 | 单一报告显式列出缺证据项;声明能力缺失导致失败 | +| D 架构 / 跨仓库 | fetch=layer 与系统层版本解析;root store 仅绑定依赖闭包;prefix domain 构建契约;libxpkg hook sandbox 接口 | 两个用户不复制系统 payload;依赖闭包与越权探针;旧 recipe 兼容 | +| E 网络 | net=proxy 的 netns 单出口与 supervisor 桥接;DNS 代理;nat/proxy 每连接 net 事件 | 直连/DNS 旁路拒绝、代理故障拒绝、主机回环授权、真实隔离 CI | +| F 执行边界 | broker hook 在受限执行环境;保留 tty/日志契约与每 scope configure 语义 | 恶意 fixture 写其他 scope 失败、正常生态 recipe 成功 | +| G 工具 / 简洁 | xdev ci plan、area/tag/changed/shard/lane、资源锁、fixture HTTP 镜像;lint/release 包装;统一 CI 报告 | 同一选择逻辑服务本地与 CI;PR 核心测试不依赖公网;关键路径计时 | +| H Luban / 库 | glibc sysconfdir 与独立 ld.so.cache;recipe revision;资源 SHA 与两架构;编译器默认库路径 | 外来程序与根内新编译程序无额外 rpath 运行;tiny/core/desktop、boot/rollback | +| I 跨平台 / UX | 统一命令/退出码/事件;NDJSON 流;平台 unsupported 原因准确;升级与旧 policy 兼容 | Linux/macOS/Windows/aarch64、WSL、agent 无输入扫描、文档生成一致 | +| J CI / 自审 | 必需车道全部绿;审查 git diff、状态/权限/清理边界及生态兼容;补足性能预算 | 固定 head 的 CI 结果、需求与证据清单、自审报告 | +| K 发布 / 生态 | 按当天现有 release 选 N≥1;资源出现立即本地 gtc;GET/sha256;xim-pkgindex latest 与所有平台更新;设置 CN 实测 | 四平台资源、GitCode 校验、索引 CI、fresh install/self update/mcpp/SubOS | + +## 跨仓库协作约束 + +xlings、libxpkg、xim-pkgindex、资源仓库分别使用独立分支/工作树,保留原工作树的 +用户改动。先实现并验证库接口,再更新客户端依赖和索引 revision;资源发布前不 +把未出现的 URL/hash 写成可安装的 latest。客户端仍以 #641 交付,上游改动记录 +关联 PR/commit 与兼容测试,避免在客户端复制 Lua 解析或 URL 展开逻辑。 + +## 初次证据(历史基线) + +初次 rebase 无冲突。原 PR 的 Linux lint 与 macOS generation 测试失败;本地清理 +测试环境后 77 个测试程序通过,但真实沙箱等 36 个 XTEST 跳过。因此尚不能发布。 +详细现场见 `.agents/docs/2026-10-08-pr-641-progress-report.md`。 + +本计划推进实现与有限、针对性的验证:每个修复先复现再回归,稳定批次只做一次 +完整本地运行后提交 CI;新的失败或新的代码变化才触发追加检查。 diff --git a/.agents/skills/xlings-usage/SKILL.md b/.agents/skills/xlings-usage/SKILL.md index cdac6fe2a..00921e14d 100644 --- a/.agents/skills/xlings-usage/SKILL.md +++ b/.agents/skills/xlings-usage/SKILL.md @@ -68,13 +68,27 @@ the output**; `--strict` refuses such a switch instead. ## SubOS — Environment Isolation -### Three levels - -| Level | Command | Root? | Use case | -|-------|---------|:---:|---| -| Shell | `xlings subos use ` | No | Version isolation only | -| FS (sandbox) | `xlings subos use --sandbox` | No | Linux filesystem isolation; macOS/Windows home redirection only | -| Image | `xlings subos use --sandbox` (storage=image) | Yes | Block-device isolation | +### What isolates, and how much + +| Level | Command | Use case | +|-------|---------|---| +| Shell | `xlings subos use ` | Version isolation only | +| Sandbox | `xlings subos use --sandbox` / `subos exec --sandbox -- ` | Linux: bwrap namespaces; macOS/Windows: home redirection only | +| Policy | `xlings subos new --sandbox=dev\|private\|locked` (or later `subos config`) | Declared once on the instance; holds however it is entered, no flag needed | + +| Preset | Network | Identity | Fetching packages from inside | +|---|---|---|---| +| `dev` | host | host | auto | +| `private` | nat (pasta) | neutral (user / instance hostname / UTC) | ask (owner approves outside) | +| `locked` | none | neutral | deny | + +Backends: bwrap (default), `--sandbox landlock` (a kernel write fence, no +namespaces, the host's sockets reachable -- only when asked for, never for +untrusted code), `--sandbox proot` (a view, not a boundary). What a backend cannot give degrades under `dev` and refuses +(exit 125, with the reason and the fix) under `private` / `locked`. +`xlings subos status ` shows what is requested and what is in effect; +`xlings subos doctor` checks every instance; `xlings self doctor --isolation +--fix` repairs Ubuntu 24.04's user-namespace restriction (one sudo). ### Lifecycle @@ -84,19 +98,30 @@ xlings subos new dev-env xlings subos new dev-env --storage tmpfs # ephemeral data xlings subos new dev-env --from subos:py-ds@1.0.0 # fork from xpkg base +# Declare what it may do -- once, at creation; entries then need no flag +xlings subos new agent-env --sandbox=private # a preset; `use` / `exec` enter it that way +xlings subos config dev-env --sandbox=private # or declare / change it later +xlings subos config dev-env --allow gpu --mount ~/proj:/work +xlings subos config dev-env --sandbox xim:policy-ci@1 # a policy package (locked by sha256) + # Enter xlings subos use dev-env # interactive shell xlings subos use dev-env --sandbox # sandbox mode -xlings subos use dev-env --cmd "echo hello" # single command -xlings subos use dev-env --sandbox --cmd "..." # sandbox + command +xlings subos exec dev-env --sandbox -- make -j8 # one command, its own exit code +xlings subos exec dev-env --sandbox=locked -- ./untrusted # a call may only tighten + +# Sessions: one instance, many commands +xlings subos start dev-env --ttl 30m # later exec/use join it +xlings subos exec dev-env -- make test +xlings subos ps +xlings subos stop dev-env -# List / info +# Inspect xlings subos list xlings subos info dev-env - -# Keeper (high-frequency exec) -xlings subos use dev-env --sandbox --keep # keep mount namespace alive -xlings subos stop dev-env # release keeper +xlings subos status dev-env # requested vs in effect +xlings subos log dev-env --kind perm # audit (outside the sandbox) +xlings subos report dev-env # programs run, files changed # Remove — deletes the SubOS AND its home (user data). It asks first; # with nobody at a terminal it deletes nothing and exits 2 unless -y is given. @@ -123,12 +148,23 @@ xlings install # installs deps into project isolation ### Agent runs inside SubOS -Treat `--sandbox` as a security boundary only on Linux with bwrap/proot. -macOS and Windows redirect HOME/USERPROFILE and must not run untrusted code. +Treat `--sandbox` as a security boundary only on Linux with bwrap (proot is +a view, landlock fences writes only). macOS and Windows redirect +HOME/USERPROFILE and must not run untrusted code. + +Declare the audience: `XLINGS_AGENT_MODE=1` (or `--agent`). In agent mode no +command waits for input; an interactive `subos use` is refused (exit 2) and +names `subos exec`. + +Inside a sandbox the xlings home is read-only and xlings still works: reads +run locally, `install` / `remove` go to the owner's broker and are decided by +the instance's policy, owner-only commands return 13 with the command to run +outside, and `fetch=ask` requests wait (exit 75) for +`xlings subos requests / approve / deny` outside. ```bash # Create isolated env for agent -xlings subos new agent-ws --from subos:dev-env@latest +xlings subos new agent-ws --from subos:dev-env@latest --sandbox=private # Enter — agent runs INSIDE this world xlings subos use agent-ws --sandbox @@ -150,7 +186,9 @@ xlings interface ### One-shot command execution ```bash -xlings subos use agent-ws --sandbox --cmd "python analyze.py" +xlings subos exec agent-ws --sandbox -- python analyze.py +# exit codes: the command's own; 125 setup; 126/127 cannot run / not found; +# 124 timeout (--timeout); 128+n signal; 13 E_PERMISSION; 75 queued for approval ``` ## Package Index Ecosystem @@ -192,13 +230,14 @@ xlings subos new exp --from subos:py-ds@1.0.0 # fork (0s, shared storage) | Flag | Context | Effect | |------|---------|--------| -| `--cmd ""` | `subos use` | Non-interactive single command exec | -| `--sandbox` | `subos use` | Enable FS-level isolation (bwrap/proot) | +| `--cmd ""` | `subos use` | Non-interactive single command exec (prefer `subos exec -- argv`) | +| `--sandbox[=preset\|backend]` | `subos use/exec/start/config` | Isolation: dev / private / locked, or bwrap / landlock / proot | +| `--net`, `--fetch`, `--allow`, `--mount`, `--observe` | `subos config` (declares) / a call (tightens only) | Policy overrides | | `--storage ` | `subos new` | shared / tmpfs / image | | `--from ` | `subos new` | Fork from local subos or pkg-spec | -| `--keep` | `subos use` | Keep mount namespace alive (no timeout) | -| `--no-keep` | `subos use` | Force disable keeper | -| `--ttl ` | `subos use` | Custom keeper idle timeout | +| `--keep` | `subos use` | The session outlives the shell until `subos stop` (Linux) | +| `--no-keep` | `subos use` | End the session with the shell | +| `--ttl ` | `subos use/start` | Session idle timeout | | `-y` | `install` | Skip confirmation prompts | | `-g` | `install` | Install to global scope (not project) | | `--reconfig` | `install` | Run config() again for the whole closure; without it a package already configured in this scope at its recipe revision is left alone | diff --git a/.agents/tasks/subos_delivery_641/README.md b/.agents/tasks/subos_delivery_641/README.md new file mode 100644 index 000000000..43a42d0a0 --- /dev/null +++ b/.agents/tasks/subos_delivery_641/README.md @@ -0,0 +1,77 @@ +# SubOS #641 交付状态 + +更新:2026-10-08。依赖与验收见 [续行计划](../../plans/2026-10-08-subos-delivery-plan.md)。 + +最新验收:`e8fbdd01` 的 macOS、Windows、ARM64 与 Linux root 已通过;Linux 主车道 +的五个失败已据真实现场追加 backend context / ELF runtime alias 修复。本批 dev / static +及 109 程序编译通过,针对性 6 pass、7 namespace skip、0 fail;最终新 head CI、发布 +及真实 CN 升级仍未完成。下表及后文保留推进记录,当前证据见进度报告顶部。 + +| 任务 | 状态 | 证据 / 后续 | +|---|---|---| +| 获取 #641、rebase 最新 main | 完成 | 本地 `review/pr-641-rebased`,base `c55d89a`,无冲突 | +| 原方案审查 | 完成 | [进度报告](../../docs/2026-10-08-pr-641-progress-report.md),保存原 CI 与本地现场 | +| 输出与 generation 所有权 | 实现,本地回归通过 | 原候选已复现;独占 staging、拒绝既有输出、完整 generation 清单;删除 lint 通过 | +| 严格状态与 boot 未知键 | 实现,本地回归通过 | stage-0 明确恢复路径;status/config/cp/list 拒绝坏状态 | +| 审计故障与 argv 隐私 | 实现,待真实隔离 CI | checked journal、先记录后放行通知、locked 退出 125 | +| root refresh 失败传播 | 实现,本地回归通过 | use/install/remove/init 不再只警告;失败保留 payload,重试生成投影 | +| min_client | 实现,本地回归通过 | 新文件最低 2026.10.8.2;数字版本比较;doctor 检查实际 entry | +| atomic file writer / policy dangling symlink | 实现,本地回归通过 | 独占 staging、错误传播、三平台 no-replace API;Windows 替换待 CI | +| 声明覆盖与执行证据分离 | 实现,本地回归通过 | `--fail-unverified`;同一车道的通过结果才能验证其声明 | +| xdev CI 计划与选择 | 核心实现,本地回归通过 | 共享 C++ 选择及 9 个 app 用例;三平台 CI 加入 app 自测;distro 接入动态三分片,执行结果待 CI | +| proxy / net 事件 | 主体实现,隔离验证待 CI | SOCKS5h 单出口、独立 netns、native syscall 通知;本地 3 个 unit 通过,2 个隔离 e2e 跳过 | +| hook sandbox | 主体实现,隔离验证待 CI | 正式 libxpkg 0.0.61;persistent worker、独立协议、payload shadow、输出日志、先审计后放行;本地 2 个 worker flow 通过,3 个隔离用例跳过 | +| 系统层解析 / store 闭包 | 主链已接,真实隔离验收待 CI | RootView 精确 RO closure、inode 刷新、broker ready/同步回执、SourceMapping/facade 与 prefix producer/export;完整本地回归 107 程序通过,隔离 skip 不计验收 | +| glibc cache / Luban 生态 | 两架构资源已发布,客户端验证实施中 | 索引 PR #940 原生 x86_64/aarch64 loader/cache/preload、三平台检查通过;GitHub/GitCode 资源完整 GET+SHA 验证;客户端接入待本批 CI | +| fixture HTTP / 资源锁 | 主体实现,本地通过 | 6 个 xdev app 程序 / 22 用例通过;默认真实 HTTP mirror 与 checked 下载;Windows root-relative 路径修复待新 head CI | +| 性能与升级矩阵 | 趋势工具已本地通过,产品矩阵待完成 | xdev member/platform 历史及下一轮实际 shard 权重通过;不以 skip 替代产品性能证据 | +| 完整 CI、自审、合入、release、GitCode、索引 | 待完成 | 每个环节绑定同一 head;资源出现立即本地补 CN | +| 真实镜像设置 | 完成 | 实际 entry 执行 `xlings config --mirror CN` 返回 `mirror = CN` | + +构建记录:第一次产品编译因版本常量误用失败,修正为 `Info::VERSION` 后 +`mcpp build` 通过;xdev 构建、版本规范与文档示例检查通过。后续的 atomic writer +变更需要重新构建验证。统一回归首次 84 个测试程序中 82 通过、2 失败;修复非普通文件读取和 +scope/generation fixture 后,受影响用例单独通过,最新逐程序证据为 84 pass。 +42 个具体测试仍因能力、平台、网络或未通过 xdev 运行而跳过;不计为已验证。 +现场:`target/xdev/pr641-hardening/`。当前安全与选择器批次进入 #641;完整发布仍未完成。 +xdev 的 6 个选择逻辑、3 个 CLI 用例及报告执行证据用例通过。 +libxpkg worker / metadata API 的 LLVM 构建和 GCC 4 个测试程序通过,客户端接入继续。 +正式依赖已发布并进入 mcpp-index;客户端 `mcpp build` 成功下载/构建 `xpkg@0.0.61`。 +最新限量回归的 protocol 2、worker flow 2、cache 2、network unit 3 个用例通过; +worker/network 5 个真实隔离用例因本机 bwrap 不可用跳过。后续 cache 所有权、net +事件及 resolution evidence 改动仍需回归。macOS generation 并发读日志定位为 readlink EINVAL,`2b78ce6e` 追加有界重试,未放宽 +验收条件,macOS run 37709550248 已通过。后续普通 push:`3ea30c71`、`dd118625`、`2b78ce6e`、`88eafe96`。 + +历史保留:2026-10-08 用户要求后续仅追加 commit、普通 push,不 force push 或改写已推送提交。 + +事务/流/闭包基础批次完整构建通过(GCC 16.1,build 8),完整客户端回归 98 程序:94 pass / 4 fail / 49 具体用例 skip;四项定位修复的针对性复验通过,逐程序最新证据98 pass;xdev 4程序/18用例pass、0skip。 +D2 的 exact mounts 与 broker 更新、D3 真实 namespace producer 仍需完成;strict domain +预检会在生产器未接通时创建前拒绝,镜像测试不再删除宿主 `/xlings`。NDJSON 1.6 +stream/cancel 与 root compiler unit 已通过;真实 namespace/GCC consumer 仍待 CI。 + +最新追加提交:`6a1df938`、`d968ee47`、`0faff256`、`562cfbd9`、`d420d81d`、 +`33bd4499`、`777dccf5`,均普通 push。CI 修复已接新版 mcpp、可移植 kernel ABI 和 +Windows 头处理;以最新 head 的远端结果为准,不沿用此前 green。 +testkit 默认真实 HTTP mirror、下载/校验/解包和缺包拒绝已端到端通过;xdev 完整 +6 程序 / 22 用例 pass、0 skip。隔离旧 home 的 N-1→candidate dispatcher 升级、CN、 +self init 和 glibc 再安装通过,1462 个 payload 文件和 workspace/configured 状态保留。 +D2 session/RootView 源码检查点与两项 unit 通过,真实 namespace 两用例本机 skip; +D3 source mapping/facade 检查点编译通过,producer/runtime/export 全链仍在接。 +300 payload 的 generation build/switch 实测中位数 26.876 / 4.939 ms,预算通过; +stage-0 timing 审计及真实四次 boot 预算已加入,尚待 boot 车道。完整交付与发布仍未结束。 + +最新完整客户端回归:107 程序 pass、0 fail、54 具体用例 skip;源码构建通过。 +Windows fixture、macOS 旧 header refresh、Linux worker FD/observe/root mount 及审计 +验证契约的修复进入下一追加提交,不能沿用旧 head 的 CI 通过结果。 +DOM-SOURCE 静态 candidate 实际导出与隔离证明门禁已接线。 + + +后续同一源码波次已冻结:私有域 start/stop/具名 ps/log/info/config/doctor/rollback/runtime, +确认后 remove、typed cp、同域 fork;跨域 fork 暂拒绝裸复制,仍需 producer 重建。 +自审发现的 same-path mount 叠层与 mixed-home export 已修,真实回归进入新 CI。 +实际旧 header S1–S5 和 owned entry install/use 四种替换的 shell 场景全部通过。 +最终批次正在串行构建与针对性回归;无需再次跑没有新依据的完整 107 程序。 + +最终波次已本地构建通过;受影响 13 程序 pass、17 case skip;xdev 6 程序/22 case +pass、0 skip。命令参考、文档示例及两个lint通过。下一步追加提交并普通push,以新固定 +head的真实隔离/三平台CI验收,随后继续处理跨域重建与最终发布链。 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97efa5d06..240d6c66f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,8 +33,8 @@ env: # runtime package -- it installs one version while declaring another. That # is what kept glibc 2.44.2 out of the index; see xlings#573. BOOTSTRAP_XLINGS_VERSION: v2026.8.27.5 - # Must contain the mcpp version pinned in .xlings.json (2026.9.28.3). - XIM_PKGINDEX_REF: 7b9bf4368663f50eb3e736a41009d0eaf2051180 + # Must contain the mcpp version pinned in .xlings.json (2026.10.5.3). + XIM_PKGINDEX_REF: ebf1fbb3417ec513b923279044433887b3af8850 jobs: build-linux: diff --git a/.github/workflows/xlings-ci-aarch64.yml b/.github/workflows/xlings-ci-aarch64.yml index 2f9ba58aa..c967a584a 100644 --- a/.github/workflows/xlings-ci-aarch64.yml +++ b/.github/workflows/xlings-ci-aarch64.yml @@ -214,10 +214,83 @@ jobs: echo "== file =="; file "$bin" file "$bin" | grep -q "ARM aarch64" || { echo "NOT aarch64"; exit 1; } file "$bin" | grep -q "statically linked" || { echo "NOT static"; exit 1; } + diagnostic_dir=build/aarch64-run-diagnostics + mkdir -p "$diagnostic_dir" + expected_version=$(sed -n 's/.*VERSION = "\([^"]*\)".*/\1/p' src/core/config.cppm | head -1) + test -n "$expected_version" + printf 'xlings %s\n' "$expected_version" > "$diagnostic_dir/expected-version.txt" + git rev-parse HEAD > "$diagnostic_dir/candidate-commit.txt" + qemu_shim=$(command -v qemu-aarch64-static) + { + type -a qemu-aarch64-static + printf 'shim=%s\n' "$qemu_shim" + readlink -f "$qemu_shim" + file -L "$qemu_shim" + if grep -aqF 'xlings:multicall-shim:abi=1:caps=handoff:f615c0de' "$qemu_shim"; then + echo 'shim carries multicall marker v1' + else + echo 'shim has no multicall marker v1 (legacy dispatch may apply)' + fi + } > "$diagnostic_dir/qemu-routing.txt" + # Record only this provider's paths and active version, never the + # complete home configuration or environment (which can hold tokens). + python3 - "$diagnostic_dir" <<'PY' + import json, os, pathlib, sys + destination = pathlib.Path(sys.argv[1]) + try: + home = pathlib.Path(os.environ['XLINGS_HOME']) + config = json.loads((home / '.xlings.json').read_text()) + scope = home / 'subos' / 'current' / '.xlings.json' + workspace = json.loads(scope.read_text())['workspace'] + active = workspace['qemu-aarch64-static'] + if isinstance(active, dict): + active = active['active'] + target = config['versions']['qemu-aarch64-static'] + release = target['versions'][active] + filename = release.get('filename') or target.get('filename') or 'qemu-aarch64-static' + provider = pathlib.Path(release['path']) / filename + if release.get('alias'): + raise ValueError('provider has an alias; direct diagnostic is unavailable') + if not provider.is_absolute() or not provider.is_file(): + raise ValueError('registered provider is not an absolute existing file') + (destination / 'qemu-provider.txt').write_text(str(provider) + '\n') + (destination / 'qemu-provider.json').write_text(json.dumps({ + 'version': active, 'path': str(provider), 'scope': str(scope) + }) + '\n') + except Exception as error: + (destination / 'qemu-provider-error.txt').write_text(str(error) + '\n') + PY echo "== qemu xlings --version ==" - ver=$(qemu-aarch64-static "$bin" --version) - echo "$ver" - echo "$ver" | grep -q "xlings" || { echo "xlings --version failed under qemu"; exit 1; } + set +e + "$qemu_shim" "$bin" --version > "$diagnostic_dir/stdout.txt" 2> "$diagnostic_dir/stderr.txt" + run_status=$? + set -e + printf '%s\n' "$run_status" > "$diagnostic_dir/status.txt" + cat "$diagnostic_dir/stdout.txt" "$diagnostic_dir/stderr.txt" + if [ "$run_status" -ne 0 ] || ! cmp -s "$diagnostic_dir/expected-version.txt" "$diagnostic_dir/stdout.txt"; then + # Diagnostic reruns never turn a failed admission into success. + # Preserve both shim and direct-provider traces to distinguish + # native dispatch from the emulator and the guest startup path. + set +e + cat "$diagnostic_dir/qemu-routing.txt" + cat "$diagnostic_dir"/qemu-provider*.txt "$diagnostic_dir"/qemu-provider.json 2>/dev/null + timeout 20s "$qemu_shim" --version > "$diagnostic_dir/shim-version.txt" 2>&1 + printf '%s\n' "$?" > "$diagnostic_dir/shim-version-status.txt" + timeout 20s "$qemu_shim" -strace "$bin" --version > "$diagnostic_dir/shim-trace-stdout.txt" 2> "$diagnostic_dir/shim-trace-stderr.txt" + printf '%s\n' "$?" > "$diagnostic_dir/shim-trace-status.txt" + if [ -s "$diagnostic_dir/qemu-provider.txt" ]; then + qemu_provider=$(cat "$diagnostic_dir/qemu-provider.txt") + file "$qemu_provider" + sha256sum "$qemu_provider" "$bin" + timeout 20s "$qemu_provider" --version > "$diagnostic_dir/provider-version.txt" 2>&1 + printf '%s\n' "$?" > "$diagnostic_dir/provider-version-status.txt" + timeout 20s "$qemu_provider" -strace "$bin" --version > "$diagnostic_dir/provider-trace-stdout.txt" 2> "$diagnostic_dir/provider-trace-stderr.txt" + printf '%s\n' "$?" > "$diagnostic_dir/provider-trace-status.txt" + fi + tail -n 100 "$diagnostic_dir"/*trace-stderr.txt + echo "::error::ARM candidate --version failed: status=$run_status, expected xlings $expected_version" + if [ "$run_status" -eq 0 ]; then exit 1; else exit "$run_status"; fi + fi # Runs ONLY on failure, as its own step. # @@ -281,9 +354,11 @@ jobs: done - name: Upload aarch64 candidate + if: always() uses: actions/upload-artifact@v4 with: name: xlings-linux-aarch64-candidate path: | build/xlings-*-linux-aarch64.tar.gz build/xlings-*-linux-aarch64.tar.gz.sha256 + build/aarch64-run-diagnostics/** diff --git a/.github/workflows/xlings-ci-archlinux.yml b/.github/workflows/xlings-ci-archlinux.yml deleted file mode 100644 index 42f5183fb..000000000 --- a/.github/workflows/xlings-ci-archlinux.yml +++ /dev/null @@ -1,80 +0,0 @@ -name: xlings-ci-archlinux - -on: - push: - paths: - - 'config/aur/**' - - '.github/workflows/xlings-ci-archlinux.yml' - -jobs: - - archlinux-test: - runs-on: ubuntu-latest - - container: - image: archlinux:latest - - steps: - - name: Checkout Repository - uses: actions/checkout@v4 - - - name: Initialization Environment - run: | - pacman -Syu --noconfirm base-devel git sudo shadow - useradd -m admin - useradd -m test - usermod -aG wheel admin - usermod -aG wheel test - echo "admin ALL=(ALL) NOPASSWD:ALL" | tee /etc/sudoers.d/admin - echo "test ALL=(ALL) NOPASSWD:ALL" | tee /etc/sudoers.d/test - chmod 0440 /etc/sudoers.d/admin - chmod 0440 /etc/sudoers.d/test - - - name: Installation Xlings on Arch Linux - run: | - su admin -c """ - set -euo pipefail - - whoami - pwd - sudo chown -R test . - echo ::group:: Copy Repo - mkdir -p /tmp/build - cp -Rv ./config/aur/* /tmp/build - echo ::endgroup:: - echo ::group:: Makepkg Xlings - cd /tmp/build - makepkg -si --noconfirm - cd - - echo ::endgroup:: - echo ::group:: Package Info - pacman -Qii xlings - pacman -Ql xlings - echo ::endgroup:: - echo ::group:: Xlings Init and package index smoke - xlings - xlings update - xlings search nvm - echo ::endgroup:: - echo ::group:: Xlings Add Test User - xlings self config --adduser test - echo ::endgroup:: - """ - - su test -c """ - echo ::group:: Xlings Output - xlings --version - echo ::endgroup:: - """ - - name: XIM Test on Arch linux - env: - TEST_CI_AUR_NO_CONFIRM: true - run: | - su test -c """ - set -euo pipefail - - echo ::group:: Test XIM package index lookup - xlings update - xlings info nvm - echo ::endgroup:: - """ diff --git a/.github/workflows/xlings-ci-linux-e2e.yml b/.github/workflows/xlings-ci-linux-e2e.yml deleted file mode 100644 index 162458bb1..000000000 --- a/.github/workflows/xlings-ci-linux-e2e.yml +++ /dev/null @@ -1,310 +0,0 @@ -name: xlings-ci-linux-e2e - -# The Linux E2E suite, split out of xlings-ci-linux.yml so it runs in PARALLEL -# with the build + unit-test job instead of after it. Shares the same cache -# lineage, so it restores a warm sandbox; the only added wall-clock vs. the -# inline version is one extra release build. The E2E-02..E2E-42 block runs via -# tests/e2e/run_all.sh, which times each test and prints a slowest-first -# summary (mirrors mcpp's runner). E2E-00 / E2E-01 stay discrete steps here -# because they're interleaved with the release build. -# -# Paired: xlings-ci-linux.yml (build + unit tests + release artifact). - -# Release-train integration branches (release/**) run the same matrix as -# main: PRs land on the integration branch first and only reach main once -# the whole train is green, so CI must gate them there, not at merge time. -on: - push: - branches: [main, master, 'release/**'] - pull_request: - branches: [main, master, 'release/**'] - -env: - GIT_TERMINAL_PROMPT: 0 - XLINGS_RELEASE_MIRROR: GLOBAL - BOOTSTRAP_XLINGS_VERSION: v2026.8.27.5 - XIM_PKGINDEX_REF: 7b9bf4368663f50eb3e736a41009d0eaf2051180 - -jobs: - e2e: - runs-on: ubuntu-24.04 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - # ── Phase 1: environment (same as xlings-ci-linux.yml) ───────── - # patchelf + lua5.4: E2E-68 (closure guard differential) rigs an ELF - # with patchelf and runs the index's dep-closure-check.sh, which needs - # lua. The test SKIPs when they are missing, so dropping them here - # would not fail CI -- it would silently stop running the test. - - name: Install system deps - run: | - # The runner image ships Microsoft apt sources that answer 403, and - # `apt-get update` fails the whole step over a repository nothing - # here installs from. Dropped before updating rather than tolerated - # with `|| true`, which would also swallow a real failure. - sudo rm -f /etc/apt/sources.list.d/microsoft*.list \ - /etc/apt/sources.list.d/microsoft*.sources - sudo apt-get update -qq - sudo apt-get install -y curl git build-essential patchelf lua5.4 - - - name: Install bootstrap xlings (pinned ${{ env.BOOTSTRAP_XLINGS_VERSION }}) - env: - XLINGS_NON_INTERACTIVE: 1 - XLINGS_VERSION: ${{ env.BOOTSTRAP_XLINGS_VERSION }} - run: | - # Retried, piped through a file, and asserted -- three fixes for one - # failure mode. `curl … | bash` reports the exit status of BASH, and - # bash fed an empty stdin exits 0, so a network reset installs - # nothing and the step goes GREEN. Measured on the 2026.9.4.1 - # release: `curl: (35) Recv failure: Connection reset by peer` here, - # step reported success, and the run died two steps later with - # `xlings: command not found` (exit 127) pointing at the wrong - # thing entirely. - for attempt in 1 2 3; do - curl -fsSL --retry 3 --retry-delay 5 --connect-timeout 20 \ - https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh \ - -o /tmp/xlings-quick-install.sh && break - echo "bootstrap download failed (attempt $attempt/3); retrying" - sleep 10 - done - test -s /tmp/xlings-quick-install.sh \ - || { echo "::error::could not download quick_install.sh"; exit 1; } - bash /tmp/xlings-quick-install.sh - # The assertion that makes a silent no-op impossible: whatever the - # installer thought it did, the binary either exists here or it does - # not. - test -x "$HOME/.xlings/bin/xlings" \ - || { echo "::error::bootstrap reported success but installed no xlings"; exit 1; } - echo "XLINGS_HOME=$HOME/.xlings" >> "$GITHUB_ENV" - echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - echo "$HOME/.xlings/bin" >> "$GITHUB_PATH" - - - name: Cache xlings package payloads - uses: actions/cache@v4 - with: - path: ~/.xlings/data/xpkgs - key: xlings-deps-${{ runner.os }}-${{ hashFiles('.xlings.json') }}-${{ env.BOOTSTRAP_XLINGS_VERSION }} - restore-keys: | - xlings-deps-${{ runner.os }}- - - - name: Cache mcpp sandbox and project packages - id: mcpp-cache - uses: actions/cache@v4 - with: - path: | - ~/.mcpp - !~/.mcpp/bmi - !~/.mcpp/build-cache - .mcpp - !.mcpp/bmi - !.mcpp/build-cache - # BMIs are NOT cached, and are deleted after every restore. That - # is the fix, and it took three prefix retirements to find. - # - # A restored BMI set never works. `import 'std' has CRC mismatch` - # follows, which reads like a compiler bug. What hid it is that the - # old guard wiped the BMIs on an INEXACT restore -- so retiring the - # key prefix forced an inexact restore, fired the guard, and produced - # one clean run. Each retirement therefore looked like a fix and was - # an accident; the next run got an exact hit, skipped the guard, and - # failed again. Read straight off two consecutive runs of THIS - # workflow: the one that passed shows "Drop stale BMIs" as `success`, - # the one that failed shows it `skipped`. - # - # So the wipe is unconditional now, and the BMI directories are - # excluded from the cache as well -- the exclusion is an optimisation - # (do not store bytes we delete on arrival), the unconditional wipe is - # the guarantee, and it cannot depend on exclude-pattern semantics. - # - # What the cache is FOR is `registry/data/xpkgs`: ~800 MB of payloads - # that are toolchain-independent and survive a round trip fine. - # - # The key still names the workflow that produced it. mcpp.toml - # declares both the dev toolchain (gcc@16.1.0) and the release target - # (gcc@15.1.0-musl), so a CI build and a release build hash the same - # -- sharing one key across workflows is wrong on its own terms even - # though it was not what caused this. - key: mcpp-v4-ci-linux-e2e-${{ runner.os }}-${{ hashFiles('mcpp.toml', 'mcpp.lock', '.xlings.json') }}-${{ env.BOOTSTRAP_XLINGS_VERSION }} - restore-keys: | - mcpp-v4-ci-linux-e2e-${{ runner.os }}- - # An inexact restore (the `restore-keys` fallback) brings BMIs built - # under a different dependency set. Mixing them with anything rebuilt - # here fails as `import 'std' has CRC mismatch` — which reads like a - # compiler bug and is a cache restored for the wrong inputs. Measured on - # PR #480, where `mcpplibs.xpkg` went 0.0.47 → 0.0.48: the fallback - # cache's tinyhttps/cmdline BMIs no longer matched the `std` the fresh - # xpkg build produced. - # - # Only the BMIs go. The payload store under registry/data/xpkgs is - # dependency-independent and is what makes the ~800 MB cache worth - # restoring at all. - # Evidence for task #56, gathered where the failure actually happens. - # - # Four hypotheses were refuted on a developer machine (2026-08-06): two - # toolchains sharing one BMI store; the tar round trip destroying - # metadata (the .gcm set survives byte-for-byte, mtimes included); a full - # project rebuild against restored BMIs; and prune_registry running after - # the restore. None reproduces it locally, so the next honest step is to - # record what CI has that a laptop does not, rather than guess a fifth - # hypothesis. - # - # What to look for: two entries that disagree on driver_identity or - # sysroot for the same cpp_standard. That would mean the restored set is - # internally inconsistent, which is the answer. - # - # continue-on-error on purpose: a diagnostic must never be the reason a - # release is blocked. Set XLINGS_KEEP_RESTORED_BMIS=1 on a throwaway - # branch to skip the wipe below and let the failure happen with this - # record in hand. - - name: Record the restored BMI set (diagnostic for #56) - shell: bash - continue-on-error: true - run: | - echo "cache-hit: ${{ steps.mcpp-cache.outputs.cache-hit }}" - for d in ~/.mcpp/bmi .mcpp/bmi ~/.mcpp/build-cache .mcpp/build-cache; do - if [ -d "$d" ]; then - echo " $d: $(du -sh "$d" 2>/dev/null | cut -f1), $(find "$d" -name '*.gcm' 2>/dev/null | wc -l) .gcm" - else - echo " $d: absent" - fi - done - echo " --- build identity per BMI dir (first 40) ---" - find ~/.mcpp/bmi .mcpp/bmi -name std-module.json 2>/dev/null | head -40 | - while read -r f; do - printf ' %-18s %s %s\n' \ - "$(basename "$(dirname "$f")")" \ - "$(grep -o '"cpp_standard": *"[^"]*"' "$f" | cut -d'"' -f4)" \ - "$(grep -o '"driver_identity": *"[^"]*"' "$f" | cut -d'"' -f4)" - done - echo " --- distinct driver identities ---" - find ~/.mcpp/bmi .mcpp/bmi -name std-module.json 2>/dev/null | - xargs -r grep -ho '"driver_identity": *"[^"]*"' | sort -u - - - name: Drop restored BMIs - if: env.XLINGS_KEEP_RESTORED_BMIS != '1' - shell: bash - run: | - rm -rf ~/.mcpp/bmi ~/.mcpp/build-cache .mcpp/bmi .mcpp/build-cache - - - name: Prepare bootstrap package index - run: | - rm -rf "$XLINGS_HOME/data/xim-pkgindex" - bash tests/e2e/prepare_fixture_index.sh "$XLINGS_HOME/data/xim-pkgindex" - - - name: Install build dependencies (mcpp) - run: | - cd "$GITHUB_WORKSPACE" - xlings config --mirror GLOBAL - # Refresh the index before resolving anything out of it. - # - # Without this the run resolves against whatever index snapshot the - # bootstrap shipped with, which is not the one this PR needs: a - # freshly published `mcpplibs.xpkg@0.0.50` reported as "not found" - # while older entries from the same index downloaded fine. The - # failure names the package, so it reads as an unpublished - # dependency rather than as an index nobody refreshed. - xlings update || true - xlings install -y - - # Installing is not switching, and this cache makes the difference - # visible. `xlings install` lays the payload down; the shim keeps - # resolving whatever version was already active -- the client says so - # ("installed, but 'x' still resolves to ..."), and with ~/.mcpp - # restored from a cache that line is the ONLY sign that the mcpp - # about to run is the old one. Measured 2026-08-06: .xlings.json - # pinned 2026.8.6.1 while the run used the cached build, whose index - # snapshot predated the dependency this PR needs -- surfacing as - # `mcpplibs.xpkg@0.0.53 not found` against an index that demonstrably - # had it. - # The pin comes from .xlings.json at runtime, not a literal -- a - # literal here is a value that changes, and the next mcpp bump would - # leave every workflow switching to the previous one. - pin="$(python3 -c 'import json;print(json.load(open(".xlings.json"))["workspace"]["mcpp"])' 2>/dev/null || true)" - [ -n "$pin" ] && xlings use mcpp "$pin" || true - - mcpp self version - - - name: Reinitialize mcpp registry (GLOBAL) - run: | - # Drop the resolved indexes and mirror config so they are - # re-resolved against GLOBAL, but KEEP registry/data/xpkgs -- the - # downloaded and extracted package payloads. - # - # Those payloads are sha256-pinned by their recipe, so they are - # mirror-independent: keeping them cannot leak a CN artifact into a - # GLOBAL build. Wiping them made every run re-fetch every transitive - # source from its upstream host, so CI failed wholesale whenever one - # of those hosts was unreachable -- e.g. compat.bzip2 (a transitive - # dep of compat.libarchive, and absent from mcpp.lock, which only - # pins direct deps) fetched from sourceware.org. It also made the - # ~800 MB mcpp cache restored one step earlier almost worthless. - prune_registry() { - [ -d "$1" ] || return 0 - find "$1" -mindepth 1 -maxdepth 1 ! -name data -exec rm -rf {} + - [ -d "$1/data" ] || return 0 - find "$1/data" -mindepth 1 -maxdepth 1 ! -name xpkgs -exec rm -rf {} + - } - prune_registry "$HOME/.mcpp/registry" - prune_registry "$GITHUB_WORKSPACE/.mcpp/registry" - mcpp self config --mirror GLOBAL - - # Force the index refresh, then ASSERT what landed. - # - # The prune above drops the resolved indexes, and mcpp's own - # re-fetch happily uses whatever it finds in the restored cache -- - # so a freshly published dependency reads as "not found" against an - # index that demonstrably has it. Measured 2026-08-06: - # `mcpplibs.xpkg@0.0.53 not found`, while the index commit, the - # published artifact, the rolling pointer and releases/latest all - # carried the entry. - # - # `mcpp index update` is the refresh; `mcpp index status` prints the - # revision each index landed on, which is the one datum that makes a - # stale-index failure readable instead of surfacing two layers away - # as `@ not found`. - mcpp index update || true - mcpp index status || true - # PER PLATFORM, not a whole-file grep. - # - # A grep for `["0.0.53"]` passes on a file where the entry exists - # only under `xpm.linux` -- which is exactly the bug this assert was - # written to catch and did not: both bumps edited the linux section - # alone, linux CI went green, and macOS/Windows failed against a - # file that literally contains the string. Slice the section for - # THIS platform first. - - - name: Prepare fixture index repo - run: | - bash tests/e2e/prepare_fixture_index.sh - - # ── E2E-00 (pre-release: mcpp builds xlings from source) ─────── - - name: "E2E-00: mcpp builds xlings from source" - run: | - bash tests/e2e/mcpp_build_xlings_test.sh - - # ── Phase 2: build the release artifact ──────────────────────── - - name: Build (linux_release) - run: | - chmod +x ./tools/linux_release.sh - SKIP_NETWORK_VERIFY=1 ./tools/linux_release.sh - - - name: "E2E-01: Bootstrap Home (portable + installed)" - run: | - bash tests/e2e/bootstrap_home_test.sh - - - name: Uninstall old Xlings - run: | - xlings self uninstall -y || rm -rf "$HOME/.xlings" - - - name: Prepare release artifact - run: | - tarball=$(ls build/xlings-*-linux-x86_64.tar.gz 2>/dev/null | head -1) - [[ -z "$tarball" ]] && { echo "No release tarball found"; exit 1; } - cp "$tarball" build/release.tar.gz - - # ── E2E-02..E2E-42 via the timed runner (slowest-first summary) ─ - - name: "E2E suite (E2E-02..E2E-42, timed)" - run: | - bash tests/e2e/run_all.sh build/release.tar.gz diff --git a/.github/workflows/xlings-ci-linux-root.yml b/.github/workflows/xlings-ci-linux-root.yml index cf87481d4..96321c83f 100644 --- a/.github/workflows/xlings-ci-linux-root.yml +++ b/.github/workflows/xlings-ci-linux-root.yml @@ -16,12 +16,18 @@ on: pull_request: branches: [main, master, 'release/**'] +# A newer push to the same PR supersedes the run in flight; main and release +# branches keep every run (each one is a commit that landed). +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: GIT_TERMINAL_PROMPT: 0 XLINGS_RELEASE_MIRROR: GLOBAL # Keep in sync with xlings-ci-linux.yml. BOOTSTRAP_XLINGS_VERSION: v2026.8.27.5 - XIM_PKGINDEX_REF: 7b9bf4368663f50eb3e736a41009d0eaf2051180 + XIM_PKGINDEX_REF: ebf1fbb3417ec513b923279044433887b3af8850 jobs: # ── Build the latest xlings from source via mcpp ────────────────── diff --git a/.github/workflows/xlings-ci-linux.yml b/.github/workflows/xlings-ci-linux.yml index fe9539b38..8b87bda65 100644 --- a/.github/workflows/xlings-ci-linux.yml +++ b/.github/workflows/xlings-ci-linux.yml @@ -9,6 +9,12 @@ on: pull_request: branches: [main, master, 'release/**'] +# A newer push to the same PR supersedes the run in flight; main and release +# branches keep every run (each one is a commit that landed). +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: GIT_TERMINAL_PROMPT: 0 # CI runs on github.com; force GLOBAL mirror so xim doesn't try gitee @@ -23,12 +29,14 @@ env: # so treat that variable as documentation, not as a lock, until it is # either honoured everywhere or removed. BOOTSTRAP_XLINGS_VERSION: v2026.8.27.5 - # Must contain the mcpp version pinned in .xlings.json (2026.9.28.3). - XIM_PKGINDEX_REF: 7b9bf4368663f50eb3e736a41009d0eaf2051180 + # Must contain the mcpp version pinned in .xlings.json (2026.10.5.3). + XIM_PKGINDEX_REF: ebf1fbb3417ec513b923279044433887b3af8850 jobs: build-and-test: runs-on: ubuntu-24.04 + outputs: + release_ready: ${{ steps.candidate.outcome == 'success' }} steps: - name: Checkout @@ -40,11 +48,17 @@ jobs: - name: fresh-install must track the published latest run: bash tests/fresh-install/no_xlings_version_pin_check.sh - # A subos home is user data; one function deletes a whole subos, and - # only with the user's confirmation. This fails any other remove_all on - # a subos path. Milliseconds, no build. - - name: subos directories are deleted in one place - run: bash tools/lint_subos_remove_all.sh + # Repository rules, every tools/lint_*.sh: a subos is deleted in one + # place (user data); system headers only in modules/platform; platform + # selection is `if constexpr`; package layers import downward only. + # Milliseconds, no build. A new lint joins by being a tools/lint_*.sh. + - name: repository lints (tools/lint_*.sh) + run: | + rc=0 + for lint in tools/lint_*.sh; do + echo "::group::$lint"; bash "$lint" || rc=1; echo "::endgroup::" + done + exit $rc # ── Phase 1: Configure Environment via xlings ───────────────── @@ -285,6 +299,19 @@ jobs: # ── Phase 1.5: Unit Tests ──────────────────────────────────── + # The capabilities this lane promises (tests/README.md). A test that + # needs one of them and finds it missing FAILS here instead of skipping: + # an isolation test that quietly skips on the one machine that runs it is + # F13 in the SubOS design. GitHub's ubuntu-24.04 image restricts + # unprivileged user namespaces through AppArmor; the lane lifts that for + # itself -- what a user does about it is `xlings self doctor --isolation`. + - name: Lane capabilities (user namespaces, bwrap) + run: | + sudo apt-get install -y -qq bubblewrap passt + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true + echo "XDEV_LANE=linux-unit" >> "$GITHUB_ENV" + echo "XDEV_LANE_CAPS=linux,posix,pty,xlings-bin,userns,bwrap,sandbox,pasta,landlock,network" >> "$GITHUB_ENV" + - name: Build and run unit tests run: | # `mcpp build` can transiently fail with "dependency '...': index @@ -296,26 +323,37 @@ jobs: echo "::warning::mcpp build failed (likely stale index entry); retrying after auto-refresh" mcpp build } - mcpp test || { - status=$? - echo "::group::mcpp build diagnostics" - - ninja_bin="$(command -v ninja || true)" - if [[ -z "$ninja_bin" ]]; then - ninja_bin="$(find "$HOME/.mcpp" "$XLINGS_HOME" -name ninja -type f -perm -111 2>/dev/null | head -1 || true)" - fi - if [[ -z "$ninja_bin" ]]; then - echo "ninja not found" - else - while IFS= read -r build_file; do - build_dir="$(dirname "$build_file")" - echo "\$ $ninja_bin -C $build_dir -v" - "$ninja_bin" -C "$build_dir" -v || true - done < <(find target -name build.ninja -type f | sort) - fi - echo "::endgroup::" - exit "$status" - } + # xdev runs `mcpp test` and renders the report; the step summary is + # rendered once, by the report job, from every lane. Built static + # (musl) so the e2e and report jobs can run this same binary: a glibc + # dev build's loader lives in this runner's mcpp registry. + mcpp build -p xdev --target x86_64-linux-musl + # A member's binary lands under the root's target/ or the member's + # own, depending on the mcpp version. + xdev="$(find target apps/xdev -path '*musl*' -name xdev -type f -perm -111 2>/dev/null | head -1)" + [[ -n "$xdev" ]] || { echo "::error::mcpp build -p xdev produced no xdev"; find . -name 'xdev*' -not -path './.git/*' | head; exit 1; } + # linux_release.sh cleans the musl target; retain the static runner + # outside that tree for candidate acceptance and downstream lanes. + mkdir -p target/xdev/runner/bin + cp "$xdev" target/xdev/runner/bin/xdev + xdev="$GITHUB_WORKSPACE/target/xdev/runner/bin/xdev" + echo "XDEV=$xdev" >> "$GITHUB_ENV" + export XDEV_BIN="$xdev" + mkdir -p target/xdev/linux-xdev + printf '%s\n' '{"name":"linux-xdev","platform":"linux","probes":{}}' > target/xdev/linux-xdev/lane.json + XTEST_META_OUT="$GITHUB_WORKSPACE/target/xdev/linux-xdev/meta.ndjson" \ + XTEST_RESULTS_OUT="$GITHUB_WORKSPACE/target/xdev/linux-xdev/cases.ndjson" \ + mcpp test -p xdev + mcpp test --no-run --message-format json + "$xdev" test --no-build --out target/xdev/linux-unit + # Retain only the already-built candidate acceptance programs. + for test in e2e/test_domain_source e2e/test_domain_producer e2e/test_subos_perf unit/test_root_generation_perf; do + binary="$(find target -type f -path "*/bin/$test" | head -1)" + test -x "$binary" + destination="target/xdev/retained/bin/$test" + mkdir -p "$(dirname "$destination")" + cp -p "$binary" "$destination" + done # A separate step, `if: failure()`, and not an `|| { ... }` on one # command. @@ -356,23 +394,40 @@ jobs: xlings --version 2>&1 | head -2 || true - name: Multiplatform contract scripts + env: + XDEV_LANE: linux-contracts + run: | + "$XDEV" test --no-mcpp --suite contract-scripts --out target/xdev/linux-contracts + + # openkal beside this toolchain's runtimes (design part 3 §4.3): glibc, + # and the static musl target the release uses. + - name: openkal coexistence (OPENKAL-COEXIST) + run: | + cd tools/openkal-coexistence + for target in "" "--target x86_64-linux-musl"; do + mcpp build $target + bin="$(find target -path '*/bin/openkal-coexistence' -type f -newer mcpp.toml | xargs ls -t | head -1)" + out="$("$bin")" + echo "$out" + grep -qx 'libstdc++ says: hello' <<<"$out" + grep -qx 'openkal says: hello' <<<"$out" + grep -q '^kal_version=' <<<"$out" + done + + # The same repository lints as the first step, through xdev this time, + # so what they cover (LAYOUT-DEPS ...) is evidence in the requirement + # report and not only a green step. + - name: Repository lints (requirement evidence) + env: + XDEV_LANE: linux-lint run: | - bash tests/scripts/test_quick_install.sh - python3 tests/scripts/test_release_candidate_gate.py - python3 tests/scripts/test_index_pointer_history.py - python3 tests/scripts/test_version_consistency.py - python3 tests/scripts/test_windows_header_hygiene.py - python3 tests/scripts/test_selector_contrast.py - bin=$(find target -path '*/bin/xlings' -type f -perm -111 -exec ls -t {} + | head -1) - python3 tests/scripts/test_docs_examples.py --xlings "$bin" - python3 tests/scripts/test_generated_command_reference.py --xlings "$bin" - python3 tests/scripts/test_cli_spec_parity.py --xlings "$bin" - XLINGS_BIN="$bin" python3 tests/scripts/test_i18n_coverage.py + "$XDEV" test --no-mcpp --suite lint --out target/xdev/linux-lint # ── Phase 2: Build via Release Script ─────────────────────── - # NOTE: the E2E suite (E2E-00..E2E-28) moved to xlings-ci-linux-e2e.yml - # so it runs in parallel with this build+unit job. This job keeps the - # release build + artifact upload. + # The ONE release build of this commit on Linux x86_64. The e2e job + # below takes this artifact instead of building its own (it used to be a + # separate workflow with a second source build and a third release + # build of the same commit). - name: Build (linux_release) run: | @@ -386,6 +441,7 @@ jobs: cp "$tarball" build/release.tar.gz - name: Candidate cold-home lifecycle + id: candidate run: | sha256sum build/release.tar.gz > build/release.tar.gz.sha256 bash tests/candidate-install/smoke.sh \ @@ -396,9 +452,473 @@ jobs: if: success() with: name: xlings-linux-x86_64 - path: build/release.tar.gz + path: | + build/release.tar.gz + build/xlings-*-linux-x86_64.tar.gz + + - name: Static domain lifecycle, checked system source and image export + id: static_domain + continue-on-error: true + env: + XDEV_LANE: linux-static-domain + run: | + candidate="$(find "$GITHUB_WORKSPACE/build" -path '*/bin/xlings' -type f | head -1)" + test -x "$candidate" + export XLINGS_BIN="$candidate" + status=0 + "$XDEV" test e2e/test_domain_source --no-build --require-metadata \ + --out target/xdev/linux-static-domain || status=$? + "$XDEV" test e2e/test_domain_producer --no-build --require-metadata \ + --out target/xdev/linux-static-domain-producer || status=$? + python3 - <<'PY' + import json + from pathlib import Path + results = [json.loads(line) for directory in ('linux-static-domain', 'linux-static-domain-producer') + for line in Path(f'target/xdev/{directory}/cases.ndjson').read_text().splitlines()] + for case in ('DomainSourceProducer.ReusesOnlyCheckedReadonlyBytesAndExportsAnOwnedImage', + 'RootExport.OwnsBorrowedClosureAndOmitsOtherScopeVersions', + 'DomainProducer.InstallsAtLogicalPrefixInsideANamespaceAndPreservesHostData'): + checked = [row for row in results if row.get('test') == case] + assert len(checked) == 1 and checked[0]['status'] == 'pass', f'{case} must actually pass' + PY + exit "$status" + + - name: Static SubOS performance budgets + id: static_perf + continue-on-error: true + env: + XDEV_LANE: linux-static-perf + run: | + candidate="$(find "$GITHUB_WORKSPACE/build" -path '*/bin/xlings' -type f | head -1)" + test -x "$candidate" + export XLINGS_BIN="$candidate" + # Timing budgets are asserted here, where each case runs alone. + export XLINGS_PERF_BUDGETS=1 + status=0 + "$XDEV" test e2e/test_subos_perf --no-build --require-metadata \ + --out target/xdev/linux-static-perf || status=$? + "$XDEV" test unit/test_root_generation_perf --no-build --require-metadata \ + --out target/xdev/linux-static-generation-perf || status=$? + python3 - <<'PY' + import json + from pathlib import Path + for directory, count in (('linux-static-perf', 4), ('linux-static-generation-perf', 3)): + results = [json.loads(line) for line in Path(f'target/xdev/{directory}/cases.ndjson').read_text().splitlines()] + assert len(results) == count and all(row['status'] == 'pass' for row in results), results + PY + exit "$status" + + - name: Attach xdev and its runs + uses: actions/upload-artifact@v4 + if: always() + with: + name: xdev-linux-build + path: | + ${{ env.XDEV }} + target/xdev/ + if-no-files-found: ignore + + - name: Require every static acceptance gate + if: always() + env: + CANDIDATE_OUTCOME: ${{ steps.candidate.outcome }} + DOMAIN_OUTCOME: ${{ steps.static_domain.outcome }} + PERF_OUTCOME: ${{ steps.static_perf.outcome }} + run: | + for outcome in "$CANDIDATE_OUTCOME" "$DOMAIN_OUTCOME" "$PERF_OUTCOME"; do + [[ "$outcome" == success ]] || { echo "::error::static acceptance is incomplete or failed"; exit 1; } + done + + # The shell e2e suite, against the artifact built above. + e2e: + needs: build-and-test + if: ${{ !cancelled() && needs.build-and-test.outputs.release_ready == 'true' }} + runs-on: ubuntu-24.04 + env: + XDEV_LANE: linux-e2e + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install system deps + run: | + sudo apt-get update -qq + sudo apt-get install -y curl git build-essential + + - name: Fetch the release artifact and xdev + uses: actions/download-artifact@v4 + with: + name: xlings-linux-x86_64 + path: build + + - name: Fetch xdev + uses: actions/download-artifact@v4 + with: + name: xdev-linux-build + path: xdev-artifact + + - name: Prepare fixture index repo + run: | + bash tests/e2e/prepare_fixture_index.sh + + - name: Binary under test + run: | + mkdir -p build/extracted + tar -xzf build/release.tar.gz -C build/extracted + bin="$(find "$GITHUB_WORKSPACE/build/extracted" -path '*/bin/xlings' -type f | head -1)" + [[ -x "$bin" ]] || { echo "::error::no bin/xlings in the release artifact"; exit 1; } + echo "XLINGS_BIN=$bin" >> "$GITHUB_ENV" + xdev="$(find "$GITHUB_WORKSPACE/xdev-artifact" -name xdev -type f | head -1)" + chmod +x "$xdev" + echo "XDEV=$xdev" >> "$GITHUB_ENV" + + - name: "E2E-01: Bootstrap Home (portable + installed)" + run: | + bash tests/e2e/bootstrap_home_test.sh + + - name: "E2E suite (E2E-02..E2E-42, timed)" + run: | + "$XDEV" test --no-mcpp --suite e2e-shell --tarball build/release.tar.gz \ + --out target/xdev/linux-e2e + + - name: Attach the e2e run + uses: actions/upload-artifact@v4 + if: always() + with: + name: xdev-linux-e2e + path: target/xdev/ + if-no-files-found: ignore + + # The root repair for AppArmor-restricted user namespaces, on a runner + # left exactly as the image ships it (#640's comment). It installs + # /usr/lib/xlings/bwrap and an AppArmor profile with sudo, which is why it + # has a runner of its own. + isolation-fix: + needs: build-and-test + if: ${{ !cancelled() && needs.build-and-test.outputs.release_ready == 'true' }} + runs-on: ubuntu-24.04 + env: + XDEV_LANE: linux-isolation-fix + XDEV_LANE_CAPS: linux,xlings-bin,sudo,network + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Fetch the release artifact + uses: actions/download-artifact@v4 + with: + name: xlings-linux-x86_64 + path: build + + - name: Fetch xdev + uses: actions/download-artifact@v4 + with: + name: xdev-linux-build + path: xdev-artifact + + - name: Binary under test + run: | + mkdir -p build/extracted + tar -xzf build/release.tar.gz -C build/extracted + echo "XLINGS_BIN=$(find "$GITHUB_WORKSPACE/build/extracted" -path '*/bin/xlings' -type f | head -1)" >> "$GITHUB_ENV" + xdev="$(find "$GITHUB_WORKSPACE/xdev-artifact" -name xdev -type f | head -1)" + chmod +x "$xdev" + echo "XDEV=$xdev" >> "$GITHUB_ENV" + + - name: self doctor --isolation --fix + run: | + "$XDEV" test --no-mcpp --suite isolation-fix --require-metadata --out target/xdev/linux-isolation-fix + + - name: Attach the run + uses: actions/upload-artifact@v4 + if: always() + with: + name: xdev-linux-isolation-fix + path: target/xdev/ + if-no-files-found: ignore + + # Deployment S as a distribution ships it (design part 2 §5): the AUR + # recipe built from THIS build's release artifact, installed with pacman, + # used by two users. Replaces xlings-ci-archlinux.yml, which ran only when + # config/aur changed and built a 0.4.14 download. + arch-package: + needs: build-and-test + if: ${{ !cancelled() && needs.build-and-test.outputs.release_ready == 'true' }} + runs-on: ubuntu-24.04 + container: + image: archlinux:latest + env: + XDEV_LANE: linux-arch-package + XDEV_LANE_CAPS: linux,root,network + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Fetch the release artifact + uses: actions/download-artifact@v4 + with: + name: xlings-linux-x86_64 + path: build + + - name: Fetch xdev + uses: actions/download-artifact@v4 + with: + name: xdev-linux-build + path: xdev-artifact + + - name: Package, install, use + run: | + xdev="$(find "$GITHUB_WORKSPACE/xdev-artifact" -name xdev -type f | head -1)" + chmod +x "$xdev" + "$xdev" test --no-mcpp --suite aur --tarball build/release.tar.gz \ + --out target/xdev/linux-arch-package + + - name: Attach the run + uses: actions/upload-artifact@v4 + if: always() + with: + name: xdev-linux-arch-package + path: target/xdev/ + if-no-files-found: ignore + + # The root projection (SubOS design part 2): Luban roots from the published + # editions -- entered in bwrap, exported to docker, booted under qemu with + # their own kernel -- against THIS build's release binary (static: an + # exported image's xlings runs with no host under it). + plan-distro: + needs: build-and-test + if: ${{ !cancelled() && needs.build-and-test.outputs.release_ready == 'true' }} + runs-on: ubuntu-24.04 + outputs: + matrix: ${{ steps.plan.outputs.matrix }} + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: xdev-linux-build + path: xdev-artifact + - name: Restore measured main-branch shard weights + uses: actions/cache/restore@v4 + with: + path: target/xdev/history + key: xdev-history-v1-linux-${{ github.sha }} + restore-keys: xdev-history-v1-linux- + - name: Plan root scenarios with the local selector + id: plan + run: | + xdev="$(find "$GITHUB_WORKSPACE/xdev-artifact" -name xdev -type f | head -1)" + test -n "$xdev" + chmod +x "$xdev" + weights=() + if [[ -f target/xdev/history/timings.json ]]; then weights+=(--timings target/xdev/history/timings.json); fi + matrix="$("$xdev" ci plan --no-mcpp --suite distro --platform linux --lane main --shards 3 --require-metadata "${weights[@]}")" + echo "matrix=$matrix" >> "$GITHUB_OUTPUT" + + distro: + needs: [build-and-test, plan-distro] + if: ${{ !cancelled() && needs.build-and-test.outputs.release_ready == 'true' && needs.plan-distro.result == 'success' }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.plan-distro.outputs.matrix) }} + runs-on: ubuntu-24.04 + env: + XDEV_LANE: linux-distro-${{ strategy.job-index }} + XDEV_LANE_CAPS: linux,xlings-bin,bwrap,userns,network,docker,qemu,sudo + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Fetch the release artifact + uses: actions/download-artifact@v4 + with: + name: xlings-linux-x86_64 + path: build + + - name: Fetch xdev + uses: actions/download-artifact@v4 + with: + name: xdev-linux-build + path: xdev-artifact + + - name: Lane capabilities (bwrap, qemu, kvm) + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq bubblewrap qemu-system-x86 e2fsprogs + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true + # KVM when the runner has it: a boot in seconds instead of a minute. + if [[ -e /dev/kvm ]]; then sudo chmod 666 /dev/kvm; fi + + - name: Binary under test + run: | + mkdir -p build/extracted + tar -xzf build/release.tar.gz -C build/extracted + echo "XLINGS_BIN=$(find "$GITHUB_WORKSPACE/build/extracted" -path '*/bin/xlings' -type f | head -1)" >> "$GITHUB_ENV" + xdev="$(find "$GITHUB_WORKSPACE/xdev-artifact" -name xdev -type f | head -1)" + chmod +x "$xdev" + echo "XDEV=$xdev" >> "$GITHUB_ENV" + + - name: Roots, images, boots + run: | + "$XDEV" test --no-mcpp --suite distro --lane main --shard '${{ matrix.shard }}' \ + --require-metadata --out target/xdev/linux-distro-${{ strategy.job-index }} + + # The luban-tiny tarball the image scenario exported, for wsl --import. + - name: Attach the root tarball + uses: actions/upload-artifact@v4 + if: always() + with: + name: luban-tiny-tar + path: /tmp/xlings-e2e-*/rootfs_image/tiny.tar.gz + if-no-files-found: ignore + + - name: Attach the run + uses: actions/upload-artifact@v4 + if: always() + with: + name: xdev-linux-distro-${{ strategy.job-index }} + path: target/xdev/ + if-no-files-found: ignore + + # EXPORT-WSL (design part 2 §13): the tarball a Linux build exported, + # imported by Windows. GitHub's Windows runners offer WSL1 only, through a + # third-party action; per the design's decision this lane REPORTS and does + # not block (a WSL1 failure says nothing about WSL2): a pass or a skip + # with the reason, in the same report as everything else. + wsl-import: + needs: distro + if: always() + runs-on: windows-2022 + steps: + - name: Fetch the root tarball + uses: actions/download-artifact@v4 + continue-on-error: true + with: + name: luban-tiny-tar + path: root + + - name: WSL + id: setup + continue-on-error: true + uses: Vampire/setup-wsl@v5 + with: + distribution: Alpine + wsl-version: 1 + + - name: wsl --import, run + id: run + continue-on-error: true + shell: pwsh + run: | + $tar = Get-ChildItem -Recurse root -Filter tiny.tar.gz | Select-Object -First 1 + if (-not $tar) { throw "no tarball from the distro job" } + New-Item -ItemType Directory -Force C:\luban | Out-Null + wsl --import luban C:\luban $tar.FullName --version 1 + if ($LASTEXITCODE -ne 0) { throw "wsl --import failed" } + $out = wsl -d luban -- /bin/sh -c '. /etc/os-release; echo "os=$ID/$VARIANT_ID"; xlings --version' + $out + if (-not ($out -match "os=luban/tiny")) { throw "not luban tiny: $out" } + if (-not ($out -match "^xlings ")) { throw "no xlings: $out" } + + - name: Record + if: always() + shell: pwsh + run: | + $dir = "target/xdev/windows-wsl" + New-Item -ItemType Directory -Force $dir | Out-Null + $name = "wsl: subos export --tar imports with wsl --import (WSL1)" + '{"name":"windows-wsl","platform":"windows","probes":{}}' | Set-Content "$dir/lane.json" + ('{"test":"' + $name + '","area":"scenario","covers":["EXPORT-WSL"],"requires":[],"proves":"flow"}') | Set-Content "$dir/meta.ndjson" + if ("${{ steps.run.outcome }}" -eq "success") { $st = "pass"; $msg = "" } + elseif ("${{ steps.setup.outcome }}" -ne "success") { $st = "skip"; $msg = "WSL1 could not be set up on this runner" } + else { $st = "skip"; $msg = "best effort on WSL1 (design part 2 §17.1): the import or the run failed; see the job log" } + ('{"test":"' + $name + '","status":"' + $st + '","ms":0,"message":"' + $msg + '"}') | Set-Content "$dir/scripts.ndjson" + + - name: Attach the run + if: always() + uses: actions/upload-artifact@v4 + with: + name: xdev-linux-wsl + path: target/xdev/ + + # One report for the Linux lanes, in the step summary. + report: + needs: [build-and-test, e2e, isolation-fix, arch-package, distro, wsl-import] + if: always() + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Fetch the runs + uses: actions/download-artifact@v4 + with: + pattern: xdev-linux-* + path: runs + + - name: Restore main-branch execution history + uses: actions/cache/restore@v4 + with: + path: target/xdev/history + key: xdev-history-v1-linux-${{ github.sha }} + restore-keys: xdev-history-v1-linux- + + - name: Report + run: | + xdev="$(find runs -name xdev -type f | head -1)" + [[ -n "$xdev" ]] || { echo "no xdev binary: the build job failed before building it"; exit 1; } + chmod +x "$xdev" + args=() + for d in $(find runs -name lane.json -exec dirname {} \;); do args+=(--in "$d"); done + # --fail-uncovered: a `required` ID in tests/requirements.toml with + # no test, or a test naming an ID the file does not declare, fails. + history=() + if [[ -f target/xdev/history/trend.json ]]; then history+=(--trend target/xdev/history/trend.json); fi + mkdir -p target/xdev/current-report + status=0 + "$xdev" report "${args[@]}" "${history[@]}" --summary \ + --requirements tests/requirements.toml --fail-uncovered \ + --write target/xdev/current-report --timings-out target/xdev/current-report/timings.json || status=$? + python3 - <<'PY' || status=1 + import json + from pathlib import Path + report = json.loads(Path('target/xdev/current-report/report.json').read_text()) + # Windows quoting has its own mandatory Windows gate; WSL1 is the + # design's explicit best-effort lane and remains visible as such. + elsewhere = {'SUBOS-CMD-WIN-QUOTING', 'EXPORT-WSL'} + missing = [name for name, evidence in report['requirements'].items() + if name not in elsewhere and not evidence['passed_by']] + assert not missing, f'required behavior lacks passing execution evidence: {missing}' + PY + if [[ -f target/xdev/current-report/trend.json && -f target/xdev/current-report/timings.json ]]; then + mkdir -p target/xdev/history + cp target/xdev/current-report/{trend,timings}.json target/xdev/history/ + fi + exit "$status" + + - name: Attach reports and retained trends + if: always() + uses: actions/upload-artifact@v4 + with: + name: xdev-linux-report + path: target/xdev/current-report + retention-days: 90 + if-no-files-found: ignore + + - name: Retain main-branch trends for subsequent plans + if: always() && github.event_name == 'push' && github.ref == 'refs/heads/main' && hashFiles('target/xdev/history/trend.json') != '' + uses: actions/cache/save@v4 + with: + path: target/xdev/history + key: xdev-history-v1-linux-${{ github.sha }} unit-asan: + # Off the PR critical path (design §24.5): it is the slowest job (~27 min, + # most of it the instrumented build) and gates nothing a PR changes more + # often than main does. It runs on every push to main and release/**, + # and on a PR that carries the `ci:asan` label. A failure on main is a + # regression to revert or fix at once. + if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'ci:asan') + # # Sanitizer profile for the unit suite (mcpp.toml `[profile.asan]`, # issue #433: a use-after-free reached three releases undetected because # nothing ever ran the tests instrumented). Measured locally 2026-09-12: @@ -581,6 +1101,16 @@ jobs: mcpp self version 2>&1 | head -3 || true xlings --version 2>&1 | head -2 || true + # E2E-00: a fresh mcpp home builds xlings for the musl target. It + # used to run on every PR in the e2e workflow; the PR build job and the + # release build above already prove the same build with the same mcpp, + # so it now runs where the instrumented build runs: on main. + - name: "E2E-00: mcpp builds xlings from source (fresh mcpp home)" + if: github.event_name == 'push' + run: | + bash tests/e2e/prepare_fixture_index.sh + bash tests/e2e/mcpp_build_xlings_test.sh + - name: Sanitizer verdict if: always() run: | diff --git a/.github/workflows/xlings-ci-macos.yml b/.github/workflows/xlings-ci-macos.yml index 19634d6b0..5fe0817dd 100644 --- a/.github/workflows/xlings-ci-macos.yml +++ b/.github/workflows/xlings-ci-macos.yml @@ -9,6 +9,12 @@ on: pull_request: branches: [main, master, 'release/**'] +# A newer push to the same PR supersedes the run in flight; main and release +# branches keep every run (each one is a commit that landed). +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: GIT_TERMINAL_PROMPT: 0 MACOSX_DEPLOYMENT_TARGET: "14.0" @@ -24,8 +30,8 @@ env: # so treat that variable as documentation, not as a lock, until it is # either honoured everywhere or removed. BOOTSTRAP_XLINGS_VERSION: v2026.8.27.5 - # Must contain the mcpp version pinned in .xlings.json (2026.9.28.3). - XIM_PKGINDEX_REF: 7b9bf4368663f50eb3e736a41009d0eaf2051180 + # Must contain the mcpp version pinned in .xlings.json (2026.10.5.3). + XIM_PKGINDEX_REF: ebf1fbb3417ec513b923279044433887b3af8850 jobs: build-and-test: @@ -270,28 +276,26 @@ jobs: # ── Phase 1.5: Unit Tests ──────────────────────────────────── - name: Build and run unit tests + env: + XDEV_LANE: macos-unit + XDEV_LANE_CAPS: macos,posix,pty,xlings-bin,sandbox + XDEV_SUMMARY: "1" run: | mcpp build - mcpp test || { - status=$? - echo "::group::mcpp build diagnostics" - - ninja_bin="$(command -v ninja || true)" - if [[ -z "$ninja_bin" ]]; then - ninja_bin="$(find "$HOME/.mcpp" "$XLINGS_HOME" -name ninja -type f -perm -111 2>/dev/null | head -1 || true)" - fi - if [[ -z "$ninja_bin" ]]; then - echo "ninja not found" - else - while IFS= read -r build_file; do - build_dir="$(dirname "$build_file")" - echo "\$ $ninja_bin -C $build_dir -v" - "$ninja_bin" -C "$build_dir" -v || true - done < <(find target -name build.ninja -type f | sort) - fi - echo "::endgroup::" - exit "$status" - } + mcpp build -p xdev + # A member's binary lands under the root's target/ or the member's + # own, depending on the mcpp version. + xdev="$(find target apps/xdev -name xdev -type f -perm -111 2>/dev/null | head -1)" + [[ -n "$xdev" ]] || { echo "::error::mcpp build -p xdev produced no xdev"; find . -name 'xdev*' -not -path './.git/*' | head; exit 1; } + xdev="$GITHUB_WORKSPACE/$xdev" + export XDEV_BIN="$xdev" + mkdir -p target/xdev/macos-xdev + printf '%s\n' '{"name":"macos-xdev","platform":"macosx","probes":{}}' > target/xdev/macos-xdev/lane.json + XTEST_META_OUT="$GITHUB_WORKSPACE/target/xdev/macos-xdev/meta.ndjson" \ + XTEST_RESULTS_OUT="$GITHUB_WORKSPACE/target/xdev/macos-xdev/cases.ndjson" \ + mcpp test -p xdev + mcpp test --no-run --message-format json + "$xdev" test --no-build --out target/xdev/macos-unit # A separate step, `if: failure()`, and not an `|| { ... }` on one # command. @@ -363,9 +367,6 @@ jobs: chmod +x ./tools/macos_release.sh SKIP_NETWORK_VERIFY=1 ./tools/macos_release.sh - - name: SubOS sandbox command contract - run: bash tests/e2e/subos_cmd_contract_test.sh - - name: Platform core contract # 2026-09-12 robustness/usability round: bash e2e contracts that # don't need a whole subos rebuild to be meaningful on macOS. diff --git a/.github/workflows/xlings-ci-windows.yml b/.github/workflows/xlings-ci-windows.yml index 5f5dd45bf..08407020a 100644 --- a/.github/workflows/xlings-ci-windows.yml +++ b/.github/workflows/xlings-ci-windows.yml @@ -9,6 +9,12 @@ on: pull_request: branches: [main, master, 'release/**'] +# A newer push to the same PR supersedes the run in flight; main and release +# branches keep every run (each one is a commit that landed). +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: GIT_TERMINAL_PROMPT: 0 # CI runs on github.com; force GLOBAL mirror so xim doesn't try gitee @@ -23,8 +29,8 @@ env: # so treat that variable as documentation, not as a lock, until it is # either honoured everywhere or removed. BOOTSTRAP_XLINGS_VERSION: v2026.8.27.5 - # Must contain the mcpp version pinned in .xlings.json (2026.9.28.3). - XIM_PKGINDEX_REF: 7b9bf4368663f50eb3e736a41009d0eaf2051180 + # Must contain the mcpp version pinned in .xlings.json (2026.10.5.3). + XIM_PKGINDEX_REF: ebf1fbb3417ec513b923279044433887b3af8850 jobs: build-and-test: @@ -267,31 +273,35 @@ jobs: id: unit continue-on-error: true shell: pwsh + env: + XDEV_LANE: windows-unit + XDEV_LANE_CAPS: windows,xlings-bin,sandbox + XDEV_SUMMARY: "1" run: | mcpp build if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - mcpp test - if ($LASTEXITCODE -ne 0) { - $status = $LASTEXITCODE - Write-Host "::group::mcpp build diagnostics" - $ninjaCmd = Get-Command ninja -ErrorAction SilentlyContinue - $ninja = if ($ninjaCmd) { $ninjaCmd.Source } else { "" } - if (-not $ninja) { - $ninjaFile = Get-ChildItem -Path "$env:USERPROFILE\.mcpp", "$env:XLINGS_HOME" -Filter ninja.exe -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($ninjaFile) { $ninja = $ninjaFile.FullName } - } - if (-not $ninja) { - Write-Host "ninja not found" - } else { - $buildFiles = Get-ChildItem -Path target -Filter build.ninja -Recurse -ErrorAction SilentlyContinue - foreach ($buildFile in $buildFiles) { - $buildDir = $buildFile.DirectoryName - Write-Host "`$ $ninja -C $buildDir -v" - & $ninja -C $buildDir -v - } - } - Write-Host "::endgroup::" - exit $status + mcpp build -p xdev + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # A member's binary lands under the root's target\ or the member's + # own, depending on the mcpp version. + $xdev = Get-ChildItem target, apps\xdev -Recurse -Filter xdev.exe -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $xdev) { Write-Host "::error::mcpp build -p xdev produced no xdev.exe under target/"; exit 1 } + $env:XDEV_BIN = $xdev.FullName + New-Item -ItemType Directory -Force target/xdev/windows-xdev | Out-Null + '{"name":"windows-xdev","platform":"windows","probes":{}}' | Set-Content target/xdev/windows-xdev/lane.json + $env:XTEST_META_OUT = "$env:GITHUB_WORKSPACE/target/xdev/windows-xdev/meta.ndjson" + $env:XTEST_RESULTS_OUT = "$env:GITHUB_WORKSPACE/target/xdev/windows-xdev/cases.ndjson" + mcpp test -p xdev + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Remove-Item Env:XTEST_META_OUT, Env:XTEST_RESULTS_OUT + mcpp test --no-run --message-format json + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & $xdev.FullName test --no-build --out target/xdev/windows-unit + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $quoting = @(Get-Content target/xdev/windows-unit/cases.ndjson | ForEach-Object { $_ | ConvertFrom-Json } | + Where-Object test -eq 'SubosCmdContract.ExitCodeHomeAndCmdQuotingOnWindows') + if ($quoting.Count -ne 1 -or $quoting[0].status -ne 'pass') { + throw 'SUBOS-CMD-WIN-QUOTING must have a passing Windows execution' } # If the BUILD failed there is nothing to run E2E against, and every @@ -324,10 +334,6 @@ jobs: shell: pwsh run: '& "$env:GITHUB_WORKSPACE\tests\e2e\ansi_code_page_directory_test.ps1"' - - name: SubOS sandbox command contract - shell: pwsh - run: '& "$env:GITHUB_WORKSPACE\tests\e2e\subos_cmd_contract_test.ps1"' - - name: Fresh XLINGS_HOME first install shell: pwsh run: '& "$env:GITHUB_WORKSPACE\tests\e2e\fresh_xlings_home_install_test.ps1"' @@ -381,6 +387,11 @@ jobs: run: | & "$env:GITHUB_WORKSPACE\tests\e2e\release_subos_smoke_test.ps1" "build\release.zip" + - name: "E2E-08: SubOS on the wsl2 carrier (or refused with its route)" + shell: pwsh + run: | + & "$env:GITHUB_WORKSPACE\tests\e2e\windows_carrier_wsl2_test.ps1" "build\release.zip" + - name: "E2E-05: Project Shim Mirror" shell: pwsh run: | diff --git a/.xlings.json b/.xlings.json index f5a3bfb90..79c7009fb 100644 --- a/.xlings.json +++ b/.xlings.json @@ -3,6 +3,6 @@ "projectScope": false, "mirror": "GLOBAL", "workspace": { - "mcpp": "2026.9.28.3" + "mcpp": "2026.10.5.3" } } diff --git a/AGENTS.md b/AGENTS.md index c66d6d44d..6e128b85b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,47 +14,51 @@ Core capabilities: ## Repository Structure ``` -src/ -├── main.cpp # Entry point -├── cli.cppm # CLI dispatch (positional + flag parsing) -├── interface.cppm # NDJSON programmatic interface (protocol v1.1) -├── core/ -│ ├── config.cppm # 3-layer config (global → subos → project) -│ ├── subos.cppm # SubOS lifecycle (create/use/fork/remove/stop) -│ ├── subos/keeper.cppm # Auto-keeper primitives (Linux namespace reuse) -│ ├── xself.cppm # Self-install/update -│ ├── xself/ # Self-management submodules -│ ├── xim/ # Package management subsystem -│ │ ├── installer.cppm # Install orchestration (type dispatch) -│ │ ├── resolver.cppm # DAG dependency resolution -│ │ ├── downloader.cppm # Parallel download + SHA256 -│ │ ├── index.cppm # Package index + cache -│ │ ├── catalog.cppm # Multi-repo catalog loading -│ │ └── libxpkg/types/ # Per-type handlers: -│ │ ├── type.cppm # PlanNode, enums, shared types -│ │ ├── script.cppm # type="script" default hooks -│ │ └── subos.cppm # type="subos" default hooks -│ └── xvm/ # Version management -│ ├── db.cppm # VersionDB CRUD + JSON -│ ├── shim.cppm # Multicall shim dispatch -│ └── commands.cppm # xvm commands (use, list, register) -├── platform.cppm # Cross-platform abstractions -├── platform/ # Platform implementations -├── libs/ # Vendored libs (json, tinyhttps) -└── ui/ # TUI (ftxui-based) +modules/ # packages linked into xlings, one per responsibility +├── libs/ json, sha256, tinyhttps nothing of xlings +├── runtime/ cancellation, guard, observe shared by core and SubOS core +├── platform/ the OS boundary: every system header and call +│ └── src/ platform.cppm (declares, includes nothing); os/ (linux, macos, +│ windows, unix), process/ (spawn, worker, stream), net/, +│ isolation/ (ns, Landlock, seccomp, mounts), fs/, triple/ +├── ui/ theme, i18n +├── store/ what keeps a payload alive: GC roots (retained generations), the retained ledger +├── subos/ the SubOS core +│ └── src/ model/ (model, manifest, roles, ports, userdata), policy/, intent/ (policy -> Intent), +│ sandbox/ (spec types, caps, tools table, elevation, network, gpu), +│ views/root/ (root projection, generations, library cache), +│ session/ (supervisor + session-init, broker) +├── confine/ sandbox implementations (linux-bwrap/proot/landlock, home-redirect, fake), +│ the selector, the compile skeleton, provider argv, the platform matrix +├── carrier/ where a SubOS runs: local, wsl2, vz; choose(); terminal/control over NDJSON +└── testkit/ C++ e2e test library (dev-dependency) +luban/ # only what a SubOS that is a MACHINE needs (luban.*) +└── src/ boot (boot entries), stage0 (first process), machine (/etc, sysusers) +apps/ +├── gui/ GUI library half +├── luban-init/ stage-0 as its own binary (links luban + modules/, never src/) +└── xdev/ dev tool: tests, report, requirement map (not shipped) +src/ # not yet separated: core, cli, ui, runtime +├── main.cpp # entry point +├── cli.cpp, cli/spec.cpp # CLI dispatch and the command spec +├── interface.cpp # NDJSON programmatic interface +└── core/ + ├── config.cppm # 3-layer config (global -> subos -> project) + ├── home/ # HomeContext: deployment mode, layout, system config + ├── subos/cmd.cpp # `subos` commands (new/use/exec/start/config/...) + ├── subos/sandbox.cpp # adapter: policy + caps -> spec -> session + ├── subos/root/ # a workspace -> its root projection; rollback/boot/export + ├── subos/domain/ # prefix domains, their producer, layers, evidence + ├── subos/carrier_image.cpp # the image a guest carrier imports + ├── xself/ # self install/update/doctor + ├── xim/ # package management (installer, resolver, index...) + └── xvm/ # version management, shims tests/ -├── e2e/ # End-to-end shell tests -│ ├── project_test_lib.sh # Shared helpers (find_xlings_bin, run_xlings) -│ ├── fixtures/ # Test fixture packages -│ └── subos_xpkg_*.sh # SubOS-as-xpkg e2e tests -└── (unit tests via `mcpp test`) - -.agents/ -├── docs/ # Agent working docs (see .agents/docs/README.md) -├── skills/ # Agent skills (this section) -├── plans/ # Implementation plans -└── tasks/ # Task tracking +├── unit/, e2e/ # C++ tests (`mcpp test`, XTEST + testkit) +├── e2e/*.sh # legacy shell e2e (tests/suites.toml) +├── requirements.toml # requirement IDs every test `covers` +└── suites.toml # legacy suites run through xdev ``` ## Build System @@ -70,11 +74,20 @@ xlings use gcc@16.1.0 # switch to glibc-linked dev toolchain mcpp build # dev binary → target///bin/xlings # Test: -mcpp test # unit tests +mcpp test # unit tests + C++ e2e tests (tests/**/test_*.cpp) XLINGS_BIN=$(find target -path '*/bin/xlings' -type f | head -1) \ - bash tests/e2e/.sh # e2e tests + bash tests/e2e/.sh # legacy e2e scripts + +# xdev (dev tool, not shipped): one report for C++ tests and legacy suites +mcpp build -p xdev +XDEV=$(find target -path '*/bin/xdev/xdev' | head -1) +$XDEV doctor # which capabilities this machine can test +$XDEV test [pattern] [--suite contract-scripts|lint|e2e-shell] ``` +New tests are C++ with `modules/testkit` (`XTEST`, `Home::isolated`); see +`tests/README.md`. + For release packaging (static binary): - Linux: `tools/linux_release.sh` (musl-gcc static) - macOS: `tools/macos_release.sh` (LLVM) @@ -84,7 +97,7 @@ For release packaging (static binary): ### CLI argparse -Manual positional parsing in each subcommand's `run()` function (see `subos.cppm` line ~1700). Pattern: +Manual positional parsing in each subcommand's `run()` function (see `src/core/subos/cmd.cpp`). Pattern: ```cpp for (int i = 3; i < argc; ++i) { std::string a = argv[i]; @@ -94,6 +107,90 @@ for (int i = 3; i < argc; ++i) { } ``` +### Platform code lives in `xlings.platform` + +**System headers and system calls are in `modules/platform` and nowhere +else** (`tools/lint_platform_headers.sh`, in the lint suite). What another +module needs from the OS -- a fork, a socket that carries descriptors, a +Landlock fence, a file's owner -- is a function there, declared on every +platform and reporting "unavailable" where it does not exist; the caller +holds the logic and no `#include`. Two exceptions, both outside the +product: `modules/testkit` (the harness does not share the product's failure +modes) and `tests/` (a test may call the kernel to check what it does). + +**Platform selection is `if constexpr`, not `#if`.** Branch with +`if constexpr (platform::is_windows)` (also `is_linux`, `is_macos`, +`is_posix`; and the values `platform::exe_suffix`, `platform::null_device`): +every build then compiles every branch, so the Linux CI catches a typo in the +Windows one, and a branch that only one CI compiles is the branch that breaks. +A function that only one OS can implement is declared on every platform in +`modules/platform` and reports "unavailable" elsewhere -- so the caller can +use `if constexpr` instead of hiding the call behind `#if`. + +`#if` on `_WIN32` / `__linux__` / `__APPLE__` / `_MSC_VER` outside +`modules/platform` needs `// platform-if-ok: ` on its line +(`tools/lint_platform_branches.sh`). The legitimate reasons are few and say +so: a declaration that differs per COMPILER (the GCC/MSVC module special +members in `xvm/types.cppm`), or a module below `xlings.platform` that cannot +import its constants (`runtime/observe.cpp`). Inside `modules/platform`, +`#if` is for headers and system calls; logic still prefers `if constexpr`. + +**Headers stay in the smallest unit that uses them.** A system header is +included by the implementation unit (`.cpp`) that makes the call, never by an +interface unit (`.cppm`): the primary interface `platform.cppm` includes +nothing, and a partition's interface includes at most the C++-wrapped C +library (`` for `stdout`) its declarations need. A type a declaration +must name opaquely (`HANDLE`) is stored as `void*` with the sentinel spelled +out, as `ProcessHandle` and `FileLock` do. The lint checks both rules; testkit +and `tests/` are outside the product and carry the same constants under the +same names. + +### SubOS = content x view x carrier (SubOS design part 3) + +One noun: a SubOS is CONTENT (workspace, home, policy). How a process sees it +is its VIEW (overlay, sandbox, root, machine); where it runs is its CARRIER +(local, wsl2, vz, native). A payload's target ABI decides the carrier -- a +Linux SubOS on Windows runs in a WSL2 distribution of the home's own, which +the user never enters (`xlings subos ...` is forwarded through `wsl.exe -d + -u root --exec /xlings/bin/xlings __carrier-env ... -- `). +`instance.json`'s `carrier` / `abi` record it (absent = local/native). +`.agents/docs/2026-10-09-subos-architecture-design-part3.md`. + +Rules that came with it: + +* **External programs come from one table** (`xlings.subos.tools`: payload, + then the machine's paths it names, never another home's shim), and + administrator rights through one door (`xlings.subos.elevation`, recorded + in `logs/elevation.ndjson`). `tools/lint_tool_resolve.sh` fails a + `std::system`, an argv that starts with a tool's literal name, or an + administrator argv outside `elevation::run`. +* **Package layers import downward** (`tools/lint_layer_deps.sh`): `luban/` + -> `modules/`; `modules/` never imports `luban.*` or the frontend. A binary + that must not carry the frontend is its OWN package (`apps/luban-init`): a + package's binaries link every source of that package. +* **A retained generation is a GC root.** `remove` completes and keeps the + payload in `/retained.json` while a generation links into it; a prune + releases it. A generation's switch is durable and checked by its inventory + (`root.gen/..inventory`); never edit a placed generation. +* **The sandbox a policy compiles to is a golden** (`tests/fixtures/intent-eq`, + INTENT-EQ). A change to it is a change to what a sandbox IS: regenerate with + `XLINGS_INTENT_GOLDEN_WRITE=1` only on purpose, and say so in the commit. + +### Core and interaction surfaces are separate (2026.10, #640) + +Who is reading the output (a person or an agent) and how it is drawn (cli, +tui, `--json`, the NDJSON interface) are two independent axes, and neither +belongs in core code. Core returns structured results and asks questions only +through the `Asker` port (`xlings.guard`); it never branches on the audience +and never assembles user-facing text. The audience is DECLARED, never +inferred: `--agent` or `XLINGS_AGENT_MODE=1` (`0` turns it off). In agent mode +a command never waits for input -- a question it cannot answer becomes a +structured error with the candidates and the exact flag that answers it. + +The SubOS design this comes from, and the module layout it implies +(`modules/runtime`, `modules/subos`, `src/core/home/`), is +`.agents/docs/2026-10-05-subos-architecture-design.md`. + ### Type-specific install dispatch `installer.cppm` checks `node.pkgType`: @@ -229,6 +326,28 @@ of the stale files. A home an older client upgraded needs that command once (`& "$env:USERPROFILE\.xlings\bin\xlings.exe" self init` also works): new code never runs there on its own. +### A SubOS can be a root (SubOS design part 2) + +A SubOS's content (workspace, home, policy) is one thing and how a process +sees it is another: the PATH overlay, the sandbox view, and the **root** -- +`subos new --rootfs`, where `/usr` is a generation of links into payloads +and `/etc`, `/home`, `/var` are the root's own machine state. The design is +`.agents/docs/2026-10-06-subos-architecture-design-part2.md`; what to keep +in mind when changing code near it: + +* **Every workspace change of a rootfs SubOS is a new generation**, made by + `xself::sync_shim_tables` (the one writer). A generation is switched with + one rename and never edited; rollback moves the pointer. +* **What may be done to a SubOS is `roles::check(op, kind, role)`** -- a + running host is not removed, a boot entry is not removed, a view has no + root to export. A new command that can hurt a running system asks it. +* **Stage-0 (`/boot/xlings-init`) runs before anything reads a home**: + /proc is not mounted yet. Nothing on its path may use Config. +* Deployment forms: S (a package manager's `/usr/bin/xlings`; a home links + its entry to it on first write), M (`sudo xlings install --system` into a + root-owned `/xlings`, after each user's own on PATH), R (a machine whose + userland is a SubOS: `/etc/xlings/root.json` names its system home). + ### SubOS user data **A SubOS's home is user data. Only a deletion the user initiated may remove diff --git a/README.md b/README.md index 21baf5b01..7fa078c17 100644 --- a/README.md +++ b/README.md @@ -20,178 +20,101 @@ Used by: MCPP · upcoming Luban Linux

-## Why xlings? - -One tool to install any version of anything, run it rootless, and isolate it OS-like — across Linux, macOS, and Windows. - -"Supported" is three separate questions, so here are three columns. A platform -xlings runs on is not automatically a platform every package has an artifact -for, and neither of those says anything about how much isolation `--sandbox` -can actually give you. - -| Platform | xlings release | Package coverage | `subos --sandbox` isolates | -|---|---|---|---| -| Linux x86_64 | ✅ published | full | the filesystem (bwrap / proot) | -| Linux aarch64 | ✅ published | partial — many recipes only publish an x86_64 artifact | the filesystem, once a backend is available for the arch | -| macOS 14+ arm64 | ✅ published | partial — some recipes ship x86_64 only | **only `$HOME`** | -| Windows x86_64 | ✅ published | partial | **only `%USERPROFILE%`** | - -Package coverage is a property of the recipe, not of xlings: `xlings install` -refuses a target only when the recipe enumerates its architectures and yours -is not among them. When a recipe ships a single artifact it is installed and -you are told the recipe could not confirm your architecture. - -> **macOS and Windows `--sandbox` is not a security boundary.** It redirects -> the home directory so tools write their dotfiles somewhere isolated. It does -> not contain the filesystem, the network or processes. Use an OS sandbox or a -> VM to run code you do not trust. - -→ [How xlings compares to apt / nix / docker](docs/comparison.md) - -## Core capabilities - -1. **Universal package infrastructure** — binary / script / config / subos / tutorial, all as xpkg -2. **Multi-version coexistence** — N versions side-by-side; version-view + ref-counting (N envs ≈ 1× storage) -3. **3-level SubOS isolation** — shell (env switch) / FS (bwrap/proot, rootless) / image (ext4, root) -4. **Decentralized package index** — official + 3rd-party + self-hosted; resource servers for binary mirrors -5. **JSON event interface** — `xlings interface` (NDJSON) for AI agents, CI, and 3rd-party tooling -6. **Self-diagnosing** — `xlings self doctor --fix` checks the four state layers and repairs them in one run +One tool to install any version of anything, run it without root, and isolate +it like an OS of its own — on Linux, macOS and Windows. ## Quick Start -**Install — Linux / macOS** - ```bash +# Linux / macOS curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash ``` -**Install — Windows (PowerShell)** - ```powershell +# Windows (PowerShell) irm https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.ps1 | iex ``` ```bash xlings install gcc@16 node@24 cmake # install (version optional) -xlings use gcc@16 # switch active version +xlings use gcc@16 # switch the active version xlings search python # search packages -xlings list # list installed -``` - -### Let an AI agent install & explain it - -Paste this to any AI agent (Claude, Codex, OpenCode, …): - -``` -Read the README of https://github.com/openxlings/xlings and install xlings on my machine. -- Linux/macOS: curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -- Windows: irm https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.ps1 | iex -Then run `xlings agent usage` and follow it to show me how to use xlings. +xlings list # what is installed ``` -xlings ships a built-in agent guide — once installed, any agent can self-learn the full workflow: +Using an AI agent? Tell it to run `xlings agent usage` — a complete guide +written for agents ships with xlings. -```bash -xlings agent # overview + skill list -xlings agent usage # complete usage guide written for LLM agents -``` +## What it does -## Usage scenarios +| Area | What you get | +|---|---| +| **Packages** | binaries, scripts, configs, SubOS bases — all one format (xpkg), from official, third-party or self-hosted indexes | +| **Versions** | any number side by side; N environments share one copy of each payload | +| **SubOS** | named environments: their own tools, home and policy — from a PATH switch to a rootless sandbox with a private network | +| **Agents** | `xlings subos exec`, exit codes that mean something, `XLINGS_AGENT_MODE=1`, and an NDJSON interface (`xlings interface`) | +| **Repair** | `xlings self doctor --fix` checks its own state and repairs it in one run | -### 🛠 Multi-version toolchains, one command everywhere +## Scenarios -Install any version of anything and keep several side by side — switch instantly, no conflicts, no sudo. The same commands work on Linux, macOS, and Windows. +**Several toolchains, no conflicts** — [guide](docs/quick-start/multi-version.md) ```bash -xlings install gcc@16 gcc@11 node@24 cmake -xlings use gcc@11 # switch back anytime — both stay installed +xlings install gcc@16 gcc@11 node@24 +xlings use gcc@11 # switch back any time; both stay installed ``` -→ [Multi-version guide](docs/quick-start/multi-version.md) - -### 📦 Reproducible project environments, consistent across OSes - -Commit a `.xlings.json` that declares per-platform versions. Each project gets its **own isolated SubOS**, so teammates and CI — on any distro or OS — land in the exact same environment, without touching the host or other projects. +**One environment for the whole team** — [guide](docs/quick-start/project-env.md) ```json -{ - "workspace": { - "xmake": "3.0.7", - "gcc": { "linux": "16.1.0" }, - "llvm": { "macosx": "20.1.7", "windows": "19.1.0" } - } -} +{ "workspace": { "xmake": "3.0.7", "gcc": { "linux": "16.1.0" }, "llvm": { "macosx": "20.1.7" } } } ``` ```bash -cd my-project/ # entering the dir activates the project SubOS -xlings install # installs the declared versions, project-local +cd my-project/ && xlings install # the project's own SubOS, the declared versions ``` -→ [Project env guide](docs/quick-start/project-env.md) - -### 🤖 Agents & untrusted code in an isolated SubOS - -On Linux, run agents inside a rootless filesystem-isolated SubOS. On macOS and -Windows, SubOS redirects the home directory but does not contain untrusted -code; use an OS sandbox or VM when that boundary is required. +**Agents and untrusted code in a sandbox** — [guide](docs/quick-start/subos-and-agent.md) ```bash -xlings subos new agent-ws --from subos:dev-env@latest -xlings subos use agent-ws --sandbox # enter the isolated world -xlings subos use agent-ws --sandbox --cmd "python run.py" # or one-shot exec +xlings subos new agent-ws --sandbox=private # isolation is declared once, here +xlings subos exec agent-ws -- python run.py # every entry is that sandbox; its own exit code +xlings subos use agent-ws # or a shell inside -- no flag needed ``` -→ [SubOS & Agent guide](docs/quick-start/subos-and-agent.md) +| Platform | Release | `--sandbox` isolates | +|---|---|---| +| Linux x86_64 / aarch64 | ✅ | filesystem, processes, network, identity (bwrap; on aarch64, once a bwrap is available for it) | +| macOS 14+ arm64 | ✅ | **only `$HOME`** | +| Windows x86_64 | ✅ | **only `%USERPROFILE%`** | -### 🩺 Upgrade and repair, without hand-editing state +> On macOS and Windows `--sandbox` is not a security boundary: use an OS +> sandbox or a VM for code you do not trust. `xlings subos status ` +> shows what a SubOS asks for and what this machine gives it. -Upgrade the client, then let xlings check its own four state layers — workspace, -version database, shims, payloads — and repair what it finds. One run; the report -you read is the state *after* the repairs, and every command it prints is one you -can paste. +**Upgrade and repair** — [guide](docs/quick-start/self-management.md) ```bash -xlings self update # upgrade xlings itself -xlings self doctor # read-only check (exit 0 = healthy) -xlings self doctor --fix --dry-run # preview the repairs -xlings self doctor --fix # repair +xlings self update +xlings self doctor --fix ``` -→ [Self-management guide](docs/quick-start/self-management.md) - ## Documentation -Guides, design notes, and specs live in [`docs/`](docs/). - -| Area | Docs | -|------|------| -| **Get started** | [Multi-version](docs/quick-start/multi-version.md) · [Project env](docs/quick-start/project-env.md) · [SubOS & Agent](docs/quick-start/subos-and-agent.md) · [Custom index](docs/quick-start/custom-index.md) · [Self-management](docs/quick-start/self-management.md) · [Build from source](docs/build-from-source.md) | -| **Architecture** | [System overview](docs/architecture/overview.md) | -| **Design** | [SubOS-as-XPKG](docs/design/subos-as-xpkg.md) · [xvm versioning](docs/design/xvm-version-management.md) · [SubOS isolation](docs/design/subos-isolation.md) · [Index ecosystem](docs/design/package-index-ecosystem.md) · [Interface protocol](docs/design/interface-protocol.md) | -| **Spec** | [xpkg manifest v1](docs/spec/xpkg-manifest-v1.md) · [.xlings.json schema](docs/spec/xlings-json-schema.md) · [Interface NDJSON v1](docs/spec/interface-ndjson-v1.md) | - -## Ecosystem - -| Project | Role | -|---------|------| -| [MCPP](https://github.com/mcpp-community/mcpp) | Modern C++ build toolchain ecosystem — distributed through xlings | -| **Luban Linux** | Upcoming Linux distribution using xlings as system-level package manager *(link when published)* | -| [xim-pkgindex](https://github.com/openxlings/xim-pkgindex) | Official package index — 60+ packages and growing | - -## Community - -- **Forum**: [forum.d2learn.org/category/9/xlings](https://forum.d2learn.org/category/9/xlings) -- **QQ Groups**: 167535744 / 1006282943 -- **Issues**: [github.com/openxlings/xlings/issues](https://github.com/openxlings/xlings/issues) - -### Contributing +| Topic | Docs | +|---|---| +| **Guides** | [Multi-version](docs/quick-start/multi-version.md) · [Project env](docs/quick-start/project-env.md) · [SubOS & Agent](docs/quick-start/subos-and-agent.md) · [Custom index](docs/quick-start/custom-index.md) · [Self-management](docs/quick-start/self-management.md) · [Build from source](docs/build-from-source.md) | +| **Design** | [Architecture](docs/architecture/overview.md) · [SubOS isolation](docs/design/subos-isolation.md) · [SubOS-as-XPKG](docs/design/subos-as-xpkg.md) · [xvm versioning](docs/design/xvm-version-management.md) · [Index ecosystem](docs/design/package-index-ecosystem.md) · [Interface protocol](docs/design/interface-protocol.md) | +| **Spec** | [xpkg manifest v1](docs/spec/xpkg-manifest-v1.md) · [.xlings.json schema](docs/spec/xlings-json-schema.md) · [Interface NDJSON v1](docs/spec/interface-ndjson-v1.md) · [Commands](docs/generated/command-reference.md) | +| **Compare** | [xlings vs apt / nix / docker](docs/comparison.md) | -- [Issue handling & bug fixing](https://xlings.d2learn.org/en/documents/community/contribute/issues.html) -- [Adding new packages](https://xlings.d2learn.org/en/documents/community/contribute/add-xpkg.html) -- [Documentation](https://xlings.d2learn.org/en/documents/community/contribute/documentation.html) +## Ecosystem and community -**Contributors** +- [MCPP](https://github.com/mcpp-community/mcpp) — modern C++ toolchain, distributed through xlings +- [xim-pkgindex](https://github.com/openxlings/xim-pkgindex) — the official package index +- **Luban Linux** — upcoming distribution using xlings as its system package manager +- [Forum](https://forum.d2learn.org/category/9/xlings) · QQ 167535744 / 1006282943 · [Issues](https://github.com/openxlings/xlings/issues) +- Contributing: [issues](https://xlings.d2learn.org/en/documents/community/contribute/issues.html) · [packages](https://xlings.d2learn.org/en/documents/community/contribute/add-xpkg.html) · [docs](https://xlings.d2learn.org/en/documents/community/contribute/documentation.html) diff --git a/README.zh.md b/README.zh.md index 7489b02bb..96e7c6980 100644 --- a/README.zh.md +++ b/README.zh.md @@ -20,171 +20,99 @@ 使用者: MCPP · 即将推出的 Luban Linux

-## 为什么选 xlings? - -一个工具,安装任意版本的任意软件,无需 root 运行,并提供 OS 级的环境隔离 —— 在 Linux / macOS / Windows 上统一。 - -"支持"是三个不同的问题,所以拆成三列。xlings 能跑的平台,不等于每个包都有 -对应架构的产物;而这两件事都不说明 `--sandbox` 到底能隔离到什么程度。 - -| 平台 | xlings 发布产物 | 包生态覆盖 | `subos --sandbox` 隔离的是 | -|---|---|---|---| -| Linux x86_64 | ✅ 有 | 完整 | 文件系统(bwrap / proot) | -| Linux aarch64 | ✅ 有 | 部分 —— 不少配方只发布 x86_64 产物 | 文件系统,前提是该架构有可用后端 | -| macOS 14+ arm64 | ✅ 有 | 部分 —— 部分配方只发布 x86_64 | **仅 `$HOME`** | -| Windows x86_64 | ✅ 有 | 部分 | **仅 `%USERPROFILE%`** | - -包生态覆盖是配方的属性,不是 xlings 的:只有当配方**逐架构列出**了自己的产物、 -而其中没有你的架构时,`xlings install` 才会拒绝;配方只提供单一产物时照常安装, -并提示该配方无法确认你的架构。 - -> **macOS 与 Windows 的 `--sandbox` 不是安全边界。** 它重定向 home 目录,让工具 -> 把 dotfile 写到隔离位置,但不隔离文件系统、网络或进程。要运行不受信任的代码, -> 请使用操作系统级沙箱或虚拟机。 - -→ [xlings 与 apt / nix / docker 对比](docs/comparison.md) - -## 核心能力 - -1. **通用包管理基础设施** —— binary / script / config / subos / tutorial 统统是 xpkg -2. **多版本共存** —— 同一工具 N 个版本并存;版本视图 + 引用计数(N 个环境 ≈ 1 份存储) -3. **三级 SubOS 隔离** —— shell(env 切换)/ FS(bwrap/proot,无需 root)/ image(ext4,需 root) -4. **去中心化包索引** —— 官方 + 第三方 + 自建仓库;资源服务器做二进制镜像分发 -5. **JSON 事件接口** —— `xlings interface`(NDJSON 协议)面向 AI Agent、CI 和第三方工具 -6. **自我诊断** —— `xlings self doctor --fix` 检查四层状态并一次修完 +一个工具,安装任意版本的任意软件,无需 root 运行,并像一个独立的 OS 一样隔离它 —— +在 Linux / macOS / Windows 上用法一致。 ## 快速开始 -**安装 —— Linux / macOS** - ```bash +# Linux / macOS curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash ``` -**安装 —— Windows (PowerShell)** - ```powershell +# Windows (PowerShell) irm https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.ps1 | iex ``` ```bash -xlings install gcc@16 node@24 cmake # 安装(版本可选) +xlings install gcc@16 node@24 cmake # 安装(版本可省略) xlings use gcc@16 # 切换当前版本 xlings search python # 搜索包 -xlings list # 查看已安装 -``` - -### 让 AI Agent 帮你安装 & 讲解 - -把以下内容复制给任意 AI agent(Claude / Codex / OpenCode 等): - -``` -阅读 https://github.com/openxlings/xlings 的 README,并在我的机器上安装 xlings。 -- Linux/macOS: curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -- Windows: irm https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.ps1 | iex -然后运行 `xlings agent usage`,按它的说明教我怎么用 xlings。 +xlings list # 已安装的包 ``` -xlings 内置了面向 agent 的使用指南 —— 安装后,任意 agent 都能自助学习完整用法: +用 AI Agent?让它运行 `xlings agent usage` —— xlings 自带一份写给 Agent 的完整使用指南。 -```bash -xlings agent # 概览 + skill 列表 -xlings agent usage # 为 LLM agent 编写的完整使用指南 -``` +## 能做什么 -## 使用场景 +| 方面 | 能力 | +|---|---| +| **包** | 二进制、脚本、配置、SubOS 基础环境 —— 统一为 xpkg,来自官方、第三方或自建索引 | +| **版本** | 任意多个版本并存;N 个环境共享同一份安装产物 | +| **SubOS** | 有名字的环境:自己的工具、home 和策略 —— 从切换 PATH 到带私有网络的无 root 沙箱 | +| **Agent** | `xlings subos exec`、有含义的退出码、`XLINGS_AGENT_MODE=1`,以及 NDJSON 接口(`xlings interface`) | +| **修复** | `xlings self doctor --fix` 检查自身状态,一次修完 | -### 🛠 多版本工具链,一套命令跨平台 +## 场景 -安装任意版本的任意软件,多个版本并存、即时切换 —— 互不冲突,无需 root。同一套命令在 Linux / macOS / Windows 上通用。 +**多套工具链,互不冲突** —— [指南](docs/quick-start/multi-version.md) ```bash -xlings install gcc@16 gcc@11 node@24 cmake -xlings use gcc@11 # 随时切回 —— 两个版本都还在 +xlings install gcc@16 gcc@11 node@24 +xlings use gcc@11 # 随时切回,两个版本都保留 ``` -→ [多版本管理](docs/quick-start/multi-version.md) - -### 📦 可复现的项目环境,跨系统一致 - -提交一份按平台声明版本的 `.xlings.json`。每个项目拥有**独立的 SubOS**,团队和 CI 不论在哪个发行版/系统上,进入项目目录即得到完全一致的环境,不影响宿主机和其他项目。 +**整个团队同一个环境** —— [指南](docs/quick-start/project-env.md) ```json -{ - "workspace": { - "xmake": "3.0.7", - "gcc": { "linux": "16.1.0" }, - "llvm": { "macosx": "20.1.7", "windows": "19.1.0" } - } -} +{ "workspace": { "xmake": "3.0.7", "gcc": { "linux": "16.1.0" }, "llvm": { "macosx": "20.1.7" } } } ``` ```bash -cd my-project/ # 进入目录即激活项目级 SubOS -xlings install # 按声明的版本装进项目级隔离环境 +cd my-project/ && xlings install # 项目自己的 SubOS,装声明的版本 ``` -→ [项目环境](docs/quick-start/project-env.md) - -### 🤖 在隔离 SubOS 中运行 Agent / 不受信代码 - -Linux 可在 rootless 文件系统隔离的 SubOS 中运行 Agent。macOS 和 Windows -仅重定向用户目录;若需运行不受信代码,请使用操作系统沙箱或虚拟机。 +**在沙箱里运行 Agent 和不受信任的代码** —— [指南](docs/quick-start/subos-and-agent.md) ```bash -xlings subos new agent-ws --from subos:dev-env@latest -xlings subos use agent-ws --sandbox # 进入隔离世界 -xlings subos use agent-ws --sandbox --cmd "python run.py" # 或一次性执行 +xlings subos new agent-ws --sandbox=private # 隔离程度在创建时声明一次 +xlings subos exec agent-ws -- python run.py # 之后每次进入都是这个沙箱;返回命令自己的退出码 +xlings subos use agent-ws # 或进入一个 shell,不用再加参数 ``` -→ [SubOS 与 Agent](docs/quick-start/subos-and-agent.md) +| 平台 | 发布产物 | `--sandbox` 隔离的是 | +|---|---|---| +| Linux x86_64 / aarch64 | ✅ | 文件系统、进程、网络、身份(bwrap;aarch64 需该架构有可用的 bwrap) | +| macOS 14+ arm64 | ✅ | **仅 `$HOME`** | +| Windows x86_64 | ✅ | **仅 `%USERPROFILE%`** | -### 🩺 升级与修复,不用手改状态文件 +> macOS 与 Windows 的 `--sandbox` 不是安全边界:运行不受信任的代码请使用操作系统级沙箱或 +> 虚拟机。`xlings subos status ` 会显示一个 SubOS 要求什么、这台机器实际给了什么。 -升级客户端之后,让 xlings 自己检查四层状态 —— workspace、版本数据库、shim、payload -—— 并把能修的一次修完。你读到的报告是**修复之后**的状态,打印出来的命令都可以直接粘贴执行。 +**升级与修复** —— [指南](docs/quick-start/self-management.md) ```bash -xlings self update # 升级 xlings 自己 -xlings self doctor # 只读体检(退出码 0 表示健康) -xlings self doctor --fix --dry-run # 先看会做什么 -xlings self doctor --fix # 修复 +xlings self update +xlings self doctor --fix ``` -→ [自我管理与修复](docs/quick-start/self-management.md) - ## 文档 -详细的使用指南、设计文档与规范都在 [`docs/`](docs/)。 - -| 分类 | 文档 | -|------|------| -| **快速上手** | [多版本管理](docs/quick-start/multi-version.md) · [项目环境](docs/quick-start/project-env.md) · [SubOS 与 Agent](docs/quick-start/subos-and-agent.md) · [自定义索引](docs/quick-start/custom-index.md) · [自我管理与修复](docs/quick-start/self-management.md) · [从源码构建](docs/build-from-source.md) | -| **架构** | [系统架构概览](docs/architecture/overview.md) | -| **设计** | [SubOS-as-XPKG](docs/design/subos-as-xpkg.md) · [xvm 版本管理](docs/design/xvm-version-management.md) · [SubOS 隔离机制](docs/design/subos-isolation.md) · [包索引生态](docs/design/package-index-ecosystem.md) · [Interface 协议](docs/design/interface-protocol.md) | -| **规范** | [xpkg 包描述格式 v1](docs/spec/xpkg-manifest-v1.md) · [.xlings.json 字段](docs/spec/xlings-json-schema.md) · [Interface NDJSON v1](docs/spec/interface-ndjson-v1.md) | - -## 生态 - -| 项目 | 角色 | -|------|------| -| [MCPP](https://github.com/mcpp-community/mcpp) | 现代 C++ 构建工具生态 —— 通过 xlings 分发 | -| **Luban Linux** | 即将推出的 Linux 发行版,采用 xlings 作为系统级包管理器 *(发布时更新链接)* | -| [xim-pkgindex](https://github.com/openxlings/xim-pkgindex) | 官方包索引 —— 60+ 个包持续增长 | - -## 社区 - -- **论坛**: [forum.d2learn.org/category/9/xlings](https://forum.d2learn.org/category/9/xlings) -- **QQ 群**: 167535744 / 1006282943 -- **Issues**: [github.com/openxlings/xlings/issues](https://github.com/openxlings/xlings/issues) - -### 参与贡献 +| 类别 | 文档 | +|---|---| +| **指南** | [多版本](docs/quick-start/multi-version.md) · [项目环境](docs/quick-start/project-env.md) · [SubOS 与 Agent](docs/quick-start/subos-and-agent.md) · [自定义索引](docs/quick-start/custom-index.md) · [自我管理](docs/quick-start/self-management.md) · [从源码构建](docs/build-from-source.md) | +| **设计** | [架构](docs/architecture/overview.md) · [SubOS 隔离](docs/design/subos-isolation.md) · [SubOS-as-XPKG](docs/design/subos-as-xpkg.md) · [xvm 版本管理](docs/design/xvm-version-management.md) · [索引生态](docs/design/package-index-ecosystem.md) · [接口协议](docs/design/interface-protocol.md) | +| **规范** | [xpkg manifest v1](docs/spec/xpkg-manifest-v1.md) · [.xlings.json schema](docs/spec/xlings-json-schema.md) · [Interface NDJSON v1](docs/spec/interface-ndjson-v1.md) · [命令参考](docs/generated/command-reference.md) | +| **对比** | [xlings 与 apt / nix / docker](docs/comparison.md) | -- [Issue 处理与 Bug 修复](https://xlings.d2learn.org/documents/community/contribute/issues.html) -- [添加新包](https://xlings.d2learn.org/documents/community/contribute/add-xpkg.html) -- [文档编写](https://xlings.d2learn.org/documents/community/contribute/documentation.html) +## 生态与社区 -**贡献者** +- [MCPP](https://github.com/mcpp-community/mcpp) —— 现代 C++ 工具链,通过 xlings 分发 +- [xim-pkgindex](https://github.com/openxlings/xim-pkgindex) —— 官方包索引 +- **Luban Linux** —— 即将推出的发行版,用 xlings 作为系统包管理器 +- [论坛](https://forum.d2learn.org/category/9/xlings) · QQ 群 167535744 / 1006282943 · [Issues](https://github.com/openxlings/xlings/issues) +- 参与贡献:[Issue 与 Bug 修复](https://xlings.d2learn.org/documents/community/contribute/issues.html) · [添加新包](https://xlings.d2learn.org/documents/community/contribute/add-xpkg.html) · [文档编写](https://xlings.d2learn.org/documents/community/contribute/documentation.html) diff --git a/apps/luban-init/mcpp.toml b/apps/luban-init/mcpp.toml new file mode 100644 index 000000000..a2d57e32e --- /dev/null +++ b/apps/luban-init/mcpp.toml @@ -0,0 +1,17 @@ +# luban-init: stage-0 as its own binary (SubOS design part 3 §8). Its own +# package, so what it links is luban and the shared modules/ -- never the +# xlings frontend: a package's binaries link every source of that package. +[package] +namespace = "luban" +name = "luban-init" +version = "0.1.0" + +[targets.luban-init] +kind = "bin" +main = "src/main.cpp" + +[dependencies] +luban = { path = "../../luban" } + +[build] +include_dirs = ["../../modules/libs/src/json"] diff --git a/apps/luban-init/src/main.cpp b/apps/luban-init/src/main.cpp new file mode 100644 index 000000000..6e7f783e4 --- /dev/null +++ b/apps/luban-init/src/main.cpp @@ -0,0 +1,10 @@ +// luban-init: stage-0 of a machine whose root is a SubOS (SubOS design part 2 +// §8.2, part 3 §8), as its own binary. It imports only luban.stage0 -- which +// the layer lint keeps free of the xlings frontend -- so "nothing on stage-0's +// path reads a home's Config" is what the build allows, not what a reviewer +// remembers. A machine boots it as `init=/boot/luban-init`; the older +// `init=/boot/xlings-init` (xlings under that name) still works. +import std; +import luban.stage0; + +int main(int argc, char* argv[]) { return luban::stage0::run(argc, argv); } diff --git a/apps/xdev/README.md b/apps/xdev/README.md new file mode 100644 index 000000000..e7c19d0f4 --- /dev/null +++ b/apps/xdev/README.md @@ -0,0 +1,77 @@ +# xdev + +Build the development tool with `mcpp build -p xdev`. It discovers C++ tests +through `mcpp test --list --message-format json`, then reads each built test's +XTEST registry. Missing compiled metadata is reported; `--require-metadata` +makes it an error. Legacy commands remain in `tests/suites.toml`. + +```sh +xdev ci plan --lane pr --changed origin/main --shards 4 --require-metadata +xdev test unit --lane main --shard 1/4 -j 4 --out target/xdev/run-1 +xdev test e2e --no-build --fixture tests/http-fixture --out target/xdev/http-run +xdev report --in target/xdev/run-1 --fail-unverified +``` + +The plan writes GitHub matrix JSON to stdout. Plan and execution share the +same selection, platform, lane, affected-module and shard rules. Timings may +be supplied with `--timings FILE`: a JSON object mapping test program IDs to +positive milliseconds. Platform-qualified keys such as `linux:xdev:test_cli` +override an unqualified key. Without a timing record, unit programs estimate one +second and e2e programs five seconds. `--write-discovery FILE` exports the +compiled metadata for another runner; `--discovery FILE` consumes it. Actual +execution refreshes metadata from the selected artifact. + +`-j N` runs up to N test programs concurrently, after their builds and metadata +reads finish serially. Each program owns its result files under `workers/N/`; +xdev merges them in selection order. Legacy suite commands execute serially +and participate in the same resource locks. A program holds the union of the +resources of its selected cases for its entire execution. Declare resources +with `XTEST(..., .resources = {"port:8080", "cpu:4"})`, or in a script header: + +```sh +# xtest: covers=REQ-ID resources=port:8080,cpu:4 +``` + +Resource names allow ASCII letters, digits, `.`, `_`, `-` and `:`. `port:N` +accepts 1–65535. `cpu`, `cpu:N` (positive N), and `cpu:all` all acquire the +**same exclusive CPU mutex**; N does not grant or account for CPU capacity. +Other valid names are opaque exclusive mutexes, including `sandbox`. +Locks use the kernel's file locking and are acquired in sorted order. They +coordinate independent xdev processes sharing the same lock directory; +`--lock-dir DIR` can share locks between separate checkouts. The default is +`target/xdev/resource-locks` within the checkout. Lock files persist: a normal +exit or process termination releases the kernel lock, and xdev never deletes +lock files or infers ownership from their age. A lock wait expires after +30 minutes and records a failed test. These locks coordinate cooperating +runners; they do not reserve CPU cores or prevent unrelated programs from +binding a declared port. + +`--fixture DIR` starts an HTTP server for the duration of the run and passes +`XLINGS_TEST_FIXTURE_URL=http://127.0.0.1:PORT` to test programs and scripts. +The OS reserves an ephemeral port while the listener is open, so concurrent +fixture owners cannot claim the same port. The platform package owns the +POSIX/Winsock boundary. The server supports GET and HEAD of ordinary files, +including binary payloads, beneath DIR; it rejects traversal, malformed +escapes and symlinks leaving the root. Requests and socket operations have +bounded waits, responses close the connection, and the owning server joins +its worker and closes its listener at shutdown. It serves up to 64 MiB per +file. It binds exclusively to IPv4 loopback and never fetches external URLs. + +`Home::isolated()` starts a process-owned minimal fixture mirror by default. +Its real xpkg index resolves `xim:fixture-data@1.0.0`; the product downloads +the archive from numeric loopback, verifies its SHA256 and extracts it. +Missing packages stay missing. Tests declaring `network` opt into the real +mirror instead. `--fixture DIR` provides an additional explicit URL to tests +and scripts; it does not rewrite their recipes or replace their index. + +`xdev report --trend PREVIOUS/trend.json --write NEXT --timings-out NEXT/timings.json` +retains up to 64 distinct executions per platform, lane and test. The report +compares the current successful duration with the prior successful median, +flags tests with both passing and failing executions, and writes reusable +per-platform median weights for `ci plan --timings`. Failures and skips never +supply a timing weight; re-reporting an execution does not add a sample. +A conflicting or unreadable history is an error. CI retains Linux reports as +90-day artifacts; main also caches its history for subsequent reports and +root-scenario shard plans. PR runs read history and attach their own report. +The intermittent-result flag describes observed outcomes and does not decide +whether a failure is an infrastructure issue or a product regression. diff --git a/apps/xdev/mcpp.toml b/apps/xdev/mcpp.toml new file mode 100644 index 000000000..5d5232c6e --- /dev/null +++ b/apps/xdev/mcpp.toml @@ -0,0 +1,31 @@ +# xdev -- the project's development tool (design §24.3). +# +# One entry point for what a contributor and CI do around the product: run the +# tests (C++ through `mcpp test`, the legacy shell/python scripts through an +# adapter), render one report from all of them, check the requirement map, +# plan the CI matrix, and say what this machine can test. +# +# Its own package, never linked into xlings: `mcpp build` at the root does not +# build it, so the product's build time and binary are unchanged. Build it with +# `mcpp build -p xdev`. It depends on testkit (the process runner and the +# capability probes) and JSON, and the platform OS boundary -- the tool that +# measures the product must not share its failure modes. +[package] +namespace = "xlings" +name = "xdev" +version = "0.1.0" + +[targets.xdev] +kind = "bin" +main = "src/main.cpp" + +[dependencies] +libs = { path = "../../modules/libs" } +testkit = { path = "../../modules/testkit" } +platform = { path = "../../modules/platform" } + +[build] +include_dirs = ["../../modules/libs/src/json", "../../modules/testkit/include"] + +[dev-dependencies.compat] +gtest = "1.15.2" diff --git a/apps/xdev/src/fixture.cpp b/apps/xdev/src/fixture.cpp new file mode 100644 index 000000000..d7e87b3f9 --- /dev/null +++ b/apps/xdev/src/fixture.cpp @@ -0,0 +1 @@ +module xlings.xdev.fixture; diff --git a/apps/xdev/src/fixture.cppm b/apps/xdev/src/fixture.cppm new file mode 100644 index 000000000..0020f8182 --- /dev/null +++ b/apps/xdev/src/fixture.cppm @@ -0,0 +1,7 @@ +export module xlings.xdev.fixture; + +export import xlings.testkit.fixture; + +export namespace xlings::xdev { +namespace fixture = xlings::testkit::fixture; +} diff --git a/apps/xdev/src/history.cpp b/apps/xdev/src/history.cpp new file mode 100644 index 000000000..5e50c5508 --- /dev/null +++ b/apps/xdev/src/history.cpp @@ -0,0 +1,91 @@ +module xlings.xdev.history; + +import std; +import xlings.libs.json; + +namespace xlings::xdev::history { +using nlohmann::json; +namespace { +using Key = std::tuple; +Key key(const Observation& o) { return {o.platform, o.lane, o.kind, o.name}; } +long long median(std::vector values) { + std::ranges::sort(values); + const auto mid = values.size() / 2; + return values.size() % 2 ? values[mid] : (values[mid - 1] + values[mid]) / 2; +} +std::expected validate(const Observation& o) { + if ((o.kind != "binary" && o.kind != "script" && o.kind != "case") || o.name.empty() + || (o.platform != "linux" && o.platform != "macos" && o.platform != "windows") + || o.lane.empty() || o.run.empty() + || (o.status != "pass" && o.status != "fail" && o.status != "skip") + || o.ms < 0 || o.ms > 86400000) + return std::unexpected("invalid trend observation: " + o.name); + return {}; +} +} + +std::expected append(const json& previous, std::span current) { + std::map> grouped; + try { + if (!previous.is_null()) { + if (!previous.is_object() || previous.at("format") != 1 || !previous.at("observations").is_array()) + return std::unexpected("unsupported trend history format"); + for (const auto& row : previous.at("observations")) { + Observation o{row.at("kind").get(), row.at("name").get(), + row.at("platform").get(), row.at("lane").get(), + row.at("run").get(), row.at("status").get(), row.at("ms").get()}; + if (const auto valid = validate(o); !valid) return std::unexpected(valid.error()); + auto& group = grouped[key(o)]; + if (group.size() >= 64) return std::unexpected("trend history exceeds retention for " + o.name); + if (std::ranges::any_of(group, [&](const Observation& p) { return p.run == o.run; })) + return std::unexpected("duplicate trend execution: " + o.name); + group.push_back(std::move(o)); + } + } + } catch (const std::exception& e) { return std::unexpected(std::string("invalid trend history: ") + e.what()); } + + std::string md = "\n### Timing trends\n\n| test | platform / lane | current ms | prior median ms | change |\n|---|---|---:|---:|---:|\n"; + bool compared = false; + for (const auto& o : current) { + if (const auto valid = validate(o); !valid) return std::unexpected(valid.error()); + auto& group = grouped[key(o)]; + const auto same = std::ranges::find(group, o.run, &Observation::run); + if (same != group.end()) { + if (same->status != o.status || same->ms != o.ms) + return std::unexpected("conflicting results for the same execution: " + o.name); + continue; + } + std::vector prior; + for (const auto& p : group) if (p.status == "pass" && p.ms > 0) prior.push_back(p.ms); + if (o.kind != "case" && o.status == "pass" && o.ms > 0 && !prior.empty()) { + const auto baseline = median(prior); + md += std::format("| `{}` | {} / {} | {} | {} | {:.1f}% |\n", o.name, o.platform, o.lane, + o.ms, baseline, 100.0 * (double(o.ms) / double(baseline) - 1.0)); + compared = true; + } + if (group.size() == 64) group.erase(group.begin()); + group.push_back(o); + } + if (!compared) md += "\nNo prior successful execution with a duration for this platform and lane.\n"; + Result result{.history = {{"format", 1}, {"observations", json::array()}}, .timings = json::object()}; + std::map> weights; + std::string flaky; + for (const auto& [k, group] : grouped) { + bool passed = false, failed = false; + for (const auto& o : group) { + result.history["observations"].push_back({{"kind", o.kind}, {"name", o.name}, {"platform", o.platform}, + {"lane", o.lane}, {"run", o.run}, {"status", o.status}, {"ms", o.ms}}); + passed |= o.status == "pass"; + failed |= o.status == "fail"; + if (o.kind != "case" && o.status == "pass" && o.ms > 0) + weights[o.platform + ":" + o.name].push_back(o.ms); + } + if (passed && failed) flaky += std::format("- `{}` ({}, {}) has both passing and failing executions.\n", + std::get<3>(k), std::get<0>(k), std::get<1>(k)); + } + for (auto& [id, values] : weights) result.timings[id] = std::max(1LL, median(std::move(values))); + if (!flaky.empty()) md += "\n### Intermittent results\n\n" + flaky; + result.markdown = std::move(md); + return result; +} +} diff --git a/apps/xdev/src/history.cppm b/apps/xdev/src/history.cppm new file mode 100644 index 000000000..4e12efb84 --- /dev/null +++ b/apps/xdev/src/history.cppm @@ -0,0 +1,29 @@ +export module xlings.xdev.history; + +import std; +import xlings.libs.json; + +export namespace xlings::xdev::history { + +struct Observation { + std::string kind; + std::string name; + std::string platform; + std::string lane; + std::string run; + std::string status; + long long ms { 0 }; +}; + +struct Result { + nlohmann::json history; + nlohmann::json timings; + std::string markdown; +}; + +// Keep 64 executions for each platform/lane/test, deduplicate re-reports, +// compare against prior successful medians, and export usable shard weights. +std::expected append(const nlohmann::json& previous, + std::span current); + +} diff --git a/apps/xdev/src/main.cpp b/apps/xdev/src/main.cpp new file mode 100644 index 000000000..fd4f5de46 --- /dev/null +++ b/apps/xdev/src/main.cpp @@ -0,0 +1,1073 @@ +// xdev -- the project's development tool (design §24.3). +// +// xdev test [pattern] [--suite NAME]... [--no-mcpp] [--out DIR] [--tarball F] +// xdev report [--in DIR]... [--summary] [--requirements FILE] [--fail-uncovered] +// xdev ci plan +// xdev doctor +// +// `test` runs `mcpp test --message-format json` with the XTEST outputs wired, +// then the legacy script suites from tests/suites.toml through an adapter, +// and renders one report. Locally and in CI it is the same command producing +// the same report; CI only adds `--summary` (GitHub step summary) and merges +// the lanes' run directories with `report --in`. +import std; +import xlings.libs.json; +import xlings.testkit; +import xlings.xdev.toml; +import xlings.xdev.selection; +import xlings.xdev.resources; +import xlings.xdev.fixture; +import xlings.xdev.history; + +namespace fs = std::filesystem; +namespace tk = xlings::testkit; +namespace toml = xlings::xdev::toml; +namespace sel = xlings::xdev::selection; +namespace resource = xlings::xdev::resources; +namespace fixture = xlings::xdev::fixture; +namespace history = xlings::xdev::history; +using nlohmann::json; + +namespace { + +// ── small utilities ────────────────────────────────────────────────── + +std::string env_or(const char* name, std::string fallback = {}) { + if (const char* v = std::getenv(name); v && *v) return v; + return fallback; +} + +std::string platform_name() { + if constexpr (tk::is_windows) return "windows"; + else if constexpr (tk::is_macos) return "macos"; + else return "linux"; +} + +// Runs through the platform shell, output to `log`. Returns the exit code. +int shell(const std::string& command, const fs::path& log) { + const std::string full = tk::is_windows + ? command + " > \"" + log.string() + "\" 2>&1" + : "(" + command + ") > \"" + log.string() + "\" 2>&1"; + std::cout.flush(); + return tk::system_exit_code(std::system(full.c_str())); +} + +std::string tail_lines(const std::string& text, std::size_t n) { + std::size_t pos = text.size(); + std::size_t count = 0; + while (pos > 0 && count <= n) { + pos = text.rfind('\n', pos - 1); + if (pos == std::string::npos) { pos = 0; break; } + ++count; + } + return text.substr(pos == 0 ? 0 : pos + 1); +} + +std::vector read_ndjson(const fs::path& p) { + std::vector out; + std::ifstream f(p); + std::string line; + while (std::getline(f, line)) { + if (line.empty() || line.front() != '{') continue; + auto j = json::parse(line, nullptr, false); + if (!j.is_discarded()) out.push_back(std::move(j)); + } + return out; +} + +void append_ndjson(const fs::path& p, const json& j) { + std::ofstream f(p, std::ios::app); + f << j.dump() << '\n'; +} + +fs::path repo_root() { + // xdev is run from the checkout; walk up to the directory holding mcpp.toml + // and tests/ so it also works from a subdirectory. + std::error_code ec; + for (auto d = fs::current_path(ec); !d.empty(); d = d.parent_path()) { + if (fs::exists(d / "mcpp.toml") && fs::is_directory(d / "tests")) return d; + if (d == d.parent_path()) break; + } + return fs::current_path(ec); +} + +// ── lane description ───────────────────────────────────────────────── + +json describe_lane() { + json lane; + lane["name"] = env_or("XDEV_LANE", "local-" + platform_name()); + lane["platform"] = platform_name(); + lane["run"] = env_or("GITHUB_RUN_ID", std::to_string(std::chrono::system_clock::now().time_since_epoch().count())) + + ":" + env_or("GITHUB_RUN_ATTEMPT", "1"); + lane["declared"] = json::array(); + for (auto cap : tk::capability_names()) + if (tk::lane_declares(cap)) lane["declared"].push_back(std::string(cap)); + json probes = json::object(); + for (auto cap : tk::capability_names()) { + auto why = tk::probe(cap); + probes[std::string(cap)] = why ? json(*why) : json(true); + } + lane["probes"] = probes; + return lane; +} + +// ── test ───────────────────────────────────────────────────────────── + +struct TestArgs { + std::string pattern; + std::vector suites; + bool mcpp = true; + fs::path out; + std::string tarball; + std::vector mcpp_args; + sel::Options selection; + bool selecting { false }; + bool no_build { false }; + std::string changed; + fs::path discovery; + fs::path timings; + fs::path write_discovery; + std::size_t jobs { 1 }; + fs::path fixture_root; + fs::path lock_directory; +}; + +// A script states what it covers and what it needs in one header line, the +// way an XTEST does in its metadata: +// +// # xtest: covers=ID,ID requires=sudo,docker proves=isolation +// +// System scenarios (a root-owned entry, a container, a booted kernel) run +// against the release artifact on lanes that have those capabilities, where +// the C++ test binaries -- dev builds against this runner's mcpp registry -- +// cannot travel. The header makes them part of the same requirement map and +// the same capability rule: missing on a lane that declares it fails, missing +// elsewhere skips. +std::optional script_meta(const std::string& command, const fs::path& root) { + std::istringstream words(command); + for (std::string w; words >> w;) { + if (!(w.ends_with(".sh") || w.ends_with(".py"))) continue; + std::ifstream in(root / w); + if (!in) continue; + std::string line; + for (int n = 0; n < 40 && std::getline(in, line); ++n) { + constexpr std::string_view tag = "# xtest:"; + if (!line.starts_with(tag)) continue; + tk::Meta m; + m.area = "scenario"; + std::istringstream kv(line.substr(tag.size())); + for (std::string pair; kv >> pair;) { + auto eq = pair.find('='); + if (eq == std::string::npos) continue; + auto key = pair.substr(0, eq); + std::vector values; + std::istringstream vs(pair.substr(eq + 1)); + for (std::string v; std::getline(vs, v, ',');) + if (!v.empty()) values.push_back(v); + if (key == "covers") m.covers = values; + else if (key == "requires") m.requires_ = values; + else if (key == "resources") m.resources = values; + else if (key == "proves" && !values.empty()) m.proves = values.front(); + } + return m; + } + return std::nullopt; + } + return std::nullopt; +} + +struct Catalog { + std::vector tests; + std::map binaries; + std::set impacted; +}; + +struct Scratch { + fs::path path; + Scratch() { + static std::atomic serial { 0 }; + for (int attempt = 0; attempt < 100; ++attempt) { + auto candidate = fs::temp_directory_path() / std::format("xdev-discovery-{}-{}", + std::chrono::steady_clock::now().time_since_epoch().count(), serial++); + if (fs::create_directory(candidate)) { path = std::move(candidate); return; } + } + throw std::runtime_error("cannot reserve discovery scratch directory"); + } + ~Scratch() { std::error_code ec; fs::remove_all(path, ec); } +}; + +std::optional find_test_binary(const fs::path& root, const sel::Test& test) { + std::error_code ec; + if (!fs::is_directory(root / "target", ec)) return std::nullopt; + auto name = test.id.substr(test.member.empty() ? 0 : test.member.size() + 1); + if constexpr (tk::is_windows) name += ".exe"; + const auto suffix = "/bin/" + name; + const auto memberSuffix = "/bin/" + test.member + "/" + name; + std::optional newest; + fs::file_time_type stamp { fs::file_time_type::min() }; + for (fs::recursive_directory_iterator it(root / "target", ec), end; !ec && it != end; it.increment(ec)) { + if (!it->is_regular_file(ec)) { if (ec) break; continue; } + const auto path = it->path().generic_string(); + if (!path.ends_with(suffix) && (test.member.empty() || !path.ends_with(memberSuffix))) continue; + const auto time = it->last_write_time(ec); + if (!ec && time > stamp) { newest = it->path(); stamp = time; } + } + return newest; +} + +std::vector listed_cases(std::string_view output, const std::vector& metadata) { + std::map known; + for (const auto& row : metadata) { + sel::Case test{.name = row.value("test", ""), .area = row.value("area", ""), + .cost = row.value("cost", "fast"), .requires_ = row.value("requires", std::vector{}), + .resources = row.value("resources", std::vector{})}; + known[test.name] = std::move(test); + } + std::istringstream in{std::string(output)}; + std::vector cases; + std::string suite; + for (std::string line; std::getline(in, line);) { + if (const auto comment = line.find('#'); comment != line.npos) line.resize(comment); + while (!line.empty() && std::isspace(static_cast(line.back()))) line.pop_back(); + if (line.empty()) continue; + if (line.starts_with(" ") && !suite.empty()) { + const auto begin = line.find_first_not_of(' '); + const auto name = suite + line.substr(begin); + if (const auto found = known.find(name); found != known.end()) cases.push_back(found->second); + else cases.push_back({.name = name}); + } else if (line.ends_with('.')) suite = line; + } + return cases; +} + +std::expected catalog(const TestArgs& args, const fs::path& root) { + Catalog result; + if (args.mcpp) { + std::string listed; + if (!args.discovery.empty()) { + std::ifstream in(args.discovery); + if (!in) return std::unexpected("cannot read discovery " + args.discovery.string()); + listed = {std::istreambuf_iterator(in), {}}; + } else { + const auto discovered = tk::run({.argv = {"mcpp", "test", "--list", "--message-format", "json"}, + .env = tk::inherited_env(), .cwd = root}); + if (discovered.exit_code != 0) + return std::unexpected("mcpp test discovery failed: " + discovered.transcript()); + listed = discovered.out; + } + const auto discovered = sel::discovery(listed, root); + if (!discovered) return std::unexpected(discovered.error()); + result.tests = *discovered; + Scratch scratch; + for (auto& test : result.tests) { + const auto binary = find_test_binary(root, test); + if (!binary) continue; + result.binaries[test.id] = *binary; + if (test.metadata_known) continue; + std::error_code ec; + // A registry from an older test source is not authoritative. + if (fs::last_write_time(*binary, ec) < fs::last_write_time(root / test.source, ec) || ec) continue; + const auto registry = scratch.path / "meta.ndjson"; + fs::remove(registry, ec); + auto env = tk::inherited_env(); + env["XTEST_META_OUT"] = registry.string(); + env.erase("XTEST_RESULTS_OUT"); + env.erase("XDEV_LANE_CAPS"); + const auto listing = tk::run({.argv = {binary->string(), "--gtest_list_tests", "--gtest_color=no"}, + .env = std::move(env), .cwd = root, .timeout = std::chrono::seconds(15)}); + if (listing.exit_code != 0) continue; + test.cases = listed_cases(listing.out, read_ndjson(registry)); + test.metadata_known = !test.cases.empty(); + } + } + if (!args.suites.empty()) { + const auto document = toml::parse_file(root / "tests/suites.toml"); + if (!document) return std::unexpected(document.error()); + for (const auto& suite : args.suites) { + const auto table = document->tables.find(suite); + if (table == document->tables.end()) return std::unexpected("unknown suite " + suite); + const auto commands = table->second.find("commands"); + if (commands == table->second.end() || !commands->second.list()) + return std::unexpected("suite has no commands: " + suite); + std::size_t index { 0 }; + for (const auto& command : *commands->second.list()) { + sel::Test test{.id = std::format("script:{}:{}", suite, index), .source = "tests/suites.toml", + .script = true, .suite = suite, .command_index = index++, .duration_ms = 5000}; + if (auto platforms = table->second.find("platforms"); platforms != table->second.end() && platforms->second.list()) + test.platforms = *platforms->second.list(); + std::istringstream words(command); + for (std::string word; words >> word;) + if (word.ends_with(".sh") || word.ends_with(".py")) { test.source = word; break; } + if (const auto metadata = script_meta(command, root)) { + test.metadata_known = true; + test.cases.push_back({.name = test.id, .area = metadata->area, + .cost = std::string(tk::to_string(metadata->cost)), .requires_ = metadata->requires_, + .resources = metadata->resources}); + } + result.tests.push_back(std::move(test)); + } + } + } + const auto timingFile = args.timings.empty() ? root / "tests/ci-timings.json" : args.timings; + if (fs::exists(timingFile)) { + const auto timings = json::parse(tk::read_file(timingFile), nullptr, false); + if (const auto applied = sel::apply_timings(result.tests, timings, args.selection.platform.empty() ? platform_name() : args.selection.platform); !applied) + return std::unexpected(applied.error()); + } else if (!args.timings.empty()) return std::unexpected("timings file does not exist: " + timingFile.string()); + if (!args.changed.empty()) { + if (args.changed.front() == '-') return std::unexpected("--changed needs a git revision, not an option"); + std::vector argv{"git", "diff", "--name-only", "-z", "--diff-filter=ACDMRTUXB"}; + argv.push_back(args.changed.find("..") == args.changed.npos ? args.changed + "...HEAD" : args.changed); + argv.push_back("--"); + const auto diff = tk::run({.argv = std::move(argv), .env = tk::inherited_env(), .cwd = root}); + if (diff.exit_code != 0) return std::unexpected("cannot determine changed files: " + diff.transcript()); + std::vector changed; + std::istringstream paths(diff.out); + for (std::string path; std::getline(paths, path, '\0');) if (!path.empty()) changed.emplace_back(path); + const auto graph = sel::sources(root); + if (!graph) return std::unexpected(graph.error()); + result.impacted = sel::affected(result.tests, *graph, changed); + } + return result; +} + +int cmd_plan(const TestArgs& args) { + const auto root = repo_root(); + const auto inventory = catalog(args, root); + if (!inventory) { std::println(std::cerr, "xdev: {}", inventory.error()); return 2; } + auto options = args.selection; + options.changed = !args.changed.empty(); + options.pattern = args.pattern; + try { + auto plan = sel::matrix(inventory->tests, options, inventory->impacted); + std::vector missing; + for (const auto& test : inventory->tests) + if (!test.metadata_known && !test.script) missing.push_back(test.id); + if (!missing.empty()) + std::println(std::cerr, "xdev: compiled metadata unavailable for {}; selection is conservative (use --require-metadata in CI)", missing); + if (!args.write_discovery.empty()) { + if (fs::exists(fs::symlink_status(args.write_discovery))) { + std::println(std::cerr, "xdev: discovery output already exists: {}", args.write_discovery.string()); + return 2; + } + std::ofstream output(args.write_discovery); + for (const auto& test : inventory->tests) { + if (test.script) continue; + json row{{"member", test.member}, {"test", test.id.substr(test.member.empty() ? 0 : test.member.size() + 1)}, + {"main", test.source.generic_string()}}; + if (test.metadata_known) { + row["cases"] = json::array(); + for (const auto& testCase : test.cases) + row["cases"].push_back({{"test", testCase.name}, {"area", testCase.area}, + {"cost", testCase.cost}, {"requires", testCase.requires_}, {"resources", testCase.resources}}); + } + output << row.dump() << '\n'; + } + output.close(); + if (!output) { std::println(std::cerr, "xdev: cannot write discovery output"); return 2; } + } + std::println("{}", plan.dump()); + return 0; + } catch (const std::exception& error) { + std::println(std::cerr, "xdev: {}", error.what()); + return 2; + } +} + +int run_suites(const TestArgs& a, const fs::path& out, const fs::path& root, + const std::set* selected = nullptr) { + if (a.suites.empty()) return 0; + auto doc = toml::parse_file(root / "tests" / "suites.toml"); + if (!doc) { + std::println(std::cerr, "xdev: {}", doc.error()); + return 2; + } + fs::create_directories(out / "logs"); + // A suite that reports per test (run_all.sh) appends here too. + tk::set_env("XDEV_RECORDS", (out / "scripts.ndjson").string()); + int failed = 0; + const auto bin = tk::xlings_binary().string(); + for (const auto& suite : a.suites) { + auto it = doc->tables.find(suite); + if (it == doc->tables.end()) { + std::println(std::cerr, "xdev: no suite '{}' in tests/suites.toml", suite); + return 2; + } + const auto& t = it->second; + if (auto p = t.find("platforms"); p != t.end() && p->second.list()) { + const auto& plats = *p->second.list(); + if (std::ranges::find(plats, platform_name()) == plats.end()) { + append_ndjson(out / "scripts.ndjson", + json{{"test", "suite:" + suite}, {"status", "skip"}, {"ms", 0}, + {"message", "suite runs on " + std::format("{}", plats)}}); + continue; + } + } + auto c = t.find("commands"); + if (c == t.end() || !c->second.list()) { + std::println(std::cerr, "xdev: suite '{}' has no commands", suite); + return 2; + } + int index = 0; + for (const auto& templ : *c->second.list()) { + const auto commandIndex = index++; + if (selected && !selected->contains(std::format("script:{}:{}", suite, commandIndex))) continue; + auto command = templ; + auto replace_all = [&](std::string_view from, const std::string& to) { + for (std::size_t pos; (pos = command.find(from)) != std::string::npos;) + command.replace(pos, from.size(), to); + }; + replace_all("{xlings}", bin); + replace_all("{tarball}", a.tarball); + auto log = out / "logs" / std::format("{}-{:02}.log", suite, commandIndex); + const auto name = suite + ": " + templ; + std::vector resourceNames; + if (auto m = script_meta(templ, root)) { + resourceNames = m->resources; + append_ndjson(out / "meta.ndjson", + json{{"test", name}, {"area", m->area}, {"covers", m->covers}, + {"requires", m->requires_}, {"resources", m->resources}, {"proves", m->proves}}); + if (auto v = tk::check_requirements(*m)) { + std::println("xdev: [{}] {} -- {}: {}", suite, templ, + v->fail ? "FAIL" : "skip", v->reason); + if (v->fail) ++failed; + append_ndjson(out / "scripts.ndjson", + json{{"test", name}, {"status", v->fail ? "fail" : "skip"}, + {"ms", 0}, {"message", v->reason}}); + continue; + } + } + std::println("xdev: [{}] {}", suite, command); + const auto started = std::chrono::steady_clock::now(); + const auto lockDirectory = a.lock_directory.empty() ? root / "target/xdev/resource-locks" : a.lock_directory; + auto lease = resource::Lease::acquire(lockDirectory, resourceNames); + if (!lease) { + ++failed; + append_ndjson(out / "scripts.ndjson", json{{"test", name}, {"status", "fail"}, + {"ms", 0}, {"message", lease.error()}}); + std::println(std::cerr, "xdev: {}", lease.error()); + continue; + } + int rc = shell(command, log); + auto ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - started).count(); + json r{{"test", name}, {"status", rc == 0 ? "pass" : "fail"}, + {"ms", ms}, {"exit_code", rc}, {"log", log.string()}}; + if (rc != 0) { + ++failed; + r["message"] = tail_lines(tk::read_file(log), 30); + std::println("xdev: FAIL (exit {}), log: {}", rc, log.string()); + } + append_ndjson(out / "scripts.ndjson", r); + } + } + return failed; +} + +int cmd_report_dirs(const std::vector& dirs, bool summary, + const fs::path& requirements, bool fail_uncovered, bool fail_unverified, const fs::path& write_to, + const fs::path& trend_from = {}, const fs::path& timings_out = {}); + +int cmd_test(const TestArgs& a) { + const auto root = repo_root(); + if (!a.selection.platform.empty() && a.selection.platform != platform_name()) { + std::println(std::cerr, "xdev: test runs on {}; --platform belongs to ci plan", platform_name()); + return 2; + } + std::optional inventory; + std::vector selection; + std::set selectedIds; + if (a.mcpp || a.selecting || a.no_build || !a.fixture_root.empty()) { + auto discovered = catalog(a, root); + if (!discovered) { std::println(std::cerr, "xdev: {}", discovered.error()); return 2; } + inventory = std::move(*discovered); + auto options = a.selection; + options.platform = platform_name(); + options.pattern = a.pattern; + options.changed = !a.changed.empty(); + auto chosen = sel::select(inventory->tests, options, inventory->impacted); + if (!chosen) { std::println(std::cerr, "xdev: {}", chosen.error()); return 2; } + selection = std::move(*chosen); + for (const auto& test : selection) selectedIds.insert(test.test.id); + } + auto out = a.out.empty() ? root / "target" / "xdev" / "run" : a.out; + std::error_code ec; + if (fs::exists(out, ec) && (!fs::is_directory(out, ec) || !fs::is_empty(out, ec))) { + std::println(std::cerr, "xdev: output already exists and is not empty: {} (choose a new --out directory)", + out.string()); + return 2; + } + if (ec) { + std::println(std::cerr, "xdev: cannot inspect output {}: {}", out.string(), ec.message()); + return 2; + } + fs::create_directories(out, ec); + if (ec) { std::println(std::cerr, "xdev: {}", ec.message()); return 2; } + out = fs::absolute(out); + + { + std::ofstream(out / "lane.json") << describe_lane().dump(2); + } + + std::optional server; + if (!a.fixture_root.empty()) { + auto started = fixture::Server::start(a.fixture_root); + if (!started) { std::println(std::cerr, "xdev: {}", started.error()); return 2; } + server.emplace(std::move(*started)); + std::println("xdev: fixture {} serves {}", server->url(), a.fixture_root.string()); + } + const auto lockDirectory = a.lock_directory.empty() ? root / "target/xdev/resource-locks" : a.lock_directory; + int mcpp_rc = 0; + if (a.mcpp && inventory) { + std::vector tasks; + std::vector taskDirectories; + for (const auto& test : selection) { + if (test.test.script) continue; + std::string buildOutput; + if (!a.no_build) { + std::vector argv{"mcpp", "test", "--no-run", "--message-format", "json"}; + const auto separator = std::ranges::find(a.mcpp_args, "--"); + argv.insert(argv.end(), a.mcpp_args.begin(), separator); + if (!test.test.member.empty()) { argv.push_back("-p"); argv.push_back(test.test.member); } + argv.push_back(test.test.id.substr(test.test.member.empty() ? 0 : test.test.member.size() + 1)); + const auto build = tk::run({.argv = std::move(argv), .env = tk::inherited_env(), .cwd = root, + .timeout = std::chrono::minutes(30)}); + buildOutput = build.transcript(); + if (build.exit_code != 0) { + mcpp_rc = 1; + append_ndjson(out / "mcpp.ndjson", json{{"test", test.test.id}, {"status", "compile_failed"}, + {"duration_ms", build.elapsed.count()}, {"compile_output", buildOutput}}); + continue; + } + } + const auto binary = find_test_binary(root, test.test); + if (!binary) { + std::println(std::cerr, "xdev: no built test binary for {} (build tests before --no-build)", test.test.id); + return 2; + } + auto current = test.test; + Scratch scratch; + auto listingEnv = tk::inherited_env(); + listingEnv["XTEST_META_OUT"] = (scratch.path / "meta.ndjson").string(); + listingEnv.erase("XTEST_RESULTS_OUT"); + listingEnv.erase("XDEV_LANE_CAPS"); + const auto listing = tk::run({.argv = {binary->string(), "--gtest_list_tests", "--gtest_color=no"}, + .env = std::move(listingEnv), .cwd = root, .timeout = std::chrono::seconds(15)}); + if (listing.exit_code == 0) { + current.cases = listed_cases(listing.out, read_ndjson(scratch.path / "meta.ndjson")); + current.metadata_known = !current.cases.empty(); + } + auto options = a.selection; + options.platform = platform_name(); + options.pattern = a.pattern; + options.shards = 1; + options.shard = 0; + options.changed = !a.changed.empty(); + // PR's no-network contract needs an actual registry, even on the + // first run after building a newly discovered test source. + options.require_metadata = options.require_metadata || options.lane == "pr"; + const auto fresh = sel::select(std::span(¤t, 1), options, inventory->impacted); + if (!fresh) { std::println(std::cerr, "xdev: {}", fresh.error()); return 2; } + if (fresh->empty()) { + append_ndjson(out / "mcpp.ndjson", json{{"test", test.test.id}, {"status", "skip"}, + {"duration_ms", 0}, {"message", "excluded by the compiled case registry"}}); + continue; + } + std::vector argv{binary->string()}; + if (a.no_build) { + // mcpp arguments before its `--` are build flags; the binary + // receives only the arguments after that separator. + const auto separator = std::ranges::find(a.mcpp_args, "--"); + if (separator != a.mcpp_args.end()) argv.insert(argv.end(), separator + 1, a.mcpp_args.end()); + else if (!a.mcpp_args.empty()) { + std::println(std::cerr, "xdev: --no-build needs test arguments after -- --"); return 2; + } + } else { + const auto separator = std::ranges::find(a.mcpp_args, "--"); + if (separator != a.mcpp_args.end()) argv.insert(argv.end(), separator + 1, a.mcpp_args.end()); + } + if (!fresh->front().filter.empty()) argv.push_back("--gtest_filter=" + fresh->front().filter); + const auto taskDirectory = out / "workers" / std::to_string(tasks.size()); + fs::create_directories(taskDirectory); + taskDirectories.push_back(taskDirectory); + auto env = tk::inherited_env(); + env["XTEST_META_OUT"] = (taskDirectory / "meta.ndjson").string(); + env["XTEST_RESULTS_OUT"] = (taskDirectory / "cases.ndjson").string(); + env["XTEST_ARTIFACTS"] = (taskDirectory / "artifacts").string(); + if (server) env["XLINGS_TEST_FIXTURE_URL"] = server->url(); + tasks.push_back({.id = test.test.id, .resources = fresh->front().resources, + .run = [argv = std::move(argv), env = std::move(env), root, taskDirectory, + id = test.test.id, buildOutput] () mutable { + const auto run = tk::run({.argv = std::move(argv), .env = std::move(env), .cwd = root, + .timeout = std::chrono::minutes(30)}); + append_ndjson(taskDirectory / "mcpp.ndjson", json{{"test", id}, + {"status", run.exit_code == 0 ? "pass" : "fail"}, {"duration_ms", run.elapsed.count()}, + {"run_output", run.transcript()}, {"compile_output", buildOutput}}); + return run.exit_code; + }}); + } + const auto executed = resource::run(tasks, a.jobs, lockDirectory); + if (!executed) { std::println(std::cerr, "xdev: {}", executed.error()); return 2; } + for (std::size_t index = 0; index < executed->size(); ++index) { + const auto& result = (*executed)[index]; + if (result.exit_code != 0) mcpp_rc = 1; + if (!result.error.empty()) { + append_ndjson(out / "mcpp.ndjson", json{{"test", result.id}, {"status", "fail"}, + {"duration_ms", result.elapsed_ms}, {"run_output", result.error}}); + std::println(std::cerr, "xdev: {}: {}", result.id, result.error); + } + for (const auto& name : {"mcpp.ndjson", "meta.ndjson", "cases.ndjson"}) { + for (auto row : read_ndjson(taskDirectories[index] / name)) { + if (std::string_view(name) != "mcpp.ndjson") row["program"] = result.id; + append_ndjson(out / name, row); + if (std::string_view(name) == "mcpp.ndjson" && result.exit_code != 0) + std::println(std::cerr, "{}", tail_lines(row.value("run_output", ""), 25)); + } + } + } + } + // Script adapters inherit a fixture URL once; worker threads have joined. + const auto oldFixture = env_or("XLINGS_TEST_FIXTURE_URL"); + if (server) tk::set_env("XLINGS_TEST_FIXTURE_URL", server->url()); + int script_failures = run_suites(a, out, root, inventory ? &selectedIds : nullptr); + if (server) tk::set_env("XLINGS_TEST_FIXTURE_URL", oldFixture); + if (script_failures == 2 && a.suites.size() > 0 && !fs::exists(out / "scripts.ndjson")) return 2; + + int rc = cmd_report_dirs({out}, env_or("GITHUB_STEP_SUMMARY").size() > 0 + && env_or("XDEV_SUMMARY") == "1", + {}, false, false, out); + if (mcpp_rc != 0 && rc == 0) { + // mcpp itself failed before reporting a test (resolution, build). + std::println(std::cerr, "xdev: `mcpp test` failed without a failing test record"); + return 1; + } + return rc; +} + +// ── report ─────────────────────────────────────────────────────────── + +struct Record { + std::string kind; // binary | case | script + std::string name; + std::string status; // pass | fail | skip + long long ms = 0; + std::string message; + std::string lane; +}; + +std::string normalise_mcpp_status(const std::string& s) { + if (s == "pass" || s == "ok") return "pass"; + if (s == "skip" || s == "ignored") return "skip"; + return "fail"; +} + +std::string first_lines(const std::string& s, std::size_t n) { + std::string out; + std::size_t lines = 0; + for (char c : s) { + if (c == '\n' && ++lines >= n) break; + out.push_back(c); + } + return out; +} + +struct Requirement { + std::string id; + std::string text; + std::string kind; // flow | isolation + std::string status; // required | planned | deferred + std::string why; +}; + +int cmd_report_dirs(const std::vector& dirs, bool summary, + const fs::path& requirements, bool fail_uncovered, bool fail_unverified, const fs::path& write_to, + const fs::path& trend_from, const fs::path& timings_out) { + std::vector records; + std::vector lanes; + std::vector observations; + using EvidenceKey = std::pair; // lane, test + std::map meta; + + for (const auto& dir : dirs) { + std::string lane_name = dir.filename().string(); + if (auto l = json::parse(tk::read_file(dir / "lane.json"), nullptr, false); + !l.is_discarded() && l.is_object()) { + lane_name = l.value("name", lane_name); + lanes.push_back(l); + } + const auto first = records.size(); + std::string execution = fs::absolute(dir).lexically_normal().string(); + std::string platform; + if (!lanes.empty() && lanes.back().value("name", "") == lane_name) { + platform = lanes.back().value("platform", ""); + execution = lanes.back().value("run", execution); + } + for (auto& j : read_ndjson(dir / "mcpp.ndjson")) { + if (!j.contains("test")) continue; + auto id = j.value("test", ""); + const auto member = j.value("member", ""); + if (!member.empty() && !id.starts_with(member + ":")) id = member + ":" + id; + Record r{ .kind = "binary", .name = std::move(id), + .status = normalise_mcpp_status(j.value("status", "")), + .ms = j.value("duration_ms", 0LL), .lane = lane_name }; + if (r.status == "fail") { + auto out = j.value("compile_output", std::string()); + if (out.empty()) out = j.value("run_output", std::string()); + r.message = j.value("status", "") + ": " + tail_lines(out, 25); + } + records.push_back(std::move(r)); + } + for (auto& j : read_ndjson(dir / "cases.ndjson")) { + const auto program = j.value("program", ""); + const auto name = j.value("test", ""); + records.push_back({ .kind = "case", .name = program.empty() ? name : program + ":" + name, + .status = j.value("status", ""), .ms = j.value("ms", 0LL), + .message = j.value("message", ""), .lane = lane_name }); + } + for (auto& j : read_ndjson(dir / "scripts.ndjson")) { + records.push_back({ .kind = "script", .name = j.value("test", ""), + .status = j.value("status", ""), .ms = j.value("ms", 0LL), + .message = j.value("message", ""), .lane = lane_name }); + } + for (auto& j : read_ndjson(dir / "meta.ndjson")) { + const auto program = j.value("program", ""); + const auto name = j.value("test", ""); + if (!program.empty()) j["test"] = program + ":" + name; + meta[{lane_name, j.value("test", "")}] = j; + } + if (!platform.empty()) + for (auto i = first; i < records.size(); ++i) { + const auto& r = records[i]; + observations.push_back({r.kind, r.name, platform, lane_name, execution, r.status, r.ms}); + } + } + + auto count = [&](std::string_view kind, std::string_view status) { + return std::ranges::count_if(records, [&](const Record& r) { + return r.kind == kind && r.status == status; + }); + }; + + std::string md; + std::string lane_names; + for (auto& l : lanes) lane_names += (lane_names.empty() ? "" : ", ") + l.value("name", "?"); + md += std::format("## xdev report — {}\n\n", lane_names.empty() ? "no lanes" : lane_names); + md += "| | pass | fail | skip |\n|---|---:|---:|---:|\n"; + for (auto kind : {"binary", "case", "script"}) { + md += std::format("| {} | {} | {} | {} |\n", + std::string_view(kind) == "binary" ? "test binaries (mcpp)" + : std::string_view(kind) == "case" ? "XTEST cases" : "legacy scripts", + count(kind, "pass"), count(kind, "fail"), count(kind, "skip")); + } + + std::vector failures; + for (auto& r : records) if (r.status == "fail") failures.push_back(&r); + if (!failures.empty()) { + md += "\n### Failures\n\n"; + for (auto* r : failures) { + md += std::format("- **{}** `{}` ({})\n", r->kind, r->name, r->lane); + if (!r->message.empty()) + md += "\n ```\n" + first_lines(r->message, 25) + "\n ```\n"; + } + } + + std::map> skips; + for (auto& r : records) + if (r.status == "skip") skips[first_lines(r.message, 1)].push_back(r.name); + if (!skips.empty()) { + md += "\n### Skipped, by reason\n\n"; + for (auto& [why, names] : skips) { + md += std::format("- {} — {}", why.empty() ? "(no reason given)" : why, names.size()); + md += names.size() <= 4 ? std::format(": {}\n", names) : "\n"; + } + } + + std::vector by_time; + for (auto& r : records) if (r.kind != "case") by_time.push_back(&r); + std::ranges::sort(by_time, std::greater<>{}, &Record::ms); + if (!by_time.empty()) { + md += "\n### Slowest\n\n| ms | test | lane |\n|---:|---|---|\n"; + for (std::size_t i = 0; i < std::min(10, by_time.size()); ++i) + md += std::format("| {} | `{}` | {} |\n", by_time[i]->ms, by_time[i]->name, by_time[i]->lane); + } + + if (!lanes.empty()) { + md += "\n### Capabilities\n\n| lane | declared | missing here |\n|---|---|---|\n"; + for (auto& l : lanes) { + std::string missing; + for (auto it = l["probes"].begin(); it != l["probes"].end(); ++it) + if (!it.value().is_boolean()) missing += (missing.empty() ? "" : ", ") + it.key(); + std::string declared; + for (auto& d : l["declared"]) declared += (declared.empty() ? "" : ", ") + d.get(); + md += std::format("| {} | {} | {} |\n", l.value("name", "?"), + declared.empty() ? "—" : declared, missing.empty() ? "—" : missing); + } + } + + // Requirement coverage (T5): every ID has a test; isolation IDs need an + // isolation test; every ID a test names exists. + int coverage_errors = 0; + json coverage = json::object(); + if (!requirements.empty()) { + auto doc = toml::parse_file(requirements); + if (!doc) { + std::println(std::cerr, "xdev: {}", doc.error()); + return 2; + } + std::vector reqs; + for (auto& name : doc->order) { + if (name.empty()) continue; + auto& t = doc->tables[name]; + Requirement r{ .id = name }; + if (auto it = t.find("text"); it != t.end() && it->second.str()) r.text = *it->second.str(); + r.kind = "flow"; + if (auto it = t.find("kind"); it != t.end() && it->second.str()) r.kind = *it->second.str(); + r.status = "required"; + if (auto it = t.find("status"); it != t.end() && it->second.str()) r.status = *it->second.str(); + if (auto it = t.find("why"); it != t.end() && it->second.str()) r.why = *it->second.str(); + if (r.status != "required" && r.status != "planned" && r.status != "deferred") { + std::println(std::cerr, "xdev: {}: unknown status '{}'", r.id, r.status); + return 2; + } + if (r.kind != "flow" && r.kind != "isolation") { + std::println(std::cerr, "xdev: {}: unknown kind '{}'", r.id, r.kind); + return 2; + } + reqs.push_back(std::move(r)); + } + std::map> covered_by; // id -> tests + std::map> verified_by; + std::set passed_tests; + for (const auto& record : records) + if (record.status == "pass" && record.kind != "binary") passed_tests.emplace(record.lane, record.name); + std::vector unknown; + for (auto& [key, m] : meta) { + const auto test = key.second + " (" + key.first + ")"; + for (auto& id : m.value("covers", json::array())) { + auto sid = id.get(); + auto it = std::ranges::find(reqs, sid, &Requirement::id); + if (it == reqs.end()) { unknown.push_back(test + " → " + sid); continue; } + if (it->kind == "isolation" && m.value("proves", "flow") != "isolation") continue; + covered_by[sid].push_back(test); + if (passed_tests.contains(key)) verified_by[sid].push_back(test); + } + } + std::vector uncovered, unverified, planned, deferred; + std::size_t required = 0; + for (auto& r : reqs) { + if (r.status == "planned") { planned.push_back(&r); continue; } + if (r.status == "deferred") { deferred.push_back(&r); continue; } + ++required; + if (!covered_by.contains(r.id)) uncovered.push_back(&r); + if (!verified_by.contains(r.id)) unverified.push_back(&r); + coverage[r.id] = {{"kind", r.kind}, {"declared_by", covered_by[r.id]}, + {"passed_by", verified_by[r.id]}}; + } + md += std::format("\n### Requirements\n\n{} required: {} declared, {} undeclared · " + "{} planned · {} deferred · {} unknown IDs in tests\n", + required, required - uncovered.size(), uncovered.size(), + planned.size(), deferred.size(), unknown.size()); + md += std::format("\nExecution evidence: {} verified, {} unverified. Skipped tests do not verify a requirement.\n", + required - unverified.size(), unverified.size()); + if (!unverified.empty()) { + md += "\nUnverified (required):\n\n"; + for (auto* r : unverified) + md += std::format("- `{}` ({}) {}\n", r->id, r->kind, r->text); + } + if (!uncovered.empty()) { + md += "\nUndeclared (required):\n\n"; + for (auto* r : uncovered) + md += std::format("- `{}` ({}) {}\n", r->id, r->kind, r->text); + } + if (!planned.empty()) { + md += "\n
Planned\n\n"; + for (auto* r : planned) + md += std::format("- `{}` {}{}\n", r->id, r->text, + covered_by.contains(r->id) ? " — **covered: flip to required**" : ""); + md += "\n
\n"; + } + if (!deferred.empty()) { + md += "\nDeferred:\n\n"; + for (auto* r : deferred) md += std::format("- `{}` {} — {}\n", r->id, r->text, r->why); + } + if (!unknown.empty()) { + md += "\nTests naming an ID that tests/requirements.toml does not declare:\n\n"; + for (auto& u : unknown) md += "- " + u + "\n"; + } + if (fail_uncovered) coverage_errors = static_cast(uncovered.size() + unknown.size()); + else coverage_errors = static_cast(unknown.size()); + if (fail_unverified) coverage_errors += static_cast(unverified.size()); + } + + json previous; + if (!trend_from.empty()) { + std::ifstream input(trend_from); + if (!input) { std::println(std::cerr, "xdev: cannot read trend history {}", trend_from.string()); return 2; } + previous = json::parse(input, nullptr, false); + } + const auto trend = history::append(previous, observations); + if (!trend) { std::println(std::cerr, "xdev: {}", trend.error()); return 2; } + md += trend->markdown; + std::println("{}", md); + if (!timings_out.empty()) { + std::ofstream output(timings_out); + output << trend->timings.dump(2) << '\n'; + output.flush(); + if (!output) { std::println(std::cerr, "xdev: cannot write timings {}", timings_out.string()); return 2; } + } + if (!write_to.empty()) { + fs::create_directories(write_to); + std::ofstream(write_to / "trend.json") << trend->history.dump(2) << '\n'; + std::ofstream(write_to / "report.md") << md; + json j; + j["lanes"] = lanes; + j["requirements"] = coverage; + j["records"] = json::array(); + for (auto& r : records) + j["records"].push_back({{"kind", r.kind}, {"name", r.name}, {"status", r.status}, + {"ms", r.ms}, {"lane", r.lane}}); + std::ofstream(write_to / "report.json") << j.dump(2); + } + if (summary) { + if (auto path = env_or("GITHUB_STEP_SUMMARY"); !path.empty()) + std::ofstream(path, std::ios::app) << md << "\n"; + } + return (failures.empty() && coverage_errors == 0) ? 0 : 1; +} + +// ── doctor ─────────────────────────────────────────────────────────── + +int cmd_doctor() { + auto lane = describe_lane(); + std::println("xdev doctor — {} ({})", lane["name"].get(), lane["platform"].get()); + auto bin = tk::xlings_binary(); + std::println(" xlings under test: {}", bin.empty() ? "(none — run `mcpp build`)" : bin.string()); + for (auto it = lane["probes"].begin(); it != lane["probes"].end(); ++it) { + const auto cap = it.key(); + const auto& v = it.value(); + bool declared = tk::lane_declares(cap); + std::println(" {:<11} {}{}", cap, v.is_boolean() ? "yes" : "no — " + v.get(), + declared ? " [declared by lane]" : ""); + } + return 0; +} + +bool parse_selection_option(const std::vector& args, std::size_t& index, TestArgs& out) { + const auto& option = args[index]; + auto value = [&]() -> std::string { + if (index + 1 >= args.size()) throw std::invalid_argument("missing value for " + option); + return args[++index]; + }; + if (option == "--require-metadata") out.selection.require_metadata = true; + else if (option == "--lane") out.selection.lane = value(); + else if (option == "--platform") out.selection.platform = value(); + else if (option == "--changed") out.changed = value(); + else if (option == "--area") out.selection.area = value(); + else if (option == "--discovery") out.discovery = value(); + else if (option == "--timings") out.timings = value(); + else if (option == "--write-discovery") out.write_discovery = value(); + else if (option == "--shard") { + const auto shard = sel::parse_shard(value()); + if (!shard) throw std::invalid_argument(shard.error()); + out.selection.shard = shard->first; + out.selection.shards = shard->second; + } else if (option == "--shards") { + const auto count = value(); + const auto shard = sel::parse_shard("1/" + count); + if (!shard) throw std::invalid_argument(shard.error()); + out.selection.shards = shard->second; + } else return false; + out.selecting = true; + return true; +} + +int usage() { + std::println(std::cerr, + "usage: xdev \n" + " test [unit|e2e|perf|pattern] [--lane pr|main|nightly] [--shard i/n] [--changed BASE]\n" + " [-j N] [--fixture DIR] [--lock-dir DIR] [--suite NAME]... [--no-mcpp|--no-build] [--out DIR] [--tarball FILE] [-- mcpp args]\n" + " report [--in DIR]... [--summary] [--requirements FILE] [--fail-uncovered] [--fail-unverified]\n" + " [--trend PREVIOUS.json] [--write DIR] [--timings-out FILE]\n" + " ci plan [--lane pr|main|nightly] [--shards N] [--platform OS] [--changed BASE..HEAD]\n" + " [--suite NAME]... [--discovery FILE] [--timings FILE] [--write-discovery FILE] [--require-metadata]\n" + " doctor"); + return 2; +} + +} // namespace + +int main(int argc, char** argv) { + std::vector args(argv + 1, argv + argc); + if (args.empty()) return usage(); + const auto cmd = args[0]; + + if (cmd == "ci" && args.size() >= 2 && args[1] == "plan") { + TestArgs plan; + for (std::size_t i = 2; i < args.size(); ++i) { + try { + if (parse_selection_option(args, i, plan)) continue; + } catch (const std::exception& error) { std::println(std::cerr, "xdev: {}", error.what()); return 2; } + if (args[i] == "--suite" && i + 1 < args.size()) plan.suites.push_back(args[++i]); + else if (args[i] == "--no-mcpp") plan.mcpp = false; + else if (!args[i].empty() && args[i][0] != '-' && plan.pattern.empty()) plan.pattern = args[i]; + else { std::println(std::cerr, "xdev ci plan: unknown option {}", args[i]); return 2; } + } + if (plan.pattern == "unit" || plan.pattern == "e2e" || plan.pattern == "perf") + plan.selection.category = std::exchange(plan.pattern, {}); + return cmd_plan(plan); + } + if (cmd == "test") { + tk::set_env("XDEV_BIN", fs::absolute(argv[0]).string()); + TestArgs a; + for (std::size_t i = 1; i < args.size(); ++i) { + const auto& x = args[i]; + try { + if (parse_selection_option(args, i, a)) continue; + } catch (const std::exception& error) { std::println(std::cerr, "xdev: {}", error.what()); return 2; } + if (x == "-j" || x == "--jobs") { + if (i + 1 >= args.size()) return usage(); + const auto& count = args[++i]; + const auto [end, error] = std::from_chars(count.data(), count.data() + count.size(), a.jobs); + if (error != std::errc{} || end != count.data() + count.size() || a.jobs == 0 || a.jobs > 1024) { + std::println(std::cerr, "xdev: -j needs 1..1024 workers"); return 2; + } + a.selecting = true; + } else if (x == "--fixture" && i + 1 < args.size()) { a.fixture_root = args[++i]; a.selecting = true; } + else if (x == "--lock-dir" && i + 1 < args.size()) { a.lock_directory = args[++i]; a.selecting = true; } + else if (x == "--suite" && i + 1 < args.size()) a.suites.push_back(args[++i]); + else if (x == "--no-mcpp") a.mcpp = false; + else if (x == "--no-build") { a.no_build = true; a.selecting = true; } + else if (x == "--out" && i + 1 < args.size()) a.out = args[++i]; + else if (x == "--tarball" && i + 1 < args.size()) a.tarball = args[++i]; + else if (x == "--") { a.mcpp_args.assign(args.begin() + static_cast(i) + 1, args.end()); break; } + else if (!x.empty() && x[0] != '-' && a.pattern.empty()) a.pattern = x; + else { std::println(std::cerr, "xdev test: unknown option {}", x); return 2; } + } + if (a.pattern == "unit" || a.pattern == "e2e" || a.pattern == "perf") { + a.selection.category = std::exchange(a.pattern, {}); + a.selecting = true; + } + return cmd_test(a); + } + if (cmd == "report") { + std::vector dirs; + bool summary = false, fail_uncovered = false, fail_unverified = false; + fs::path requirements, write_to, trend_from, timings_out; + for (std::size_t i = 1; i < args.size(); ++i) { + const auto& x = args[i]; + if (x == "--in" && i + 1 < args.size()) dirs.emplace_back(args[++i]); + else if (x == "--summary") summary = true; + else if (x == "--requirements" && i + 1 < args.size()) requirements = args[++i]; + else if (x == "--fail-uncovered") fail_uncovered = true; + else if (x == "--fail-unverified") fail_unverified = true; + else if (x == "--write" && i + 1 < args.size()) write_to = args[++i]; + else if (x == "--trend" && i + 1 < args.size()) trend_from = args[++i]; + else if (x == "--timings-out" && i + 1 < args.size()) timings_out = args[++i]; + else { std::println(std::cerr, "xdev report: unknown option {}", x); return 2; } + } + if (dirs.empty()) dirs.push_back(repo_root() / "target" / "xdev" / "run"); + return cmd_report_dirs(dirs, summary, requirements, fail_uncovered, fail_unverified, write_to, trend_from, timings_out); + } + if (cmd == "doctor") return cmd_doctor(); + return usage(); +} diff --git a/apps/xdev/src/resources.cpp b/apps/xdev/src/resources.cpp new file mode 100644 index 000000000..9ed8ba7d9 --- /dev/null +++ b/apps/xdev/src/resources.cpp @@ -0,0 +1,96 @@ +module xlings.xdev.resources; + +import std; +import xlings.platform; + +namespace xlings::xdev::resources { + +std::expected normalize(std::string_view resource) { + if (resource.empty() || resource.size() > 128 + || !std::ranges::all_of(resource, [](unsigned char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' || c == '.' || c == ':'; + })) return std::unexpected("invalid resource name: " + std::string(resource)); + if (resource == "cpu" || resource.starts_with("cpu:")) { + if (resource != "cpu" && resource != "cpu:all") { + unsigned count { 0 }; + const auto value = resource.substr(4); + const auto [end, error] = std::from_chars(value.data(), value.data() + value.size(), count); + if (error != std::errc{} || end != value.data() + value.size() || count == 0) + return std::unexpected("cpu:N needs a positive integer; it is an exclusive CPU resource"); + } + return "cpu"; + } + if (resource.starts_with("port:")) { + unsigned port { 0 }; + const auto value = resource.substr(5); + const auto [end, error] = std::from_chars(value.data(), value.data() + value.size(), port); + if (error != std::errc{} || end != value.data() + value.size() || port == 0 || port > 65535) + return std::unexpected("port:N needs a port in 1..65535"); + return "port:" + std::to_string(port); + } + return std::string(resource); +} + +std::expected Lease::acquire(const std::filesystem::path& directory, + std::span resources, + std::chrono::milliseconds timeout) { + std::set names; + for (const auto& resource : resources) { + auto name = normalize(resource); + if (!name) return std::unexpected(name.error()); + names.insert(*name); + } + Lease lease; + if (names.empty()) return lease; + std::error_code ec; + std::filesystem::create_directories(directory, ec); + if (ec) return std::unexpected("cannot create resource lock directory: " + ec.message()); + for (const auto& name : names) { + std::string encoded; + for (unsigned char byte : name) encoded += std::format("{:02x}", byte); + xlings::platform::FileLock lock; + std::string error; + if (!lock.acquire(directory / (encoded + ".lock"), timeout, [] { return false; }, error)) + return std::unexpected("cannot acquire resource " + name + ": " + error); + lease.locks_.push_back(std::move(lock)); + } + return lease; +} + +std::expected, std::string> +run(std::span tasks, std::size_t jobs, const std::filesystem::path& directory) { + if (jobs == 0 || jobs > 1024) return std::unexpected("-j needs 1..1024 workers"); + for (const auto& task : tasks) { + if (!task.run) return std::unexpected("task has no runner: " + task.id); + for (const auto& name : task.resources) + if (auto valid = normalize(name); !valid) return std::unexpected(valid.error()); + } + std::vector results(tasks.size()); + std::atomic next { 0 }; + std::vector workers; + for (std::size_t worker = 0; worker < std::min(jobs, tasks.size()); ++worker) { + workers.emplace_back([&] { + for (;;) { + const auto index = next++; + if (index >= tasks.size()) return; + const auto started = std::chrono::steady_clock::now(); + Result result{.id = tasks[index].id}; + try { + auto lease = Lease::acquire(directory, tasks[index].resources); + if (!lease) { result.exit_code = 2; result.error = lease.error(); } + else result.exit_code = tasks[index].run(); + } catch (const std::exception& error) { + result.exit_code = 2; + result.error = error.what(); + } + result.elapsed_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - started).count(); + results[index] = std::move(result); + } + }); + } + for (auto& worker : workers) worker.join(); + return results; +} + +} // namespace xlings::xdev::resources diff --git a/apps/xdev/src/resources.cppm b/apps/xdev/src/resources.cppm new file mode 100644 index 000000000..d2b4a08c5 --- /dev/null +++ b/apps/xdev/src/resources.cppm @@ -0,0 +1,45 @@ +export module xlings.xdev.resources; + +import std; +import xlings.platform; + +export namespace xlings::xdev::resources { + +// Safe opaque exclusive resource names. cpu and cpu:N all mean the same +// exclusive CPU benchmark resource; N is never represented as core capacity. +// port:N is an exclusive named port. Other safe names are opaque mutexes. +std::expected normalize(std::string_view resource); + +class Lease { +private: + std::vector locks_; + +public: + Lease() = default; + Lease(Lease&&) noexcept = default; + Lease& operator=(Lease&&) noexcept = default; + Lease(const Lease&) = delete; + static std::expected + acquire(const std::filesystem::path& directory, std::span resources, + std::chrono::milliseconds timeout = std::chrono::minutes(30)); +}; + +struct Task { + std::string id; + std::vector resources; + std::function run; +}; +struct Result { + std::string id; + int exit_code { 0 }; + long long elapsed_ms { 0 }; + std::string error; +}; + +// Locks are kernel-owned and shared by independent xdev processes using the +// same directory; they are never guessed stale or deleted. Sorted acquisition +// avoids deadlocks. Results retain input order regardless of completion order. +std::expected, std::string> +run(std::span tasks, std::size_t jobs, const std::filesystem::path& directory); + +} // namespace xlings::xdev::resources diff --git a/apps/xdev/src/selection.cpp b/apps/xdev/src/selection.cpp new file mode 100644 index 000000000..41780e385 --- /dev/null +++ b/apps/xdev/src/selection.cpp @@ -0,0 +1,350 @@ +module xlings.xdev.selection; + +import std; +import xlings.xdev.resources; +import xlings.libs.json; + +namespace xlings::xdev::selection { + +namespace { + +struct Token { std::string text; bool quoted { false }; }; + +// Only declarations and quoted includes are needed. Comments and strings, +// including raw fixture strings, must not become fictitious dependencies. +std::vector tokens(std::string_view code) { + std::vector out; + for (std::size_t at = 0; at < code.size();) { + if (std::isspace(static_cast(code[at]))) { ++at; continue; } + if (code.substr(at, 2) == "//") { + const auto end = code.find('\n', at + 2); + at = end == code.npos ? code.size() : end + 1; + continue; + } + if (code.substr(at, 2) == "/*") { + const auto end = code.find("*/", at + 2); + at = end == code.npos ? code.size() : end + 2; + continue; + } + if (code.substr(at, 2) == "R\"") { + const auto begin = code.find('(', at + 2); + if (begin != code.npos && begin - at <= 18) { + const auto closing = ")" + std::string(code.substr(at + 2, begin - at - 2)) + "\""; + const auto end = code.find(closing, begin + 1); + at = end == code.npos ? code.size() : end + closing.size(); + continue; + } + } + if (code[at] == '"' || code[at] == '\'') { + const char quote = code[at++]; + std::string value; + while (at < code.size() && code[at] != quote) { + if (code[at] == '\\' && at + 1 < code.size()) ++at; + value += code[at++]; + } + if (at < code.size()) ++at; + out.push_back({value, true}); + continue; + } + const auto begin = at++; + if (std::isalnum(static_cast(code[begin])) || code[begin] == '_') + while (at < code.size() + && (std::isalnum(static_cast(code[at])) || code[at] == '_')) ++at; + out.push_back({std::string(code.substr(begin, at - begin)), false}); + } + return out; +} + +bool under(const fs::path& path, std::string_view prefix) { + const auto value = path.generic_string(); + return value == prefix || value.starts_with(std::string(prefix) + "/"); +} + +bool platform_ok(std::span required, std::string_view platform) { + for (const auto& cap : required) + if ((cap == "linux" || cap == "macos" || cap == "windows") && cap != platform) return false; + return true; +} + +bool expensive(const Case& test) { + if (test.cost == "slow") return true; + for (const auto& cap : test.requires_) + if (cap == "sandbox" || cap == "bwrap" || cap == "sudo" || cap == "docker" + || cap == "qemu" || cap == "kvm" || cap == "network" || cap == "landlock") return true; + return false; +} + +bool has(std::span values, std::string_view value) { + return std::ranges::find(values, value) != values.end(); +} + +} // namespace + +Source scan_source(fs::path path, std::string_view contents) { + Source source{.path = std::move(path)}; + const auto ts = tokens(contents); + for (std::size_t at = 0; at < ts.size(); ++at) { + if (ts[at].quoted) continue; + if (ts[at].text == "#" && at + 2 < ts.size() && ts[at + 1].text == "include" && ts[at + 2].quoted) { + source.includes.push_back(ts[at + 2].text); + continue; + } + if (ts[at].text != "module" && ts[at].text != "import") continue; + const auto keyword = ts[at].text; + std::string name; + auto end = at + 1; + for (; end < ts.size() && ts[end].text != ";"; ++end) { + if (ts[end].quoted || ts[end].text == "{" || ts[end].text == "}") { name.clear(); break; } + if (ts[end].text != "." && ts[end].text != ":" + && !std::ranges::all_of(ts[end].text, [](unsigned char c) { return std::isalnum(c) || c == '_'; })) { + name.clear(); break; + } + name += ts[end].text; + } + if (name.empty() || end == ts.size()) continue; + if (keyword == "module" && !name.starts_with(':')) source.module = name; + else if (keyword == "import") source.imports.push_back(name); + at = end; + } + for (auto& name : source.imports) + if (name.starts_with(':')) name = source.module.substr(0, source.module.find(':')) + name; + return source; +} + +std::expected, std::string> sources(const fs::path& root) { + std::vector graph; + std::error_code ec; + for (auto top : {"src", "modules", "apps", "tests"}) { + const auto dir = root / top; + if (!fs::exists(dir, ec)) { if (ec) return std::unexpected(ec.message()); continue; } + for (fs::recursive_directory_iterator it(dir, ec), end; !ec && it != end; it.increment(ec)) { + if (!it->is_regular_file(ec)) { if (ec) break; continue; } + const auto extension = it->path().extension(); + if (extension != ".cpp" && extension != ".cppm" && extension != ".h" && extension != ".hpp") continue; + std::ifstream in(it->path(), std::ios::binary); + if (!in) return std::unexpected("cannot read dependency source " + it->path().string()); + const auto text = std::string{std::istreambuf_iterator(in), {}}; + if (in.bad()) return std::unexpected("cannot read dependency source " + it->path().string()); + graph.push_back(scan_source(it->path().lexically_relative(root), text)); + } + if (ec) return std::unexpected("cannot inspect dependency sources: " + ec.message()); + } + return graph; +} + +std::set affected(const std::vector& tests, const std::vector& graph, + std::span changed) { + std::set dirty; + std::set modules; + std::map> headers; + for (const auto& source : graph) headers[source.path.filename().string()].push_back(source.path); + bool global { false }, product { false }; + for (const auto& path : changed) { + dirty.insert(path.lexically_normal()); + product = product || under(path, "src") || under(path, "modules") || under(path, "res"); + const auto extension = path.extension(); + if (path.filename() == "mcpp.toml" || path.filename() == "mcpp.lock" + || path.filename() == ".xlings.json" || under(path, ".github") || under(path, "res")) global = true; + if ((extension == ".cpp" || extension == ".cppm" || extension == ".h" || extension == ".hpp") + && std::ranges::find(graph, path.lexically_normal(), &Source::path) == graph.end()) global = true; + } + bool progress { true }; + while (progress) { + progress = false; + for (const auto& source : graph) { + bool depends = dirty.contains(source.path) || (!source.module.empty() && modules.contains(source.module)); + for (const auto& name : source.imports) depends = depends || modules.contains(name); + for (const auto& include : source.includes) { + depends = depends || dirty.contains((source.path.parent_path() / include).lexically_normal()) + || dirty.contains(fs::path(include).lexically_normal()); + // Include search paths vary by package; duplicate matching names + // deliberately overselect rather than guess which compiler chose. + for (const auto& candidate : headers[fs::path(include).filename().string()]) + depends = depends || dirty.contains(candidate); + } + if (!depends) continue; + progress = dirty.insert(source.path).second || progress; + if (!source.module.empty()) progress = modules.insert(source.module).second || progress; + } + } + std::set impacted; + for (const auto& test : tests) + if (global || dirty.contains(test.source) || (product && (under(test.source, "tests/e2e") || test.script))) + impacted.insert(test.id); + return impacted; +} + +std::expected, std::string> parse_shard(std::string_view text) { + const auto slash = text.find('/'); + std::size_t index { 0 }, count { 0 }; + if (slash == text.npos) return std::unexpected("--shard expects i/n with 1 <= i <= n"); + const auto [left, le] = std::from_chars(text.data(), text.data() + slash, index); + const auto [right, re] = std::from_chars(text.data() + slash + 1, text.data() + text.size(), count); + if (le != std::errc{} || re != std::errc{} || left != text.data() + slash || right != text.data() + text.size() + || index == 0 || count == 0 || index > count || count > 1024) + return std::unexpected("--shard expects i/n with 1 <= i <= n <= 1024"); + return std::pair{index, count}; +} + +std::expected, std::string> +select(std::span tests, const Options& options, const std::set& impacted) { + if (options.lane != "pr" && options.lane != "main" && options.lane != "nightly") + return std::unexpected("--lane must be pr, main or nightly"); + if (options.shards == 0 || options.shards > 1024 || options.shard > options.shards) + return std::unexpected("invalid shard count or index"); + if (!options.category.empty() && options.category != "unit" && options.category != "e2e" && options.category != "perf") + return std::unexpected("unknown test category " + options.category); + if (!options.platform.empty() && options.platform != "linux" && options.platform != "macos" && options.platform != "windows") + return std::unexpected("unknown platform " + options.platform); + std::vector selected; + for (const auto& test : tests) { + if (!options.pattern.empty() && test.id.find(options.pattern) == std::string::npos) continue; + const bool perf = test.source.stem().string().ends_with("_perf"); + if (options.category.empty() && options.pattern.empty() && options.lane != "nightly" && perf) continue; + if (!options.category.empty()) { + if ((options.category == "unit" && !under(test.source, "tests/unit")) + || (options.category == "e2e" && (!under(test.source, "tests/e2e") || perf)) + || (options.category == "perf" && !perf)) continue; + } + if (!options.platform.empty() && !test.platforms.empty() && !has(test.platforms, options.platform)) continue; + if (options.require_metadata && !test.metadata_known && !test.script) + return std::unexpected("compiled metadata is missing for " + test.id + "; build tests or provide --discovery"); + if (!options.area.empty() && !test.metadata_known) + return std::unexpected("--area needs compiled metadata for " + test.id + "; build tests or provide --discovery"); + Selected entry{.test = test}; + if (test.metadata_known && !test.cases.empty()) { + std::size_t kept { 0 }; + for (const auto& testCase : test.cases) { + if (!options.platform.empty() && !platform_ok(testCase.requires_, options.platform)) continue; + if (!options.area.empty() && testCase.area != options.area) continue; + if (options.lane == "pr") { + if (has(testCase.requires_, "network")) continue; + if (options.changed && expensive(testCase) && !impacted.contains(test.id)) continue; + } + if (!entry.filter.empty()) entry.filter += ':'; + entry.filter += testCase.name; + ++kept; + for (const auto& resource : testCase.resources) { + auto name = resources::normalize(resource); + if (!name) return std::unexpected(test.id + ": " + name.error()); + if (!has(entry.resources, *name)) entry.resources.push_back(*name); + } + for (const auto& cap : testCase.requires_) + if (!has(entry.requires_, cap)) entry.requires_.push_back(cap); + } + if (kept == 0) continue; + if (kept == test.cases.size()) entry.filter.clear(); + } else if (options.lane == "pr" && test.script + && test.suite != "lint" && test.suite != "contract-scripts") { + // The legacy aggregate e2e adapter has no per-command network + // declaration. It belongs to main/nightly until migrated. + continue; + } + selected.push_back(std::move(entry)); + } + std::ranges::sort(selected, [](const Selected& a, const Selected& b) { + if (a.test.duration_ms != b.test.duration_ms) return a.test.duration_ms > b.test.duration_ms; + return a.test.id < b.test.id; + }); + std::vector load(options.shards, 0); + for (auto& test : selected) { + const auto least = std::ranges::min_element(load); + test.shard = static_cast(least - load.begin()) + 1; + *least += test.test.duration_ms; + } + if (options.shard != 0) std::erase_if(selected, [&](const Selected& test) { return test.shard != options.shard; }); + std::ranges::sort(selected, {}, [](const Selected& test) { return test.test.id; }); + return selected; +} + +std::expected, std::string> discovery(std::string_view ndjson, const fs::path& root) { + std::vector tests; + std::set ids; + std::istringstream stream{std::string(ndjson)}; + for (std::string line; std::getline(stream, line);) { + if (line.empty()) continue; + const auto row = nlohmann::json::parse(line, nullptr, false); + if (!row.is_object()) return std::unexpected("invalid JSON test discovery record"); + if (row.contains("summary")) continue; + try { + Test test; + const auto name = row.at("test").get(); + test.member = row.value("member", ""); + test.id = test.member.empty() ? name : test.member + ":" + name; + const fs::path source = row.at("main").get(); + test.source = (source.is_absolute() ? source.lexically_relative(root) : source).lexically_normal(); + if (test.id.empty() || test.source.empty() || test.source.is_absolute() || *test.source.begin() == ".." + || !fs::is_regular_file(root / test.source) || !ids.insert(test.id).second) + return std::unexpected("discovery has an invalid, absent or duplicate test source: " + name); + test.duration_ms = under(test.source, "tests/unit") ? 1000 : 5000; + if (auto cases = row.find("cases"); cases != row.end()) { + if (!cases->is_array()) return std::unexpected("discovery cases must be an array"); + test.metadata_known = true; + std::set names; + for (const auto& value : *cases) { + Case testCase{.name = value.at("test").get(), + .area = value.value("area", ""), .cost = value.value("cost", "fast"), + .requires_ = value.value("requires", std::vector{}), + .resources = value.value("resources", std::vector{})}; + if (testCase.name.empty() || testCase.name.find_first_of(":*?\n\r") != testCase.name.npos + || !names.insert(testCase.name).second) + return std::unexpected("invalid or duplicate discovered case name"); + if (testCase.cost != "fast" && testCase.cost != "medium" && testCase.cost != "slow") + return std::unexpected("invalid discovered test cost"); + test.cases.push_back(std::move(testCase)); + } + } + tests.push_back(std::move(test)); + } catch (const nlohmann::json::exception& error) { + return std::unexpected("invalid test discovery: " + std::string(error.what())); + } + } + return tests; +} + +std::expected apply_timings(std::vector& tests, const nlohmann::json& timings, std::string_view platform) { + if (!timings.is_object()) return std::unexpected("timings must map test binary IDs to positive milliseconds"); + for (auto& test : tests) { + auto value = platform.empty() ? timings.end() : timings.find(std::string(platform) + ":" + test.id); + if (value == timings.end()) value = timings.find(test.id); + if (value == timings.end()) continue; + if (!value->is_number_integer() || *value <= 0 || *value > 86400000) + return std::unexpected("invalid timing for " + test.id); + test.duration_ms = value->get(); + } + return {}; +} + +nlohmann::json matrix(std::span tests, const Options& options, const std::set& impacted) { + nlohmann::json include = nlohmann::json::array(); + for (auto platform : {"linux", "macos", "windows"}) { + if (!options.platform.empty() && options.platform != platform) continue; + auto perPlatform = options; + perPlatform.platform = platform; + perPlatform.shard = 0; + const auto selected = select(tests, perPlatform, impacted); + if (!selected) throw std::invalid_argument(selected.error()); + for (std::size_t shard = 1; shard <= options.shards; ++shard) { + nlohmann::json ids = nlohmann::json::array(), filters = nlohmann::json::object(); + std::set capabilities, resourceNames; + long long duration { 0 }; + for (const auto& test : *selected) { + if (test.shard != shard) continue; + ids.push_back(test.test.id); + filters[test.test.id] = test.filter; + capabilities.insert(test.requires_.begin(), test.requires_.end()); + resourceNames.insert(test.resources.begin(), test.resources.end()); + duration += test.test.duration_ms; + } + if (ids.empty()) continue; + include.push_back({{"platform", platform}, {"lane", options.lane}, + {"shard", std::format("{}/{}", shard, options.shards)}, {"tests", ids}, + {"filters", filters}, {"requires", std::vector(capabilities.begin(), capabilities.end())}, + {"resources", std::vector(resourceNames.begin(), resourceNames.end())}, + {"estimated_ms", duration}}); + } + } + return {{"include", include}}; +} + +} // namespace xlings::xdev::selection diff --git a/apps/xdev/src/selection.cppm b/apps/xdev/src/selection.cppm new file mode 100644 index 000000000..a198ba052 --- /dev/null +++ b/apps/xdev/src/selection.cppm @@ -0,0 +1,76 @@ +export module xlings.xdev.selection; + +import std; +import xlings.libs.json; + +export namespace xlings::xdev::selection { + +namespace fs = std::filesystem; + +struct Case { + std::string name; + std::string area; + std::string cost { "fast" }; + std::vector requires_; + std::vector resources; +}; + +struct Test { + std::string id; // mcpp's member-qualified binary or script:suite:index + fs::path source; // checkout-relative, from mcpp's discovery + std::string member; + std::vector cases; // compiled XTEST registry, not source guesses + bool metadata_known { false }; + bool script { false }; + std::string suite; + std::size_t command_index { 0 }; + std::vector platforms; + long long duration_ms { 1000 }; +}; + +struct Source { + fs::path path; + std::string module; + std::vector imports; + std::vector includes; +}; + +Source scan_source(fs::path path, std::string_view contents); +std::expected, std::string> sources(const fs::path& root); +// Interfaces, implementation units and local headers all participate. An +// unsupported/global build input selects conservatively, never silently less. +std::set affected(const std::vector& tests, const std::vector& graph, + std::span changed); + +struct Options { + std::string lane { "main" }; // pr | main | nightly + std::string platform; // linux | macos | windows; empty = all + std::string pattern; + std::string category; // unit | e2e | perf + std::string area; // requires compiled metadata for every candidate + std::size_t shards { 1 }; + std::size_t shard { 0 }; // 0 = all; CLI indices are 1-based + bool changed { false }; + bool require_metadata { false }; +}; + +struct Selected { + Test test; + std::string filter; // gtest case filter; empty = all cases + std::vector requires_; + std::vector resources; + std::size_t shard { 1 }; +}; + +std::expected, std::string> +select(std::span tests, const Options& options, + const std::set& impacted = {}); +std::expected, std::string> parse_shard(std::string_view text); +// Input: mcpp test --list --message-format json, optionally augmented by +// xdev's exported compiled registries. Invalid records are errors, not skips. +std::expected, std::string> discovery(std::string_view ndjson, const fs::path& root); +std::expected apply_timings(std::vector& tests, const nlohmann::json& timings, std::string_view platform = {}); +nlohmann::json matrix(std::span tests, const Options& options, + const std::set& impacted = {}); + +} // namespace xlings::xdev::selection diff --git a/apps/xdev/src/toml.cppm b/apps/xdev/src/toml.cppm new file mode 100644 index 000000000..545b9efce --- /dev/null +++ b/apps/xdev/src/toml.cppm @@ -0,0 +1,176 @@ +export module xlings.xdev.toml; + +import std; + +// The TOML this repository's test manifests use, and no more: `[table]` +// headers, `key = "string"`, `key = true|false|`, and arrays of +// strings that may span lines. Comments start with `#` outside a string. +// +// A full TOML library would be a dependency for three files that a person +// edits by hand; an unsupported construct is reported with its line number +// rather than silently read as something else. +export namespace xlings::xdev::toml { + +struct Value { + std::variant> v; + + const std::string* str() const { return std::get_if(&v); } + const std::vector* list() const { return std::get_if>(&v); } + const bool* boolean() const { return std::get_if(&v); } +}; + +using Table = std::map>; + +struct Document { + std::vector order; // table names, in file order + std::map> tables; +}; + +std::expected parse(std::string_view text); +std::expected parse_file(const std::filesystem::path& path); + +} // namespace xlings::xdev::toml + +namespace xlings::xdev::toml { + +namespace { + +std::string_view trim(std::string_view s) { + while (!s.empty() && (s.front() == ' ' || s.front() == '\t' || s.front() == '\r')) s.remove_prefix(1); + while (!s.empty() && (s.back() == ' ' || s.back() == '\t' || s.back() == '\r')) s.remove_suffix(1); + return s; +} + +// Strip a trailing comment that is not inside a string. +std::string_view strip_comment(std::string_view s) { + bool in_str = false; + for (std::size_t i = 0; i < s.size(); ++i) { + if (s[i] == '\\' && in_str) { ++i; continue; } + if (s[i] == '"') in_str = !in_str; + else if (s[i] == '#' && !in_str) return s.substr(0, i); + } + return s; +} + +std::expected read_string(std::string_view& s) { + if (s.empty() || s.front() != '"') return std::unexpected("expected a string"); + std::string out; + std::size_t i = 1; + for (; i < s.size(); ++i) { + char c = s[i]; + if (c == '"') break; + if (c == '\\' && i + 1 < s.size()) { + char n = s[++i]; + switch (n) { + case 'n': out.push_back('\n'); break; + case 't': out.push_back('\t'); break; + case '"': out.push_back('"'); break; + case '\\': out.push_back('\\'); break; + default: out.push_back('\\'); out.push_back(n); + } + continue; + } + out.push_back(c); + } + if (i >= s.size()) return std::unexpected("unterminated string"); + s.remove_prefix(i + 1); + return out; +} + +} // namespace + +std::expected parse(std::string_view text) { + Document doc; + std::string current; + doc.order.push_back(""); + doc.tables[""] = {}; + + std::vector lines; + for (std::size_t start = 0; start <= text.size();) { + auto end = text.find('\n', start); + if (end == std::string_view::npos) end = text.size(); + lines.push_back(text.substr(start, end - start)); + start = end + 1; + } + + for (std::size_t ln = 0; ln < lines.size(); ++ln) { + auto where = [&](std::string_view what) { + return std::format("line {}: {}", ln + 1, what); + }; + auto line = trim(strip_comment(lines[ln])); + if (line.empty()) continue; + if (line.front() == '[') { + if (line.back() != ']' || line.starts_with("[[")) return std::unexpected(where("unsupported table header")); + current = std::string(trim(line.substr(1, line.size() - 2))); + if (current.size() >= 2 && current.front() == '"' && current.back() == '"') + current = current.substr(1, current.size() - 2); + if (!doc.tables.contains(current)) doc.order.push_back(current); + doc.tables[current]; + continue; + } + auto eq = line.find('='); + if (eq == std::string_view::npos) return std::unexpected(where("expected key = value")); + auto key = std::string(trim(line.substr(0, eq))); + auto rest = trim(line.substr(eq + 1)); + if (key.size() >= 2 && key.front() == '"' && key.back() == '"') key = key.substr(1, key.size() - 2); + + Value value; + if (!rest.empty() && rest.front() == '"') { + auto s = read_string(rest); + if (!s) return std::unexpected(where(s.error())); + value.v = std::move(*s); + } else if (rest == "true" || rest == "false") { + value.v = (rest == "true"); + } else if (!rest.empty() && rest.front() == '[') { + // Join continuation lines until the closing bracket. + std::string joined(rest); + while (joined.find(']') == std::string::npos || [&] { + // a ']' inside a string does not close the array + bool in_str = false; + for (std::size_t i = 0; i < joined.size(); ++i) { + if (joined[i] == '\\' && in_str) { ++i; continue; } + if (joined[i] == '"') in_str = !in_str; + else if (joined[i] == ']' && !in_str) return false; + } + return true; + }()) { + if (++ln >= lines.size()) return std::unexpected(where("unterminated array")); + joined += ' '; + joined += trim(strip_comment(lines[ln])); + } + std::string_view a(joined); + a.remove_prefix(1); + std::vector items; + while (true) { + a = trim(a); + if (a.empty()) return std::unexpected(where("unterminated array")); + if (a.front() == ']') break; + auto s = read_string(a); + if (!s) return std::unexpected(where("arrays hold strings only")); + items.push_back(std::move(*s)); + a = trim(a); + if (!a.empty() && a.front() == ',') a.remove_prefix(1); + } + value.v = std::move(items); + } else { + long long n = 0; + auto [p, ec] = std::from_chars(rest.data(), rest.data() + rest.size(), n); + if (ec != std::errc{} || p != rest.data() + rest.size()) + return std::unexpected(where("unsupported value: " + std::string(rest))); + value.v = n; + } + doc.tables[current][key] = std::move(value); + } + return doc; +} + +std::expected parse_file(const std::filesystem::path& path) { + std::ifstream f(path, std::ios::binary); + if (!f) return std::unexpected("cannot read " + path.string()); + std::string text{std::istreambuf_iterator(f), std::istreambuf_iterator()}; + auto doc = parse(text); + if (!doc) return std::unexpected(path.string() + ": " + doc.error()); + return doc; +} + +} // namespace xlings::xdev::toml diff --git a/apps/xdev/tests/test_cli.cpp b/apps/xdev/tests/test_cli.cpp new file mode 100644 index 000000000..0fd637138 --- /dev/null +++ b/apps/xdev/tests/test_cli.cpp @@ -0,0 +1,243 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.libs.json; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; +using nlohmann::json; + +namespace { + +struct Fixture { + tk::Home home = tk::Home::isolated("xdev-selection"); + const char* binary = std::getenv("XDEV_BIN"); + Fixture() { + tk::write_file(home.root() / "mcpp.toml", "[package]\nname = \"selection-fixture\"\n"); + fs::create_directories(home.root() / "tests/unit"); + } + tk::RunResult run(std::vector args, std::map extra = {}) const { + args.insert(args.begin(), binary ? binary : ""); + auto env = tk::inherited_env(); + for (const auto& [key, value] : extra) env[key] = value; + return tk::run({.argv = std::move(args), .env = std::move(env), .cwd = home.root()}); + } + fs::path discovery(const std::vector& records) const { + const auto file = home.root() / "discovery.ndjson"; + std::string text; + for (const auto& row : records) text += row.dump() + "\n"; + tk::write_file(file, text); + return file; + } +}; + +json row(std::string id, std::string source, std::vector cases) { + return {{"member", ""}, {"test", std::move(id)}, {"main", std::move(source)}, {"cases", std::move(cases)}}; +} + +json test_case(std::string name, std::vector capabilities = {}) { + return {{"test", std::move(name)}, {"area", "fixture"}, {"cost", "fast"}, {"requires", capabilities}}; +} + +void executable(const fs::path& path, std::string_view contents) { + tk::write_file(path, contents); + fs::permissions(path, fs::perms::owner_all); +} + +} // namespace + +XTEST(XdevCli, ProductionDiscoveryInvokesMcppListWithoutBuilding, + .area = "testkit", .covers = {"CI-TEST-SELECTION"}) { + if constexpr (!tk::is_posix) GTEST_SKIP() << "POSIX executable discovery fixture"; + Fixture fixture; + if (!fixture.binary || !*fixture.binary) GTEST_SKIP() << "set XDEV_BIN to the built xdev"; + tk::write_file(fixture.home.root() / "tests/unit/new_test.cpp", "import std;"); + const auto tools = fixture.home.root() / "tools"; + executable(tools / "mcpp", R"(#!/bin/sh +if [ "$*" != "test --list --message-format json" ]; then + echo "unexpected mcpp invocation: $*" >&2 + exit 41 +fi +printf '%s\n' "$*" > "$FAKE_MCPP_LOG" +printf '%s\n' '{"member":"","test":"unit/new_test","main":"tests/unit/new_test.cpp"}' '{"summary":{"total":1}}' +)"); + const auto log = fixture.home.root() / "mcpp-call"; + const auto planned = fixture.run({"ci", "plan", "--platform", "linux", "--shards", "2"}, + {{"PATH", tools.string()}, {"FAKE_MCPP_LOG", log.string()}}); + ASSERT_EQ(planned.exit_code, 0) << planned.transcript(); + const auto matrix = json::parse(planned.out); + ASSERT_EQ(matrix["include"].size(), 1); + EXPECT_EQ(matrix["include"][0]["tests"], std::vector{"unit/new_test"}); + EXPECT_EQ(tk::read_file(log), "test --list --message-format json\n"); + EXPECT_FALSE(fs::exists(fixture.home.root() / "target")) << "discovery did not build artifacts"; +} + +XTEST(XdevCli, MatrixAndArtifactExecutionShareTheSameCompleteDisjointShards, + .area = "testkit", .covers = {"CI-TEST-SELECTION"}) { + if constexpr (!tk::is_posix) GTEST_SKIP() << "POSIX executable artifact fixture"; + Fixture fixture; + if (!fixture.binary || !*fixture.binary) GTEST_SKIP() << "set XDEV_BIN to the built xdev"; + std::vector records; + for (const auto& name : {"a", "b", "c"}) { + const auto source = "tests/unit/" + std::string(name) + ".cpp"; + tk::write_file(fixture.home.root() / source, "import std;"); + records.push_back(row("unit/" + std::string(name), source, + {test_case(std::string(name) + ".Fast"), test_case(std::string(name) + ".Network", {"network"})})); + executable(fixture.home.root() / "target/fixture/bin/unit" / name, std::format(R"(#!/bin/sh +if [ "$1" = "--gtest_list_tests" ]; then + printf '%s\n' '{}.' ' Fast' ' Network' + printf '%s\n' '{{"test":"{}.Fast","area":"fixture","requires":[]}}' '{{"test":"{}.Network","area":"fixture","requires":["network"]}}' > "$XTEST_META_OUT" + exit 0 +fi +printf '%s\n' '{}:'"$*" >> "$EXEC_TRACE" +printf '%s\n' '{{"test":"{}.Fast","status":"pass"}}' >> "$XTEST_RESULTS_OUT" +)", name, name, name, name, name)); + } + const auto inventory = fixture.discovery(records); + const auto planned = fixture.run({"ci", "plan", "--platform", "linux", "--lane", "pr", + "--shards", "2", "--discovery", inventory.string(), "--require-metadata"}); + ASSERT_EQ(planned.exit_code, 0) << planned.transcript(); + const auto matrix = json::parse(planned.out); + ASSERT_EQ(matrix["include"].size(), 2); + const auto trace = fixture.home.root() / "trace"; + std::set executed; + for (const auto& job : matrix["include"]) { + const auto shard = job["shard"].get(); + const auto output = fixture.home.root() / ("run-" + shard.substr(0, shard.find('/'))); + const auto run = fixture.run({"test", "--lane", "pr", "--shard", shard, "--no-build", + "--discovery", inventory.string(), "--out", output.string()}, {{"EXEC_TRACE", trace.string()}}); + ASSERT_EQ(run.exit_code, 0) << run.transcript(); + const auto report = json::parse(tk::read_file(output / "report.json")); + std::vector binaryIds; + for (const auto& record : report["records"]) + if (record["kind"] == "binary") { + binaryIds.push_back(record["name"].get()); + EXPECT_TRUE(executed.insert(binaryIds.back()).second); + } + EXPECT_EQ(binaryIds, job["tests"].get>()); + } + EXPECT_EQ(executed.size(), records.size()); + const auto called = tk::read_file(trace); + for (const auto& name : {"a", "b", "c"}) + EXPECT_NE(called.find(std::string(name) + ":--gtest_filter=" + name + ".Fast"), std::string::npos); + EXPECT_EQ(called.find(".Network"), std::string::npos); +} + +XTEST(XdevCli, ExplicitPerfProgramRunsAfterCompiledCaseDiscoveryWithoutEnablingNetwork, + .area = "testkit", .covers = {"CI-TEST-SELECTION"}) { + if constexpr (!tk::is_posix) GTEST_SKIP() << "POSIX executable artifact fixture"; + Fixture fixture; + if (!fixture.binary || !*fixture.binary) GTEST_SKIP() << "set XDEV_BIN to the built xdev"; + tk::write_file(fixture.home.root() / "tests/unit/selected_perf.cpp", "import std;"); + const auto inventory = fixture.discovery({row("unit/selected_perf", "tests/unit/selected_perf.cpp", + {test_case("Perf.Measure"), test_case("Perf.Network", {"network"})})}); + executable(fixture.home.root() / "target/fixture/bin/unit/selected_perf", R"(#!/bin/sh +if [ "$1" = "--gtest_list_tests" ]; then + printf '%s\n' 'Perf.' ' Measure' ' Network' + printf '%s\n' '{"test":"Perf.Measure","area":"fixture","requires":[]}' '{"test":"Perf.Network","area":"fixture","requires":["network"]}' > "$XTEST_META_OUT" + exit 0 +fi +printf '%s\n' "$*" > "$EXEC_TRACE" +printf '%s\n' '{"test":"Perf.Measure","status":"pass"}' > "$XTEST_RESULTS_OUT" +)"); + const auto planned = fixture.run({"ci", "plan", "--lane", "pr", "--discovery", inventory.string()}); + ASSERT_EQ(planned.exit_code, 0) << planned.transcript(); + EXPECT_TRUE(json::parse(planned.out)["include"].empty()); + const auto output = fixture.home.root() / "explicit-perf"; + const auto trace = fixture.home.root() / "trace"; + const auto run = fixture.run({"test", "unit/selected_perf", "--lane", "pr", "--no-build", + "--discovery", inventory.string(), "--out", output.string()}, {{"EXEC_TRACE", trace.string()}}); + ASSERT_EQ(run.exit_code, 0) << run.transcript(); + EXPECT_EQ(tk::read_file(trace), "--gtest_filter=Perf.Measure\n"); + const auto report = json::parse(tk::read_file(output / "report.json")); + const auto found = std::ranges::find_if(report["records"], [](const json& record) { + return record.value("kind", "") == "binary" && + record.value("name", "") == "unit/selected_perf"; + }); + ASSERT_NE(found, report["records"].end()); + EXPECT_EQ(found->at("status").get(), "pass"); +} + +XTEST(XdevCli, ChangedSelectionFollowsARealGitDiffThroughAnImplementationDependency, + .area = "testkit", .covers = {"CI-TEST-SELECTION"}) { + Fixture fixture; + if (!fixture.binary || !*fixture.binary) GTEST_SKIP() << "set XDEV_BIN to the built xdev"; + tk::write_file(fixture.home.root() / "modules/base/api.cppm", "export module fixture.base;"); + tk::write_file(fixture.home.root() / "modules/base/api.cpp", "module fixture.base;"); + tk::write_file(fixture.home.root() / "tests/unit/affected.cpp", "import fixture.base;"); + tk::write_file(fixture.home.root() / "tests/unit/unrelated.cpp", "import fixture.elsewhere;"); + const auto inventory = fixture.discovery({ + row("unit/affected", "tests/unit/affected.cpp", {test_case("Affected.Isolation", {"sandbox"})}), + row("unit/unrelated", "tests/unit/unrelated.cpp", {test_case("Unrelated.Isolation", {"sandbox"})})}); + auto git = [&](std::vector args) { + args.insert(args.begin(), "git"); + auto env = tk::inherited_env(); + env["HOME"] = fixture.home.root().string(); + env["USERPROFILE"] = fixture.home.root().string(); + env["GIT_CONFIG_NOSYSTEM"] = "1"; + env["GIT_CONFIG_GLOBAL"] = (fixture.home.root() / "no-global-config").string(); + return tk::run({.argv = std::move(args), .env = std::move(env), .cwd = fixture.home.root()}); + }; + ASSERT_EQ(git({"init", "-q"}).exit_code, 0); + ASSERT_EQ(git({"add", "modules", "tests", "mcpp.toml"}).exit_code, 0); + ASSERT_EQ(git({"-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-qm", "base"}).exit_code, 0); + const auto base = git({"rev-parse", "HEAD"}).out; + auto revision = base; + while (!revision.empty() && std::isspace(static_cast(revision.back()))) revision.pop_back(); + tk::write_file(fixture.home.root() / "modules/base/api.cpp", "module fixture.base;\nint changed = 1;\n"); + ASSERT_EQ(git({"add", "modules/base/api.cpp"}).exit_code, 0); + ASSERT_EQ(git({"-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-qm", "change"}).exit_code, 0); + const auto planned = fixture.run({"ci", "plan", "--platform", "linux", "--lane", "pr", + "--changed", revision, "--discovery", inventory.string()}); + ASSERT_EQ(planned.exit_code, 0) << planned.transcript(); + const auto matrix = json::parse(planned.out); + ASSERT_EQ(matrix["include"].size(), 1); + EXPECT_EQ(matrix["include"][0]["tests"], std::vector{"unit/affected"}); +} + +XTEST(XdevCli, TrendArtifactsPreserveMemberAndPlatformAndFeedFutureShardPlans, + .area = "testkit", .covers = {"CI-TRENDS"}) { + Fixture fixture; + if (!fixture.binary || !*fixture.binary) GTEST_SKIP() << "set XDEV_BIN to the built xdev"; + auto report = [&](std::string run, long long ms, std::string status, fs::path prior = {}) { + const auto input = fixture.home.root() / ("input-" + run); + const auto output = fixture.home.root() / ("report-" + run); + tk::write_file(input / "lane.json", json{{"name", "linux-unit"}, {"platform", "linux"}, + {"run", run}, {"declared", json::array()}, {"probes", json::object()}}.dump()); + tk::write_file(input / "mcpp.ndjson", json{{"member", "xdev"}, {"test", "unit/slow"}, + {"status", status}, {"duration_ms", ms}}.dump() + "\n"); + std::vector args{"report", "--in", input.string(), "--write", output.string(), + "--timings-out", (fixture.home.root() / "timings.json").string()}; + if (!prior.empty()) args.insert(args.end(), {"--trend", prior.string()}); + return std::pair{fixture.run(args), output}; + }; + const auto [first, baseline] = report("1", 3000, "pass"); + ASSERT_EQ(first.exit_code, 0) << first.transcript(); + const auto [second, latest] = report("2", 6000, "pass", baseline / "trend.json"); + ASSERT_EQ(second.exit_code, 0) << second.transcript(); + EXPECT_NE(second.out.find("100.0%"), std::string::npos) << second.out; + auto history = json::parse(tk::read_file(latest / "trend.json")); + EXPECT_EQ(history.at("observations").size(), 2); + const auto timing = json::parse(tk::read_file(fixture.home.root() / "timings.json")); + EXPECT_EQ(timing.at("linux:xdev:unit/slow"), 4500); + const auto [failed, failure] = report("3", 99999, "fail", latest / "trend.json"); + EXPECT_EQ(failed.exit_code, 1) << failed.transcript(); + EXPECT_NE(failed.out.find("both passing and failing"), std::string::npos) << failed.out; + EXPECT_EQ(json::parse(tk::read_file(fixture.home.root() / "timings.json")), timing) + << "failed duration never becomes a shard weight"; + // Re-render the same execution; it must not manufacture another sample. + const auto duplicate = fixture.run({"report", "--in", (fixture.home.root() / "input-3").string(), + "--trend", (failure / "trend.json").string(), "--write", (fixture.home.root() / "repeat").string()}); + EXPECT_EQ(duplicate.exit_code, 1); + EXPECT_EQ(json::parse(tk::read_file(fixture.home.root() / "repeat/trend.json"))["observations"].size(), 3); + tk::write_file(fixture.home.root() / "tests/unit/slow.cpp", "import std;"); + const auto inventory = fixture.discovery({json{{"member", "xdev"}, {"test", "unit/slow"}, + {"main", "tests/unit/slow.cpp"}, {"cases", json::array({test_case("Slow.Check")})}}}); + const auto plan = fixture.run({"ci", "plan", "--platform", "linux", "--discovery", inventory.string(), + "--timings", (fixture.home.root() / "timings.json").string()}); + ASSERT_EQ(plan.exit_code, 0) << plan.transcript(); + const auto matrix = json::parse(plan.out); + EXPECT_EQ(matrix["include"][0]["estimated_ms"], 4500); +} diff --git a/apps/xdev/tests/test_fixture.cpp b/apps/xdev/tests/test_fixture.cpp new file mode 100644 index 000000000..dfa6a6933 --- /dev/null +++ b/apps/xdev/tests/test_fixture.cpp @@ -0,0 +1,157 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" +import std; +import xlings.platform; +import xlings.xdev.fixture; +import xlings.libs.json; + +namespace tk = xlings::testkit; +namespace fixture = xlings::xdev::fixture; +namespace platform = xlings::platform; + +namespace { +std::expected fetch(std::uint16_t port, std::string request) { + auto connection = platform::TcpStream::connect_loopback(port); + if (!connection) return std::unexpected(connection.error()); + auto sent = connection->send_all(request, std::chrono::seconds(2)); + if (!sent) return std::unexpected(sent.error()); + std::string response; + std::array bytes{}; + for (;;) { + auto read = connection->receive(bytes, std::chrono::seconds(2)); + if (!read) return std::unexpected(read.error()); + if (*read == 0) return response; + response.append(bytes.data(), *read); + } +} +} + +XTEST(XdevFixture, RealLoopbackServerServesExactBytesAndStopsWithItsOwner, + .area = "testkit", .covers = {"TEST-HTTP-FIXTURE"}) { + auto home = tk::Home::isolated("http-fixture"); + const std::string payload{"payload\0binary", 14}; + tk::write_file(home.root() / "fixture/payload.bin", payload); + auto server = fixture::Server::start(home.root() / "fixture"); + ASSERT_TRUE(server) << server.error(); + const auto port = server->port(); + EXPECT_NE(port, 0); + EXPECT_EQ(server->url(), "http://127.0.0.1:" + std::to_string(port)); + for (int attempt = 0; attempt < 3; ++attempt) { + const auto response = fetch(port, "GET /payload.bin HTTP/1.1\r\nHost: localhost\r\n\r\n"); + ASSERT_TRUE(response) << response.error(); + const auto header = response->find("\r\n\r\n"); + ASSERT_NE(header, std::string::npos); + EXPECT_TRUE(response->starts_with("HTTP/1.1 200 OK\r\n")); + EXPECT_EQ(response->substr(header + 4), payload); + EXPECT_NE(response->find("Content-Length: 14\r\n"), std::string::npos); + } + const auto head = fetch(port, "HEAD /payload.bin HTTP/1.1\r\n\r\n"); + ASSERT_TRUE(head) << head.error(); + EXPECT_TRUE(head->ends_with("\r\n\r\n")); + EXPECT_NE(head->find("Content-Length: 14\r\n"), std::string::npos); + server->stop(); + EXPECT_FALSE(platform::TcpStream::connect_loopback(port, std::chrono::milliseconds(200))); +} + +XTEST(XdevFixture, RejectsTraversalMalformedPathsAndSymlinkEscapes, + .area = "testkit", .covers = {"TEST-HTTP-FIXTURE"}) { + auto home = tk::Home::isolated("http-boundary"); + tk::write_file(home.root() / "fixture/ok.txt", "ok"); + tk::write_file(home.root() / "secret.txt", "private"); + const auto root = home.root() / "fixture"; + EXPECT_EQ(fixture::respond(root, "GET /ok.txt?query=1 HTTP/1.1\r\n\r\n").body, "ok"); + for (const auto* url : {"/../secret.txt", "/%2e%2e/secret.txt", "/%2Fetc/passwd", "/bad%00name", + "/bad%xxname", "/C:%5csecret.txt", "/%5csecret.txt"}) + EXPECT_EQ(fixture::respond(root, std::string("GET ") + url + " HTTP/1.1\r\n\r\n").status, 403) << url; + EXPECT_EQ(fixture::respond(root, "POST /ok.txt HTTP/1.1\r\n\r\n").status, 405); + EXPECT_EQ(fixture::respond(root, "GET /ok.txt nonsense\r\n\r\n").status, 400); + EXPECT_EQ(fixture::respond(root, "GET /absent.txt HTTP/1.1\r\n\r\n").status, 404); + std::error_code ec; + std::filesystem::create_symlink(home.root() / "secret.txt", root / "escape.txt", ec); + if (!ec) EXPECT_EQ(fixture::respond(root, "GET /escape.txt HTTP/1.1\r\n\r\n").status, 403); +} + +XTEST(XdevFixture, ConcurrentOwnersReserveDifferentPortsAndSlowRequestsCannotBlockShutdown, + .area = "testkit", .covers = {"TEST-HTTP-FIXTURE"}) { + auto home = tk::Home::isolated("http-lifecycle"); + auto first = fixture::Server::start(home.root()); + auto second = fixture::Server::start(home.root()); + ASSERT_TRUE(first) << first.error(); + ASSERT_TRUE(second) << second.error(); + EXPECT_NE(first->port(), second->port()); + auto slow = platform::TcpStream::connect_loopback(first->port()); + ASSERT_TRUE(slow) << slow.error(); + ASSERT_TRUE(slow->send_all("GET /", std::chrono::seconds(1))); + const auto started = std::chrono::steady_clock::now(); + first->stop(); + EXPECT_LT(std::chrono::steady_clock::now() - started, std::chrono::seconds(1)); + const auto response = fetch(second->port(), "GET /missing HTTP/1.1\r\n\r\n"); + ASSERT_TRUE(response) << response.error(); + EXPECT_TRUE(response->starts_with("HTTP/1.1 404 Not Found\r\n")); +} + + +XTEST(XdevFixture, CliChildConsumesAnExplicitLoopbackFixture, + .area = "testkit", .resources = {"port:8080"}) { + if (!std::getenv("XDEV_HTTP_CHILD")) return; + const auto* environment = std::getenv("XLINGS_TEST_FIXTURE_URL"); + ASSERT_NE(environment, nullptr); + const std::string_view url{environment}; + constexpr std::string_view PREFIX { "http://127.0.0.1:" }; + ASSERT_TRUE(url.starts_with(PREFIX)); + unsigned port { 0 }; + const auto number = url.substr(PREFIX.size()); + const auto [end, error] = std::from_chars(number.data(), number.data() + number.size(), port); + ASSERT_EQ(error, std::errc{}); + ASSERT_EQ(end, number.data() + number.size()); + ASSERT_LE(port, 65535); + const auto response = fetch(static_cast(port), "GET /payload.txt HTTP/1.1\r\n\r\n"); + ASSERT_TRUE(response) << response.error(); + EXPECT_TRUE(response->ends_with("fixture-child-payload")); +} + +XTEST(XdevFixture, CliRunsArtifactWorkersWithFixtureUrlAndMergesTheirRecords, + .area = "testkit", .covers = {"TEST-HTTP-FIXTURE", "CI-RESOURCE-LOCKS"}) { + const auto* binary = std::getenv("XDEV_BIN"); + if (!binary || !*binary) GTEST_SKIP() << "set XDEV_BIN to the built xdev"; + auto home = tk::Home::isolated("fixture-cli"); + tk::write_file(home.root() / "mcpp.toml", "[package]\nname = \"fixture-cli\"\n"); + tk::write_file(home.root() / "fixture/payload.txt", "fixture-child-payload"); + std::string discovery; + const std::string child { "XdevFixture.CliChildConsumesAnExplicitLoopbackFixture" }; + for (const auto* name : {"a", "b"}) { + const auto source = "tests/unit/" + std::string(name) + ".cpp"; + tk::write_file(home.root() / source, "import std;"); + auto destination = home.root() / "target/fixture/bin/unit" / name; + if constexpr (tk::is_windows) destination += ".exe"; + std::filesystem::create_directories(destination.parent_path()); + std::filesystem::copy_file(platform::get_executable_path(), destination); + nlohmann::json row{{"member", ""}, {"test", "unit/" + std::string(name)}, {"main", source}, + {"cases", {{{"test", child}, {"area", "testkit"}, {"resources", {"port:8080"}}}}}}; + discovery += row.dump() + "\n"; + } + const auto input = home.root() / "discovery.ndjson"; + tk::write_file(input, discovery); + const auto out = home.root() / "run"; + auto env = tk::inherited_env(); + env["XDEV_HTTP_CHILD"] = "1"; + // Child invocations must not append to this program's own result streams. + env.erase("XTEST_META_OUT"); + env.erase("XTEST_RESULTS_OUT"); + const auto run = tk::run({.argv = {binary, "test", "--no-build", "--discovery", input.string(), + "-j", "2", "--fixture", (home.root() / "fixture").string(), "--out", out.string(), + "--", "--", "--gtest_filter=" + child}, .env = std::move(env), .cwd = home.root(), + .timeout = std::chrono::seconds(30)}); + ASSERT_EQ(run.exit_code, 0) << run.transcript(); + const auto report = nlohmann::json::parse(tk::read_file(out / "report.json")); + int programs { 0 }, cases { 0 }; + for (const auto& row : report["records"]) { + if (row["kind"] == "binary") { ++programs; EXPECT_EQ(row["status"], "pass"); } + if (row["kind"] == "case") { ++cases; EXPECT_EQ(row["status"], "pass"); } + } + EXPECT_EQ(programs, 2); + EXPECT_EQ(cases, 2); + EXPECT_TRUE(std::filesystem::is_regular_file(out / "workers/0/cases.ndjson")); + EXPECT_TRUE(std::filesystem::is_regular_file(out / "workers/1/cases.ndjson")); +} diff --git a/apps/xdev/tests/test_home_fixture.cpp b/apps/xdev/tests/test_home_fixture.cpp new file mode 100644 index 000000000..0d3e241ad --- /dev/null +++ b/apps/xdev/tests/test_home_fixture.cpp @@ -0,0 +1,38 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" +import std; +import xlings.libs.json; +import xlings.testkit.index_fixture; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; + +XTEST(HttpFixture, DefaultHomeInitializesAndInstallsThroughItsOwnedLoopbackMirror, + .area = "testkit", .covers = {"TEST-HTTP-FIXTURE"}, .requires_ = {"xlings-bin"}) { + auto home = tk::Home::isolated("default-http"); + const auto config = nlohmann::json::parse(tk::read_file(home.dir() / ".xlings.json")); + const auto base = xlings::testkit::index_fixture::url(); + ASSERT_TRUE(base.starts_with("http://127.0.0.1:")); + EXPECT_EQ(config["XLINGS_RES"]["GLOBAL"][0], base); + EXPECT_EQ(config["index_repos"][0]["source"], "git"); + const auto entry = home.dir() / "bin" / (tk::is_windows ? "xlings.exe" : "xlings"); + fs::create_directories(entry.parent_path()); + fs::copy_file(tk::xlings_binary(), entry); + const auto initialized = home.xlings({"self", "init"}); + ASSERT_EQ(initialized.exit_code, 0) << initialized.transcript(); + const auto installed = home.xlings({"install", "xim:fixture-data@1.0.0", "-y"}); + ASSERT_EQ(installed.exit_code, 0) << installed.transcript(); + const auto payload = home.dir() / "data/xpkgs/xim-x-fixture-data/1.0.0"; + bool found = false; + for (const auto& file : fs::recursive_directory_iterator(payload)) { + if (file.path().filename() == "fixture.txt") { + EXPECT_EQ(tk::read_file(file.path()), "xlings HTTP fixture\n"); + found = true; + } + } + EXPECT_TRUE(found) << installed.transcript(); + const auto missing = home.xlings({"install", "xim:no-public-fixture-package@1.0.0", "-y"}); + EXPECT_NE(missing.exit_code, 0) << missing.transcript(); + EXPECT_LT(missing.elapsed, std::chrono::seconds(10)); +} diff --git a/apps/xdev/tests/test_loopback_http.cpp b/apps/xdev/tests/test_loopback_http.cpp new file mode 100644 index 000000000..9c83307a9 --- /dev/null +++ b/apps/xdev/tests/test_loopback_http.cpp @@ -0,0 +1,97 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" +import std; +import xlings.platform; +import xlings.platform.loopback_http; +import xlings.testkit.fixture; + +namespace tk = xlings::testkit; +namespace http = xlings::platform::loopback_http; +namespace platform = xlings::platform; +namespace fs = std::filesystem; + +XTEST(LoopbackHttp, DownloadsExactBytesAndAttributesADestinationFailure, + .area = "testkit", .covers = {"TEST-HTTP-FIXTURE"}) { + auto home = tk::Home::isolated("loopback-transfer"); + const std::string body{"payload\0binary", 14}; + tk::write_file(home.root() / "served/data.bin", body); + auto server = tk::fixture::Server::start(home.root() / "served"); + ASSERT_TRUE(server) << server.error(); + std::vector progress; + const auto result = http::download(server->url() + "/data.bin", home.root() / "download", + std::chrono::seconds(2), [&](double total, double received) { + EXPECT_EQ(total, body.size()); progress.push_back(received); + }); + ASSERT_TRUE(result.success) << result.error; + EXPECT_EQ(tk::read_file(home.root() / "download"), body); + EXPECT_EQ(result.written, body.size()); + ASSERT_FALSE(progress.empty()); + EXPECT_EQ(progress.back(), body.size()); + const auto badDestination = http::download(server->url() + "/data.bin", home.root(), std::chrono::seconds(2)); + EXPECT_EQ(badDestination.failure, http::Failure::Local); + const auto missing = http::download(server->url() + "/missing", home.root() / "download", std::chrono::seconds(2)); + EXPECT_EQ(missing.failure, http::Failure::Source); + EXPECT_EQ(tk::read_file(home.root() / "download"), body); +} + +XTEST(LoopbackHttp, RejectsExternalAddressesAndMalformedSourcesBeforeWriting, + .area = "testkit", .covers = {"TEST-HTTP-FIXTURE"}) { + auto home = tk::Home::isolated("loopback-protocol"); + const auto destination = home.root() / "user-file"; + tk::write_file(destination, "keep"); + for (const auto* url : {"http://example.com/a", "http://localhost:80/a", "http://127.0.0.1:0/a", + "http://127.0.0.1:80@other/a", "http://127.0.0.1:80/a\r\nInjected: true"}) { + const auto result = http::download(url, destination, std::chrono::seconds(1)); + EXPECT_EQ(result.failure, http::Failure::Source); + EXPECT_EQ(tk::read_file(destination), "keep"); + } + for (const auto* response : { + "HTTP/1.1 302 Found\r\nLocation: https://example.com\r\nContent-Length: 0\r\n\r\n", + "HTTP/1.1 200 OK\r\nContent-Length: 1\r\nContent-Length: 1\r\n\r\nx", + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n"}) { + auto listener = platform::LoopbackListener::open(); + ASSERT_TRUE(listener); + std::jthread owner([&](std::stop_token stop) { + while (!stop.stop_requested()) { + auto accepted = listener->accept(std::chrono::milliseconds(50)); + if (!accepted) return; + if (!*accepted) continue; + std::array request{}; + (void)(**accepted).receive(request, std::chrono::seconds(1)); + (void)(**accepted).send_all(response, std::chrono::seconds(1)); + return; + } + }); + const auto result = http::download(std::format("http://127.0.0.1:{}/data", listener->port()), + destination, std::chrono::seconds(2)); + EXPECT_EQ(result.failure, http::Failure::Source) << result.error; + EXPECT_EQ(tk::read_file(destination), "keep"); + } +} + +XTEST(LoopbackHttp, CancellationInterruptsAStalledBodyWithoutWaitingForTheDeadline, + .area = "testkit", .covers = {"TEST-HTTP-FIXTURE"}) { + auto home = tk::Home::isolated("loopback-cancel"); + auto listener = platform::LoopbackListener::open(); + ASSERT_TRUE(listener); + std::jthread owner([&](std::stop_token stop) { + while (!stop.stop_requested()) { + auto accepted = listener->accept(std::chrono::milliseconds(50)); + if (!accepted) return; + if (!*accepted) continue; + std::array request{}; + (void)(**accepted).receive(request, std::chrono::seconds(1)); + (void)(**accepted).send_all("HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\n", std::chrono::seconds(1)); + while (!stop.stop_requested()) std::this_thread::sleep_for(std::chrono::milliseconds(5)); + return; + } + }); + const auto start = std::chrono::steady_clock::now(); + const auto result = http::download(std::format("http://127.0.0.1:{}/data", listener->port()), + home.root() / "download", std::chrono::seconds(30), {}, [&] { + return std::chrono::steady_clock::now() - start > std::chrono::milliseconds(200); + }); + EXPECT_EQ(result.failure, http::Failure::Cancelled) << result.error; + EXPECT_LT(std::chrono::steady_clock::now() - start, std::chrono::seconds(2)); +} diff --git a/apps/xdev/tests/test_resources.cpp b/apps/xdev/tests/test_resources.cpp new file mode 100644 index 000000000..a48d42112 --- /dev/null +++ b/apps/xdev/tests/test_resources.cpp @@ -0,0 +1,99 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" +import std; +import xlings.platform; +import xlings.xdev.resources; +import xlings.xdev.selection; + +namespace tk = xlings::testkit; +namespace resource = xlings::xdev::resources; +namespace sel = xlings::xdev::selection; + +XTEST(XdevResources, RejectsUnsafeNamesAndMakesCpuRequestsExclusive, + .area = "testkit", .covers = {"CI-RESOURCE-LOCKS"}) { + for (const auto* bad : {"", "../escape", "port:0", "port:65536", "port:no", "cpu:0", "cpu:no", "bad key"}) + EXPECT_FALSE(resource::normalize(bad)) << bad; + EXPECT_EQ(*resource::normalize("cpu:8"), "cpu"); + EXPECT_EQ(*resource::normalize("cpu:all"), "cpu"); + EXPECT_EQ(*resource::normalize("port:08080"), "port:8080"); + EXPECT_EQ(*resource::normalize("opaque-resource:v1"), "opaque-resource:v1"); + const std::vector tests{{.id = "unit/mixed", .source = "tests/unit/mixed.cpp", + .cases = {{.name = "Cases.Fast", .resources = {"cpu:2", "port:8080"}}, + {.name = "Cases.Network", .requires_ = {"network"}, .resources = {"port:9000"}}}, + .metadata_known = true}}; + const auto selected = sel::select(tests, {.lane = "pr"}); + ASSERT_TRUE(selected); + ASSERT_EQ(selected->size(), 1); + EXPECT_EQ(selected->front().resources, (std::vector{"cpu", "port:8080"})); +} + +XTEST(XdevResources, SharedKeysSerializeWhileIndependentKeysRunInParallel, + .area = "testkit", .covers = {"CI-RESOURCE-LOCKS"}) { + auto home = tk::Home::isolated("resource-scheduler"); + std::atomic active { 0 }, maximum { 0 }, completed { 0 }; + std::vector tasks; + for (int index = 0; index < 4; ++index) { + tasks.push_back({.id = std::to_string(index), .resources = {index % 2 ? "cpu:4" : "cpu:1", "port:8080"}, + .run = [&] { + const int count = ++active; + int prior = maximum.load(); + while (prior < count && !maximum.compare_exchange_weak(prior, count)) {} + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + --active; + ++completed; + return 0; + }}); + } + const auto serial = resource::run(tasks, 4, home.root() / "locks"); + ASSERT_TRUE(serial); + EXPECT_EQ(maximum.load(), 1); + EXPECT_EQ(completed.load(), 4); + for (const auto& result : *serial) EXPECT_EQ(result.exit_code, 0) << result.error; + std::atomic entered { 0 }; + auto both = [&] { + ++entered; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); + while (entered.load() < 2 && std::chrono::steady_clock::now() < deadline) + std::this_thread::yield(); + return entered.load() == 2 ? 0 : 42; + }; + const std::vector independent{ + {.id = "a", .resources = {"port:8081"}, .run = both}, + {.id = "b", .resources = {"port:8082"}, .run = both}}; + const auto parallel = resource::run(independent, 2, home.root() / "locks"); + ASSERT_TRUE(parallel); + for (const auto& result : *parallel) EXPECT_EQ(result.exit_code, 0) << result.error; +} + +XTEST(XdevResources, LeaseExcludesAnotherProcessAndReleasesForItsNextAttempt, + .area = "testkit", .covers = {"CI-RESOURCE-LOCKS"}) { + const std::vector names{"port:8080", "cpu:4", "cpu:1"}; + if (const auto* directory = std::getenv("XDEV_LOCK_CHILD"); directory && *directory) { + const auto lease = resource::Lease::acquire(directory, names, std::chrono::milliseconds(80)); + const bool expected = std::string_view(std::getenv("XDEV_LOCK_EXPECT")) == "available"; + EXPECT_EQ(lease.has_value(), expected); + return; + } + auto home = tk::Home::isolated("cross-process-resource-lock"); + const auto directory = home.root() / "locks"; + auto child = [&](std::string expectation) { + auto env = tk::inherited_env(); + env["XDEV_LOCK_CHILD"] = directory.string(); + env["XDEV_LOCK_EXPECT"] = expectation; + env.erase("XTEST_META_OUT"); + env.erase("XTEST_RESULTS_OUT"); + return tk::run({.argv = {xlings::platform::get_executable_path().string(), + "--gtest_filter=XdevResources.LeaseExcludesAnotherProcessAndReleasesForItsNextAttempt"}, + .env = std::move(env), .timeout = std::chrono::seconds(10)}); + }; + { + auto lease = resource::Lease::acquire(directory, names, std::chrono::seconds(1)); + ASSERT_TRUE(lease) << lease.error(); + const auto blocked = child("blocked"); + EXPECT_EQ(blocked.exit_code, 0) << blocked.transcript(); + } + const auto released = child("available"); + EXPECT_EQ(released.exit_code, 0) << released.transcript(); + EXPECT_TRUE(std::filesystem::is_directory(directory)) << "persistent lock files are not removed"; +} diff --git a/apps/xdev/tests/test_selection.cpp b/apps/xdev/tests/test_selection.cpp new file mode 100644 index 000000000..4c621e8a3 --- /dev/null +++ b/apps/xdev/tests/test_selection.cpp @@ -0,0 +1,147 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" +import std; +import xlings.xdev.selection; +import xlings.libs.json; + +namespace sel = xlings::xdev::selection; +namespace fs = std::filesystem; + +XTEST(XdevSelection, DependenciesFollowImplementationsPartitionsAndHeadersTransitively, + .area = "testkit", .covers = {"CI-TEST-SELECTION"}) { + std::vector graph{ + sel::scan_source("modules/base/api.cppm", "export module base; export import :part;"), + sel::scan_source("modules/base/part.cppm", "export module base:part;"), + sel::scan_source("modules/base/api.cpp", "module base;\n#include \"local.hpp\""), + sel::scan_source("modules/base/local.hpp", "// nothing"), + sel::scan_source("modules/upper/api.cppm", "export module upper; import base;"), + sel::scan_source("tests/unit/a.cpp", "import upper;"), + sel::scan_source("tests/unit/b.cpp", "import elsewhere;"), + sel::scan_source("tests/e2e/c.cpp", "import std;")}; + const std::vector tests{ + {.id = "unit/a", .source = "tests/unit/a.cpp"}, + {.id = "unit/b", .source = "tests/unit/b.cpp"}, + {.id = "e2e/c", .source = "tests/e2e/c.cpp"}}; + for (const auto& path : {"modules/base/api.cpp", "modules/base/part.cppm", "modules/base/local.hpp"}) { + const std::vector changed{path}; + const auto impacted = sel::affected(tests, graph, changed); + EXPECT_TRUE(impacted.contains("unit/a")) << path; + EXPECT_FALSE(impacted.contains("unit/b")) << path; + EXPECT_TRUE(impacted.contains("e2e/c")) << "black-box e2e exercises the changed product"; + } + const std::vector docs{"docs/readme.md"}; + EXPECT_TRUE(sel::affected(tests, graph, docs).empty()); + const std::vector removed{"modules/base/deleted.cppm"}; + EXPECT_EQ(sel::affected(tests, graph, removed).size(), tests.size()) << "unknown removed source is conservative"; +} + +XTEST(XdevSelection, CommentsAndFixtureStringsCannotInventModuleImports, + .area = "testkit", .covers = {"CI-TEST-SELECTION"}) { + const auto source = sel::scan_source("tests/a.cpp", R"fixture( + // import comment; + /* export module wrong; */ + const auto fixture = R"(import fake; export module fake;)"; + const char* text = "import quoted;"; + export module real:part; + import :other; + import actual.api; + #include "local.hpp" + )fixture"); + EXPECT_EQ(source.module, "real:part"); + EXPECT_EQ(source.imports, (std::vector{"real:other", "actual.api"})); + EXPECT_EQ(source.includes, (std::vector{"local.hpp"})); +} + +XTEST(XdevSelection, PrKeepsFastTestsButSelectsAffectedIsolationAndNeverNetwork, + .area = "testkit", .covers = {"CI-TEST-SELECTION"}) { + const std::vector tests{ + {.id = "unit/fast", .source = "tests/unit/fast.cpp", .cases = {{.name = "Unit.Fast"}}, .metadata_known = true}, + {.id = "e2e/mixed", .source = "tests/e2e/mixed.cpp", .cases = { + {.name = "Flow.Fast", .area = "subos"}, + {.name = "Isolation.Real", .area = "subos", .requires_ = {"linux", "sandbox"}}, + {.name = "Download.Network", .area = "xim", .requires_ = {"network"}}, + {.name = "Mac.Platform", .requires_ = {"macos"}}}, .metadata_known = true}}; + sel::Options options{.lane = "pr", .platform = "linux", .changed = true}; + auto unrelated = sel::select(tests, options); + ASSERT_TRUE(unrelated.has_value()); + ASSERT_EQ(unrelated->size(), 2); + EXPECT_EQ(unrelated->front().filter, "Flow.Fast"); + auto changed = sel::select(tests, options, {"e2e/mixed"}); + ASSERT_TRUE(changed.has_value()); + EXPECT_EQ(changed->front().filter, "Flow.Fast:Isolation.Real"); + options.lane = "main"; + auto main = sel::select(tests, options); + ASSERT_TRUE(main.has_value()); + EXPECT_EQ(main->front().filter, "Flow.Fast:Isolation.Real:Download.Network"); + options.platform = "macos"; + auto mac = sel::select(tests, options); + ASSERT_TRUE(mac.has_value()); + EXPECT_EQ(mac->front().filter, "Flow.Fast:Download.Network:Mac.Platform"); +} + +XTEST(XdevSelection, HistoricalDurationShardsAreDeterministicDisjointAndComplete, + .area = "testkit", .covers = {"CI-TEST-SELECTION"}) { + std::vector tests{ + {.id = "a", .source = "tests/unit/a.cpp", .duration_ms = 9}, + {.id = "b", .source = "tests/unit/b.cpp", .duration_ms = 8}, + {.id = "c", .source = "tests/unit/c.cpp", .duration_ms = 2}, + {.id = "d", .source = "tests/unit/d.cpp", .duration_ms = 1}}; + ASSERT_TRUE(sel::apply_timings(tests, nlohmann::json{{"a", 90}, {"b", 80}, {"c", 20}, {"d", 10}}).has_value()); + sel::Options options{.platform = "linux", .shards = 2}; + const auto plan = sel::matrix(tests, options); + ASSERT_EQ(plan["include"].size(), 2); + EXPECT_EQ(plan["include"][0]["estimated_ms"], 100); + EXPECT_EQ(plan["include"][1]["estimated_ms"], 100); + std::set unionIds; + for (std::size_t shard = 1; shard <= 2; ++shard) { + options.shard = shard; + const auto selected = sel::select(tests, options); + ASSERT_TRUE(selected.has_value()); + std::vector ids; + for (const auto& test : *selected) { + EXPECT_TRUE(unionIds.insert(test.test.id).second) << "a test occurs in more than one shard"; + ids.push_back(test.test.id); + } + EXPECT_EQ(plan["include"][shard - 1]["tests"], ids) << "plan and execution select the same IDs"; + } + EXPECT_EQ(unionIds.size(), tests.size()); + std::ranges::reverse(tests); + options.shard = 0; + EXPECT_EQ(sel::matrix(tests, options), plan); +} + +XTEST(XdevSelection, InvalidSelectorsTimingsAndUnknownMetadataFailExplicitly, + .area = "testkit", .covers = {"CI-TEST-SELECTION"}) { + for (auto bad : {"0/2", "3/2", "1/0", "1/1025", "1", "1/2x", "-1/2"}) + EXPECT_FALSE(sel::parse_shard(bad).has_value()) << bad; + EXPECT_EQ(sel::parse_shard("2/3").value(), (std::pair{2, 3})); + std::vector tests{{.id = "a", .source = "tests/unit/a.cpp"}}; + sel::Options options{.lane = "unknown"}; + EXPECT_FALSE(sel::select(tests, options).has_value()); + options.lane = "pr"; + options.require_metadata = true; + EXPECT_FALSE(sel::select(tests, options).has_value()); + options.require_metadata = false; + options.area = "subos"; + EXPECT_FALSE(sel::select(tests, options).has_value()); + EXPECT_FALSE(sel::apply_timings(tests, nlohmann::json{{"a", -1}}).has_value()); +} + +XTEST(XdevSelection, DiscoveryRequiresRealSourceFilesAndRejectsDuplicateCaseFilters, + .area = "testkit", .covers = {"CI-TEST-SELECTION"}) { + auto home = xlings::testkit::Home::isolated("selection-discovery"); + xlings::testkit::write_file(home.root() / "tests/unit/a.cpp", "import std;"); + const auto record = nlohmann::json{{"member", ""}, {"test", "unit/a"}, {"main", "tests/unit/a.cpp"}}; + ASSERT_EQ(sel::discovery(record.dump() + "\n", home.root())->size(), 1); + EXPECT_FALSE(sel::discovery(record.dump() + "\n" + record.dump() + "\n", home.root()).has_value()); + auto invalid = record; + invalid["main"] = "../outside.cpp"; + EXPECT_FALSE(sel::discovery(invalid.dump(), home.root()).has_value()); + invalid = record; + invalid["main"] = "tests/unit/missing.cpp"; + EXPECT_FALSE(sel::discovery(invalid.dump(), home.root()).has_value()); + invalid = record; + invalid["cases"] = nlohmann::json::array({{{"test", "Suite.Case"}}, {{"test", "Suite.Case"}}}); + EXPECT_FALSE(sel::discovery(invalid.dump(), home.root()).has_value()); +} diff --git a/config/aur/PKGBUILD b/config/aur/PKGBUILD index f4d218f6d..9e403b470 100644 --- a/config/aur/PKGBUILD +++ b/config/aur/PKGBUILD @@ -1,37 +1,33 @@ # Maintainer: sunrisepeak +# +# Deployment S (SubOS design part 2 §5): /usr/bin/xlings belongs to pacman. +# Each user still has a home of their own (~/.xlings); the first command that +# writes one links its entry to this binary, so `pacman -Syu` upgrades every +# user and `xlings self update` points at pacman instead of replacing it. +# +# Nothing is installed but the binary: a user's home is created by xlings on +# first use, and it is user data -- removing this package never touches it. +# +# CI builds this package from the binary under test: +# XLINGS_LOCAL_TARBALL=/path/xlings--linux-x86_64.tar.gz makepkg pkgname=xlings -pkgver=0.4.14 +pkgver=2026.10.4.1 pkgrel=1 -pkgdesc="A Developer's Toolkit for Programming Learning, Development, and Tutorial Creation" +pkgdesc="Universal package infrastructure with OS-like SubOS isolation" arch=('x86_64') url="https://github.com/openxlings/xlings" license=('Apache-2.0') depends=() -source=( - "https://github.com/openxlings/xlings/releases/download/v${pkgver}/xlings-${pkgver}-linux-x86_64.tar.gz" - "xlings-remove.hook" -) -sha256sums=( - '4d5ba18fb5f8b32ec899c43c64719302445fe13eec952629f28cce9d8c400b71' - 'SKIP' -) +optdepends=('bubblewrap: kernel-enforced SubOS sandboxes' + 'passt: private networks (net=nat) for SubOS sandboxes') +if [[ -n ${XLINGS_LOCAL_TARBALL:-} ]]; then + source=("$XLINGS_LOCAL_TARBALL") + sha256sums=('SKIP') +else + source=("https://github.com/openxlings/xlings/releases/download/v${pkgver}/xlings-${pkgver}-linux-x86_64.tar.gz") + sha256sums=('721f0e3752f5a43b023ed12d6327757ffb51b7814e302c1c88e3c3bf4b00a0b0') +fi package() { - cd "$srcdir/$pkgname-$pkgver-linux-x86_64" - - # Install binary - install -Dm755 bin/xlings "$pkgdir/usr/bin/xlings" - - # Install all files to /usr/share/xlings - install -d "$pkgdir/usr/share/$pkgname" - cp -r . "$pkgdir/usr/share/$pkgname" - - chmod 766 "$pkgdir/usr/share/$pkgname/.xlings.json" - - # Set permissions for files - # find "$pkgdir/usr/share/$pkgname" -type f -exec chmod 644 {} + - # find "$pkgdir/usr/share/$pkgname" -type d -exec chmod 755 {} + - - # Add hooks - install -Dm644 ../xlings-remove.hook "$pkgdir/usr/share/libalpm/hooks/xlings-remove.hook" + install -Dm755 "$srcdir/xlings-${pkgver}-linux-x86_64/bin/xlings" "$pkgdir/usr/bin/xlings" } diff --git a/config/aur/xlings-remove.hook b/config/aur/xlings-remove.hook deleted file mode 100644 index 3316271a8..000000000 --- a/config/aur/xlings-remove.hook +++ /dev/null @@ -1,9 +0,0 @@ -[Trigger] -Operation = Remove -Type = Package -Target = xlings - -[Action] -Description = xlings self uninstall -When = PreTransaction -Exec = /usr/bin/env xlings self uninstall diff --git a/docs/README.md b/docs/README.md index 857ada2fd..489324dca 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,7 +21,7 @@ macOS/Windows 的重定向不是不受信代码的安全边界。 - **[二、使用指南](#二使用指南)** - [2.1 多版本管理](quick-start/multi-version.md) - [2.2 项目环境](quick-start/project-env.md) - - [2.3 SubOS 与 Agent](quick-start/subos-and-agent.md) + - [2.3 SubOS 使用指南(场景与示例)](quick-start/subos-and-agent.md) - [2.4 自定义包索引](quick-start/custom-index.md) - [2.5 自我管理与修复](quick-start/self-management.md) - **[三、高级主题](#三高级主题)** @@ -61,19 +61,23 @@ xlings remove gcc # 卸载 | 项目环境复现 | 在项目目录放 `.xlings.json` 声明依赖,`xlings install` 一键安装 | | 为 Agent 创建隔离环境 | `xlings subos new agent-ws --from subos:dev-env@latest` | | 进入隔离的 SubOS | `xlings subos use agent-ws --sandbox` | -| 在 SubOS 中执行命令 | `xlings subos use agent-ws --sandbox --cmd "python run.py"` | +| 在 SubOS 中执行命令 | `xlings subos exec agent-ws --sandbox -- python run.py` | +| 创建时声明隔离程度 | `xlings subos new agent-ws --sandbox=private` | | 升级 xlings 自己 | `xlings self update` | | 环境出问题、命令报错 | `xlings self doctor` 体检,`xlings self doctor --fix` 一键修复 | ### 1.4 SubOS 环境隔离 -SubOS 提供三级隔离,满足从日常开发到 Agent 安全执行的不同需求: +SubOS 是有名字的环境:自己的工具版本、home 和策略。隔离程度由策略声明,而不是由进入方式决定: -| 级别 | 隔离范围 | 需要 root | 适用场景 | -|------|----------|:---------:|----------| -| Shell | 工具版本 | 否 | 日常开发、版本切换 | -| FS | 文件系统(HOME, /tmp) | 否 | Agent 运行、实验、不信任代码 | -| Image | 块设备完整隔离 | 是 | 重型工作负载 | +| 层次 | 怎么用 | 隔离 | +|------|--------|------| +| 环境 | `xlings subos use ` | 工具版本(PATH),宿主文件照常可见 | +| 沙箱 | `xlings subos use --sandbox` / `subos exec --sandbox -- ` | Linux:文件系统、进程;macOS / Windows:仅 home | +| 策略 | `xlings subos new --sandbox=dev\|private\|locked`(或之后 `subos config`) | 声明一次,之后每次进入都按它;在沙箱之上再加网络、身份、装包审批、审计 | + +存储(`--storage shared / tmpfs / image`)只决定 home 放在哪里,不增加边界。 +场景和命令见 [SubOS 使用指南](quick-start/subos-and-agent.md),行为说明见 [SubOS 隔离模型](design/subos-isolation.md)。 ### 1.5 包索引 diff --git a/docs/design/subos-isolation.md b/docs/design/subos-isolation.md index 35285e5fb..6ba1f5db5 100644 --- a/docs/design/subos-isolation.md +++ b/docs/design/subos-isolation.md @@ -1,157 +1,239 @@ -> 更新日期:2026-08-03 +> 更新日期:2026-10-08(含 Part 2:根呈现、系统层、Luban) +> +> 总体架构(部署形态、策略、进入与外部执行、可观测性、平台抽象)见 +> `.agents/docs/2026-10-05-subos-architecture-design.md`。本文是落地后的使用与行为说明: +> 每一节写的是现在的实际行为,没有实现的部分在最后一节列出。 # SubOS 隔离模型 ## 概述 -SubOS 提供三级轻量环境隔离,从零开销的环境变量切换到完整块设备隔离,按需递进。隔离级别与存储模式是正交的两个维度,可自由组合。 - -```mermaid -graph TD - subgraph 隔离级别 - L1[Shell 级: env/PATH 切换] - L2[FS 级: bwrap / proot 文件系统视图隔离] - L3[Image 级: ext4 稀疏文件 + loop 挂载] - end - L1 -->|--sandbox| L2 - L2 -->|--storage image| L3 -``` - -## 三级隔离详解 - -### Level 1 — Shell 级 - -- 入口: `xlings subos use ` -- 机制: 仅修改 `$PATH` 和环境变量 (`XLINGS_ACTIVE_SUBOS`),启动子 shell -- 开销: 零额外开销,无特权要求 -- 共享: 与宿主共享完整文件系统视图 -- 提示符: `[xsubos:]` - -### Level 2 — FS 级 (Sandbox) - -- 入口: `xlings subos use --sandbox [bwrap|proot]` -- 机制: 通过 namespace/ptrace 构建隔离的文件系统视图 -- 特点: rootless、私有 HOME 和 /tmp、真实用户身份(非 fake root) -- 提示符: `>` - -宿主只读绑定: -- `/usr`, `/bin`, `/lib`, `/lib64` — POSIX 用户态工具链 -- `/etc/resolv.conf`, `/etc/ssl`, `/etc/ld.so.cache` — 网络与链接器 -- `/proc`, `/sys`, `/dev` — 内核接口 - -沙箱读写: -- `/home/` — 私有用户目录 -- `/tmp` — 私有临时目录 -- `~/.xlings` — 与宿主共享 (工具链/配置) - -### Level 3 — Image 级 - -- 创建: `xlings subos new --storage image` -- 机制: `truncate` 创建稀疏文件 + `mkfs.ext4` 格式化 + `sudo mount -o loop` 挂载 -- 结果: `/home` 完全位于独立块设备,具备 inode/权限/quota 隔离 -- 默认大小: 50G(稀疏文件,实际占用按需增长) -- 卸载: `sudo umount` 自动清理 - -## 后端选择策略 - -```mermaid -flowchart LR - A[detect_backend_] --> B{xim:bwrap 存在?} - B -->|是| C{userns probe 通过?} - C -->|是| D[使用 bwrap] - C -->|否| E{xim:proot 存在?} - B -->|否| E - E -->|是| F[使用 proot] - E -->|否| G[自动安装 bwrap → 重试 → proot → error] -``` - -| 后端 | 机制 | 特权要求 | 存储模式支持 | -|------|------|----------|--------------| -| bwrap | setuid + mount namespace | xim 安装时 chmod 4755 | Shared / Tmpfs / Image | -| proot | ptrace 系统调用拦截 | 无 | 仅 Shared | - -bwrap 为首选后端。系统 `/usr/bin/bwrap` 被跳过 (缺 setuid、AppArmor 限制);仅使用 xim-pool 管理的 setuid 二进制。Image/Tmpfs 存储模式要求 bwrap (需要 mount namespace),proot 不支持。 - -## 存储模式与隔离级别 - -两轴正交设计: - -| 存储模式 | Shell 级行为 | Sandbox 级行为 | -|----------|-------------|---------------| -| **Shared** | 仅 env 切换 | 绑定 `subos//home/` 和 `tmp/` | -| **Tmpfs** | 仅 env 切换 (存储休眠) | bwrap `--tmpfs /home --tmpfs /tmp`,退出即消失 | -| **Image** | 仅 env 切换 (存储休眠) | loop mount `home.img` → 绑定到 `/home` | - -Shell 级入口永远不触发挂载操作;非 Shared 存储仅在 `--sandbox` 时生效。 - -## 沙箱目录布局 - -``` -~/.xlings/subos// - bin/ # subos 专属工具链 (shim) - home/ # Shared 模式: 沙箱用户 HOME - / - .bashrc - .profile - .config/fish/config.fish - .zshrc - .xlings -> ~/.xlings (宿主共享) - tmp/ # Shared 模式: 沙箱 /tmp - etc/ # NSS 模板 (passwd, group, hosts, nsswitch.conf) - sandbox-root/ # proot chroot 根 (避免路径反转 bug) - home.img # Image 模式: ext4 稀疏文件 - .mountpoint/ # Image 模式: loop 挂载点 - .xlings.json # subos 元数据 (storage, name, ...) -``` - -## 安全模型 - -**共享 (对外可见):** -- 宿主 `/usr`, `/bin`, `/lib*` (只读) -- `~/.xlings` 目录 (读写,工具链共享) -- `/proc`, `/sys`, `/dev` - -**私有 (对外不可见):** -- `/home/` — 沙箱独占 -- `/tmp` — 沙箱独占 -- `/etc/passwd`, `/etc/group` — 沙箱自有模板 (仅包含 root + 当前用户) -- 宿主 HOME、其他用户目录 — 不绑定 - -**限制:** -- 禁止嵌套沙箱 (检测 `XLINGS_SUBOS_MODE=sandbox`) -- proot 后端无法阻止 ptrace escape (安全边界为便利性隔离,非安全容器) - -## 跨平台行为 - -| 平台 | Shell 级 | FS 级 (Sandbox) | Image 级 | -|------|----------|-----------------|----------| -| **Linux** | 完整支持 | bwrap (首选) / proot (回退) | ext4 loop mount | -| **macOS 14+ arm64** | 支持 | 仅 HOME 重定向(无 namespace/ptrace) | 不支持 | -| **Windows x86_64** | 支持 | 仅 USERPROFILE 重定向 | 不支持 | - -macOS 沙箱入口仅提供 HOME 重定向,Windows 沙箱入口仅提供 USERPROFILE -重定向。二者都不提供文件系统视图或进程隔离,不能作为不受信代码的 -安全边界。两端均支持 `--cmd` 并传播子进程退出码。 - -## GPU 透传 (`--gpu`) - -bwrap 后端默认通过 `--dev /dev` 创建全新 tmpfs,只暴露最小设备节点白名单 (`null` / `zero` / `random` / `tty` / ...),宿主的 `/dev/nvidia*`、`/dev/dri/*` 等字符设备**完全不可见**。这是 "最小宿主暴露" 原则的体现,但同时也阻断了 sandbox 内的 GPU 使用。 - -需要 GPU 时显式追加 `--gpu`: +一个 SubOS 实例有三件互相独立的事: + +| 轴 | 回答的问题 | 怎么设 | +|---|---|---| +| **策略** | 允许做什么、隔离到什么程度 | `subos config --sandbox=dev\|private\|locked`、覆盖项、策略包 | +| **后端** | 用什么实现隔离 | bwrap(默认)、`--sandbox landlock`、`--sandbox proot`;macOS / Windows 为 home 重定向 | +| **存储** | 实例的 home 放在哪里 | `subos new --storage shared\|tmpfs\|image` | + +策略声明在实例上(`/config/subos//policy.json`,在实例目录之外,沙箱里只读)。无论怎样进入 +(`use`、`exec`、`start`、interface),都按这份声明执行。没有声明的实例按原样进入(Legacy),并带上 #640 的 S0 修复。 + +## 进入与外部执行 + +| 命令 | 作用 | +|---|---| +| `xlings subos use [--sandbox]` | 交互 shell;`--cmd` 跑一条命令;agent 模式下拒绝交互(exit 2),改用 `exec` | +| `xlings subos exec [--sandbox[=preset]] -- ` | 跑一条命令,返回它自己的退出码;有运行中的会话就加入它 | +| `xlings subos start [--ttl 10m]` / `stop ` | 后台会话:之后的 `exec` / `use` 都加入同一个实例(同一个 /tmp、同一组进程) | +| `xlings subos ps` / `status ` / `doctor []` | 运行中的会话;实例要求的与本机能给的;每个实例的健康检查 | +| `xlings subos log ` / `report ` | 审计事件;会话报告(执行过的程序、rw 映射里改动的文件) | +| `xlings subos cp :` | 在宿主和实例之间复制 | + +退出码:命令自己的;`125` 进入前失败(包括策略要求而本机给不了);`126/127` 命令无法执行 / 不存在; +`124` 超时;`128+n` 被信号终止;`13` `E_PERMISSION`;`75` 请求已排队等待批准;`2` agent 模式下需要确认。 + +`--keep` / `--ttl` 在 Linux 上是会话的空闲时间(替代原来的 keeper);其他平台不生效,行为与之前一致。 + +## 策略 + +| 预设 | 文件系统 | 进程 | 网络 | 身份 | 获取包 | +|---|---|---|---|---|---| +| `dev` | home 只读,只挂入本实例 | pid / ipc / uts 隔离 | host | host | auto | +| `private` | 同上 | 同上,禁止嵌套 userns | `nat`(pasta) | 中性(user / 主机名=实例名 / UTC) | ask | +| `locked` | 同上,`--mount` 默认只读 | 同上 | `none`(只有 lo) | 中性 | deny | + +- **覆盖项**:`--net`、`--fetch`、`--index-update`、`--observe`、`--allow `、`--mount`、`--env-pass`。 + 写在 `subos config` 上是声明;写在单次调用上**只能收紧**,或者在 `grants_allowed` 范围内授权。 +- **具名授权**:`display`、`audio`、`camera`、`gpu`、`ssh-agent`、`dbus`、`host-loopback`,每项只打开一样东西 + (一个 socket 文件或一组设备节点),从不暴露宿主的整个运行时目录。 +- **规则**:`permissions.fetch.rules` 按顺序匹配(包名 glob、来源索引、大小),第一条匹配的生效。 +- **策略包**:`subos config --sandbox ns:name[@version]` 选择一个 `type = "subos-policy"` 的 xpkg + (payload 根目录的 `policy.json`)。选择时显示它相对所基于的预设改了什么;策略复制进实例文件并锁定 + `resolved: {from, sha256, base}`,包更新不会悄悄改变策略,`--policy-upgrade` 显式升级并显示差异 + (owner 在包之上做过的修改会出现在差异里)。系统配置的 `subos_policy_sources` 可以限制来源。 +- **fail closed**:策略里出现本版本不认识或不能执行的值(例如 `net: "vpn"`、`fetch: "layer"`)时拒绝进入,不忽略。 +- 新写的策略声明 `min_client`;认识该字段的客户端不足最低版本时拒绝进入。`self doctor` + 检查 home 中实际 entry 的版本并给升级命令,不会自动降低或删除策略。早于该机制的客户端 + 可能完全忽略策略,使用声明策略的实例前必须先升级 home 的 entry。 + +## 沙箱里的 xlings 与 broker + +沙箱里能看到 xlings home,但只读;只有本实例的目录可写。沙箱里的 xlings 照常可用: + +- 只读命令(`list`、`info`、`--version`、shim 分发)在本地执行,**不经过 broker**; +- 改动 home 的命令(`install`、`remove`、`update`)交给宿主侧的 broker(`run/subos//broker.sock`), + broker 用同一个 `policy.decide()` 判定,在宿主侧执行并带回退出码; +- 只有 owner 能做的事(`self update`、改策略、动别的实例)在沙箱里返回 `13`,并给出在外面执行的命令; +- `fetch=ask` 的请求进入队列(exit `75`),由 owner 在外面 `subos requests / approve / deny`。 + +带执行策略的 recipe 由持久 Lua worker 加载、读取 metadata、构建计划并执行 hook, +使用独立的控制管道;stdout/stderr 属于日志或显式 tty。Linux 强边界将实际 scope、 +索引及依赖只读绑定,只允许写目标 payload 的私有副本、scratch 和当前 hook 日志。 +宿主在校验声明的源路径及运行时闭包后,将结果复制到独占 staging 再发布; +hook 持有的旧文件描述符不能继续修改新发布的 payload。头文件、库、版本和环境声明 +由宿主消费,声明不能将 scratch 的可变路径或另一个 scope 的文件直接带进视图。 +隔离能力不足且策略要求强边界时拒绝执行,并报告缺失能力。 + +## 后端 + +| 后端 | 机制 | 能给的 | 给不了的 | 选择方式 | +|---|---|---|---|---| +| **bwrap** | user / mount / pid / ipc / uts / net namespace | 全部预设 | — | 默认;查找顺序:root 拥有的 `/usr/lib/xlings/bwrap` → 系统 bwrap → xim payload | +| **landlock** | Landlock LSM(内核 ≥ 5.13),没有 namespace | 写入围栏:只有实例目录、`/dev`、`/proc` 和 rw `--mount` 可写;`TMPDIR` 在实例内 | 文件不可见、pid / net / 身份隔离;宿主的 unix socket(D-Bus、agent、其他会话)仍可连接,**不是**运行不受信代码的边界 | 只在显式 `--sandbox landlock` 时使用,从不自动替代 bwrap | +| **proot** | ptrace | 文件系统视图 | 安全边界(视图而非边界) | `--sandbox proot`,或 bwrap 不存在时回退 | +| **home 重定向** | 环境变量 | HOME / USERPROFILE 指向实例 | 其余全部(advisory) | macOS / Windows | + +后端给不了的项:在 `dev` 下降级并说明原因;在 `private` / `locked`(这些项是 must)下拒绝进入(125), +逐项列出缺什么、怎么修。`xlings subos status ` 显示请求的与实际生效的。 + +Ubuntu 24.04 默认限制非特权 user namespace(AppArmor)。`xlings self doctor --isolation --fix` 用一次 sudo +安装 root 拥有的 `/usr/lib/xlings/bwrap` 和一个窄 AppArmor profile;不改 sysctl,不创建 setuid。 + +## 网络与身份 + +| 模式 | 实现 | 本机服务 | 出口 | +|---|---|---|---| +| `host` | 共享宿主网络(抽象 unix socket 也可达) | 可达 | 宿主 | +| `nat` | 预先建好的 user+net namespace + pasta 接入;`--publish 8080:80`;`--allow host-loopback` 才能访问宿主本机服务 | 不可达 | 宿主 IP | +| `none` | net namespace,只有 lo | 不可达 | 无 | +| `proxy` | 只有 lo、无外部路由的 net namespace;回环 SOCKS5h 入口经私有 fd 桥交给 supervisor | 不可直连 | 声明的 SOCKS5h 代理 | + +代理可由实例所有者声明,或在未固定代理的实例上单次选择: ```bash -xlings subos use mygpu --sandbox --gpu +xlings subos config box --sandbox private --proxy socks5h://127.0.0.1:1080 +xlings subos exec box -- curl https://example.com ``` -`--gpu` 启用后会: - -- 对宿主**存在**的 NVIDIA 节点(`/dev/nvidiactl`、`/dev/nvidia-uvm`、`/dev/nvidia-uvm-tools`、`/dev/nvidia-modeset`、`/dev/nvidia0..15`)以 `--dev-bind` 透传;不存在的节点静默跳过 -- 透传 `/dev/dri` (DRM / Vulkan / 显示) -- 以只读方式绑定 `/sys`(libcuda / nvml 通过 `/sys/bus/pci/devices/...` 枚举 GPU 所必需) - -约束: - -- `--gpu` 必须与 `--sandbox` 同时使用,否则解析期报错 -- proot 后端默认即 `--bind /dev` + `--bind /sys` 全透传,`--gpu` 在 proot 模式下静默无视 - -实现位于 `src/core/subos/gpu.cppm`,独立于 `subos.cppm`,方便后续扩展 AMD ROCm (`/dev/kfd`) 等。设计文档:`.agents/docs/2026-05-22-subos-sandbox-gpu-passthrough.md`。 +沙箱内 `ALL_PROXY` / `all_proxy` 指向本命名空间的 `socks5h://127.0.0.1:1080`。 +回环入口只接受 SOCKS5 CONNECT;接受的连接经私有 SCM_RIGHTS 通道交给宿主侧 supervisor, +后者只连接配置中的代理。目标域名原样发给代理,不调用宿主解析器;声明的代理地址为域名时, +supervisor 单独解析该端点,并以 `proxy-endpoint-resolved` 事件标明。TCP CONNECT 请求和代理响应 +在写入 net 审计后才继续。nat 的 seccomp 通知记录 `net-attempt`,表示 connect/sendto/sendmsg 请求, +不表示内核已经成功建立连接。 + +客户端必须支持 SOCKS5h;不支持代理的 DNS 查询、直接 IP 连接及 UDP 不能通过这扇门。 +清空代理变量不会恢复宿主网络,也没有到 nat/host 的自动回退。当前代理入口不支持认证、BIND、 +UDP ASSOCIATE 或透明 IP 转发;含凭据的 URL 会被拒绝。macOS / Windows 的代理网络仍返回不可用。 +已固定代理的实例拒绝以单次 `--proxy` 更换出口;实例所有者可通过 `subos config` 更新声明。 + +中性身份:用户名 `user`、主机名 = 实例名、`TZ=UTC`、`LANG=C.UTF-8`,`/etc/passwd` 等来自实例的 `etc-neutral/`。 + +## 终端与进程 + +- 非交互命令放进新会话,没有控制终端;交互 shell 保留终端以支持作业控制,并加载 seccomp 过滤器拦截 `TIOCSTI`(F8)。 +- 会话由宿主侧的 supervisor 托管:`SIGTERM` / `SIGHUP` 结束会话;等待期间 `Ctrl-C` 属于命令。 +- 环境变量默认清空,只放行 `kBaseEnvPass` 和策略的 `env_pass`(名字,或 `NAME*` 前缀)。 + +`interface subos_exec` 使用 argv 子进程,运行中分开输出 stdout/stderr 的 NDJSON 片段, +不等命令结束。控制通道可取消执行;无效 UTF-8 片段标记为 base64,客户端按 stream +拼接字节后解码。stdin 保留给控制协议,命令读取到 EOF。 + +## 可观测性 + +审计写在沙箱看不到的地方:`/logs/subos//events.ndjson`(按大小轮转), +同时保存 `sessions/.ndjson`。默认省略 argv 值,保留程序和参数数量。 +locked 的日志写入失败会拒绝启动或终止会话;exec 的 seccomp 通知在成功记录后才放行。 +其他档位会警告。 + +| 级别 | 记录 | +|---|---| +| `basic` | 生命周期(会话开始 / 结束、策略变更及差异)、权限判定、每次 `exec` 加入及其退出码 | +| `standard` | + rw 映射里改动的文件 | +| `full` | + 沙箱里执行过的每个程序(seccomp 用户态通知,由 session-init 安装;bwrap 后端) | + +只记录环境变量的**名字**,不记录值;看起来像密钥的值一律不落盘。`XLINGS_TRACE=caps,provider,...` 打印诊断。 + +## 存储 + +| 存储 | 沙箱里的 home | 说明 | +|---|---|---| +| `shared`(默认) | `subos//home/` | 与 shell 级进入共用 | +| `tmpfs` | tmpfs,退出即消失 | 需要 bwrap | +| `image` | `home.img`(ext4 稀疏文件,loop 挂载,需要 sudo) | 需要 bwrap | + +存储只改变 home 放在哪里,不增加边界。实例的 home、`home.img` 和 xlings 无法证明归自己所有的文件是**用户数据**, +只有用户发起并确认过的删除(`subos remove` 等)才能删除它们(见 AGENTS.md "SubOS user data")。 + +## 部署形态 + +- **用户 / 自定义 / 便携**:每个用户一个 home,`self update` 更新 entry。 +- **系统包(S)**:`/usr/bin/xlings` 归包管理器所有。`self update` 不碰它,提示包管理器命令; + `self update --user` 显式在 home 里装一个用户级 xlings。`/etc/xlings/config.json` 提供 `mirror` / `lang` 默认值 + 和 `subos_policy_sources`,home 自己的 `.xlings.json` 覆盖它。`self doctor` 报告实际在用的 entry 和系统文件。 +- **系统包(S)的 home**:用户第一次执行写命令(或 `self init`、`doctor --fix`)时,home 的 `bin/xlings` 被建成 + 指向系统 entry 的链接——没有它,shim 表不会建立,装的包就不在 PATH 上。包管理器升级本体,所有用户跟着升级。 +- **系统层(M)**:`sudo xlings install --system ` 装进 `/xlings`(root 拥有,`.xlings-home` 声明 + `mode: multi` 或 `mode: root, layout: multi`;`XLINGS_SYSTEM_LAYER` 可指定别处)。 + 用户的 `use` 优先选择自己拥有的版本;系统层候选读取其注册表和 `default` 的安装记录,校验整个绑定组及 + `.xlings-resolution.json` 中记录的运行时依赖闭包,再把带来源 home/作用域的元数据写入用户注册表, + 将头文件、库和程序激活到用户视图。payload 保留在系统层,不复制到用户 store;系统层的 `configured` + 记录也不复制。缺少旧 payload 的闭包记录时,系统层所有者必须显式 `install --reconfig`,读取失败不视为空闭包。 + 与用户拥有的依赖注册发生冲突时拒绝借用,要求在用户作用域安装完整闭包。用户 shell profile 同时把系统层 + 的程序放在用户自己的之后、宿主之前;用户写不了系统层。 + +头文件、库和声明的文件资产使用同一批预检与发布事务。旧作用域的登记加实际链接目标/文件对象身份才能证明旧资产的所有权;仅仅指向 payload store 不构成证明。未知的普通文件、目录和链接保留并拒绝覆盖。目录链接需要与其他包合并时,先在独占暂存目录构造叶子链接再发布,后续更新逐个验证叶子,避免写进共享 payload。旧资产在元数据保存、回读验证及 root 刷新完成前保留作回滚;失败恢复旧工作区与旧视图。没有目录创建证明的真实父目录保留,即使它已经变空。Windows 无法建立跨盘硬链接时会明确失败。 + + 共享系统 store 的自动 GC 保留 payload:系统层无法观察其他用户的只读引用,不能以本 home + 没有引用为删除依据。损坏或未知的 home 声明也使 GC 拒绝;普通用户 home 不跟随 store 符号链接清理外部数据。 +- **根(R)**:一台机器的整个用户态就是一个 SubOS(见下一节)。系统 home 是 `/xlings`(multi)或构建它的 + home 的路径(single,这个用户等价于 root);`/etc/xlings/root.json` 是 stage-0 找到它的锚点。 + +## 根呈现(Part 2) + +设计:`.agents/docs/2026-10-06-subos-architecture-design-part2.md`。一个 SubOS 的内容(工作区、home、策略) +与它的呈现方式无关;第三种呈现方式是**根**:SubOS 本身就是 `/`。 + +| 部分 | 行为 | +|---|---| +| 种类与角色 | `subos new --rootfs` 声明 `kind=rootfs`(`config/subos//instance.json`,沙箱里只读);正在作为 `/` 的是 `host`,`boot.json` 里的是启动项。删除、策略、拷贝、启动、导出、回滚都问同一张表(`modules/subos/src/roles.cppm`) | +| `/usr` | 一代 = 一棵链接树:已注册的程序和库优先,再是各激活包 payload 的 `bin/`、`lib/`(普通发行版的语义,busybox 的 applet 就这样进 `/usr/bin`);带 alias 的程序保留 shim;`usr/lib/modules/` 来自内核包 | +| 代 | `/root -> root.gen/`,一次 rename 切换;工作区的每次变动(install / use / remove)产生新的一代;`subos rollback` 只切指针 | +| 机器状态 | `/etc` 只补缺(factory:`usr/share/factory/etc` 和 sysroot 的 `etc/`),sysusers 只追加;`/home`、`/var`、`/root` 不随代变化 | +| 进入实例 | bwrap:实例的树作为 `/`,uid 0。owner-private home 视图只读绑定经过校验的 metadata、依赖闭包内的 payload 和 generation;实例本身同时是嵌套根的 `default`。安装经 broker,精确挂载刷新完成后才回复成功,失败结束会话 | +| 私有前缀域 | `new --rootfs --domain ` 在用户拥有的 namespace 内构建;逻辑 home 与物理存储分别记录。系统来源通过只读 metadata facade 与精确 payload slot 映射,payload 字节不改写。按名字访问该实例的命令重新进入对应域 | +| `fetch=layer` | 沙箱里装的包进这个根自己的作用域;视图实例(没有根)拒绝进入 | +| 导出 | `subos export --rootfs/--tar/--disk`:实例成为镜像的 `default`,带上闭包里的 payload(工作区、已安装、每个 ELF 的 loader 与搜索路径)、静态 xlings、`root.json`、`boot/xlings-init` | +| 启动 | 内核 `init=/boot/xlings-init`:stage-0 挂载内核文件系统,按 `boot.json`(试启动 once、默认、计数用尽后 fallback)选 SubOS,把 `/usr` 指过去,exec 它的 init;init 起不来就试下一个。`subos boot --now`:busybox init 的 restart 重新执行 stage-0,不重启内核 | +| 库搜索 | managed loader 使用自己的 RUNPATH/私有 `etc`,不读宿主 cache;glibc 2.44.3 revision 2 的根 loader 从逻辑 interpreter 根读取 `/etc/ld.so.cache` 与 preload,默认目录是 `/lib64` = 根的 `/usr/lib` | +| 根 cache | 存在根内 `ldconfig` 时,refresh、rollback 与 stage-0 重新生成 cache;只读输入、独占 staging、写前记录旧/新 digest。未知或外部修改的 cache 保留并报错;不采用宿主 cache | + +## 跨平台 + +| 平台 | 后端 | 内核强制 | +|---|---|---| +| Linux | bwrap / landlock / proot | 是(proot 除外) | +| macOS 14+ | home 重定向;会话(`start`)不可用 | 否(advisory,路线见设计附录 A:Seatbelt) | +| Windows | USERPROFILE 重定向;会话不可用 | 否(advisory,路线:AppContainer) | + +`subos status` / `subos doctor` 的平台矩阵来自实际探测(gates),不是手写的表。 + +## GPU 透传(`--gpu` / `--allow gpu`) + +bwrap 后端默认只暴露最小 `/dev`。`--gpu`(等价于 `--allow gpu`)对宿主存在的 NVIDIA 节点、`/dev/dri` 做 +`--dev-bind`,并只读绑定 `/sys`(libcuda / nvml 枚举 PCI 设备需要)。proot 下 `/dev`、`/sys` 原样透传, +`--gpu` 不改变什么。实现:`modules/subos/src/gpu.cppm`。 + +## 尚未实现 + +| 项 | 现状 | +|---|---| +| 在 macOS / Windows 上呈现根 | 返回 unavailable;Windows 用 `subos export --tar` + `wsl --import` | +| 根里编译的程序默认在 `/usr/lib` 找库 | gcc 的 specs 用的是 payload 的 loader,它的默认目录是 payload 的而不是根的 `/usr/lib`;只在 `/usr/lib` 的库需要 `-Wl,-rpath,/usr/lib`(luban-desktop 的场景就是这样做的)。根里的 gcc 改用 `/lib64` 的 loader 需要 gcc recipe 支持按作用域的 specs | +| 加入会话的交互 shell 的作业控制 | 加入时提示:作业控制留在第一个 shell | +| interface 的 `subos_exec` 实时流式输出 | 结束后返回输出与退出码 | + +## 代码在哪里 + +| 位置 | 内容 | +|---|---| +| `modules/subos/src/` | SubOS 核心:`model/`(model、manifest、roles、ports、userdata)、`policy/`、`intent/`(策略 + 调用 → 与后端无关的 Intent)、`sandbox/`(spec 类型、caps、tools 工具表、elevation、network、gpu)、`views/root/`(根投影、generation、库缓存)、`session/`(supervisor 与 session-init、broker) | +| `modules/confine/src/` | 沙箱实现:linux-bwrap、linux-proot、linux-landlock、home-redirect、fake;选择器、编译骨架、`provider`(argv)、`gates`(由各实现的声明汇总出的平台矩阵) | +| `modules/carrier/src/` | 承载:local、wsl2(每个 home 一个 WSL2 发行版)、vz(经 xlings-vm 助手);`choose`(按 ABI 选择)、`terminal` / `control`(同一套 NDJSON interface) | +| `modules/store/src/` | payload 的 GC roots:哪些保留中的 generation 引用它,以及保留账本 | +| `modules/platform/src/` | 所有系统头与系统调用:`os/`、`process/`(进程、信号、带描述符的本地 socket;macOS 为分帧的流)、`net/`、`isolation/`(namespace、Landlock、seccomp、挂载)、`fs/`、`triple/` | +| `modules/runtime/src/` | `guard`(删除前的确认令牌)、`observe`(审计日志、脱敏) | +| `luban/src/` | 只属于"一台机器":`boot`(启动项)、`stage0`(第一个进程)、`machine`(机器的 /etc、sysusers);`apps/luban-init` 是 stage-0 的独立二进制 | +| `src/core/subos/` | `xlings subos` 命令:`cmd.cpp`(生命周期、承载路由);`run.cpp`(exec / start / cp);`configure.cpp`(config / status / doctor);`audit.cpp`;`root/`(根的投影、rollback / boot / export / diff / pack);`domain/`(前缀域);`sandbox.cpp`(把策略接到 confine 和会话上);`carrier_image.cpp`(承载镜像) | +| `tests/requirements.toml` | 每一项行为的需求 ID;`tests/unit`、`tests/e2e` 中的测试用 `covers` 认领 | diff --git a/docs/generated/command-reference.md b/docs/generated/command-reference.md index cb1500d3d..498cdb3d1 100644 --- a/docs/generated/command-reference.md +++ b/docs/generated/command-reference.md @@ -16,7 +16,7 @@ Options: `-h, --help` — Show help for the selected command; `--version` — Sh Install packages -Options: `-g, --global` — Use global scope; `-u, --use` — Activate installed version; `--reconfig` — Run the configuration step again, even where it already ran +Options: `-g, --global` — Use global scope; `-u, --use` — Activate installed version; `--reconfig` — Run the configuration step again, even where it already ran; `--subos ` — Install into this subos instead of the current one; `--system` — Install into the system layer (/xlings): every user of this machine gets it ## `xlings remove [version]` @@ -68,13 +68,13 @@ Manage SubOS environments Create a SubOS -Options: `--storage ` — shared, tmpfs or image; `--image-size ` — Image size; `--from ` — Fork source; `--runtime ` — Runtime binding, e.g. glibc@2.44 +Options: `--sandbox [PRESET]` — Declare its isolation once: dev (default), private or locked; `--storage ` — shared, tmpfs or image; `--image-size ` — Image size; `--from ` — Fork source; `--runtime ` — Runtime binding, e.g. glibc@2.44; `--rootfs` — Make it a root: entered, exported or booted as /; `--domain ` — Build a rootfs at this logical home prefix in an owned namespace; `--carrier ` — Where it runs: local, wsl2 (Windows) or vz (macOS); default chosen by what it is; `--abi ` — native (this machine's programs) or linux ## `xlings subos use [name]` Enter a SubOS -Options: `--global` — Persist the active SubOS; `--shell [KIND]` — Emit shell activation code; `--sandbox [BACKEND]` — Enable sandbox (bwrap or proot on Linux); `--cmd ` — Run one command; `--keep` — Keep the namespace keeper; `--no-keep` — Disable the namespace keeper; `--ttl ` — Keeper idle timeout; `--gpu` — Expose GPU devices (bwrap only) +Options: `--global` — Persist the active SubOS; `--shell [KIND]` — Emit shell activation code; `--sandbox [BACKEND]` — Enable sandbox (bwrap, proot or landlock on Linux); --sandbox=dev|private|locked picks a preset; `--net ` — This call only: host, nat, none or proxy (may only tighten); `--proxy ` — SOCKS5h endpoint for net=proxy; `--observe ` — This call only: off, basic, standard or full; `--fetch ` — This call only: auto, ask or deny (may only tighten); `--allow ` — This call only: grant from the policy's grants_allowed; `--no-degrade` — Refuse to enter when anything asked for is missing; `--mount ` — This call only: map a host path into the SubOS; repeatable; `--cmd ` — Run one command; `--keep` — Keep the session after the shell exits; `--no-keep` — End the session with the shell; `--ttl ` — Session idle timeout; `--gpu` — Expose GPU devices (bwrap only) ## `xlings subos list` @@ -90,7 +90,101 @@ Show SubOS details ## `xlings subos stop ` -Stop a SubOS keeper +Stop a SubOS's running session + +## `xlings subos exec [name] [command]...` + +Run a command in a SubOS from outside it + +Options: `--sandbox [BACKEND]` — Run in the SubOS's sandbox (bwrap, proot or landlock on Linux); --sandbox=dev|private|locked picks a preset; `--net ` — This call only: host, nat, none or proxy (may only tighten); `--proxy ` — SOCKS5h endpoint for net=proxy; `--observe ` — This call only: off, basic, standard or full; `--fetch ` — This call only: auto, ask or deny (may only tighten); `--allow ` — This call only: grant from the policy's grants_allowed; `--no-degrade` — Refuse to run when anything asked for is missing; `--publish ` — With net=nat: publish a TCP port; repeatable; `--mount ` — This call only: map a host path into the SubOS; repeatable; `--cwd
` — Working directory inside; `--env ` — Set a variable; repeatable; `--timeout ` — End the command after DURATION (90, 30s, 10m, 2h); exits 124; `--json` — Print the result as JSON on stderr when the command ends; `--temp` — Use a throwaway SubOS, removed afterwards (its audit is kept); `--from ` — With --temp: fork it from this SubOS or package + +## `xlings subos start ` + +Start a SubOS session that runs without a terminal + +Options: `--sandbox [BACKEND]` — Sandbox backend (bwrap, proot or landlock); --sandbox=dev|private|locked picks a preset; `--net ` — host, nat, none or proxy (may only tighten); `--proxy ` — SOCKS5h endpoint for net=proxy; `--observe ` — This call only: off, basic, standard or full; `--allow ` — Grant from the policy's grants_allowed; `--no-degrade` — Refuse to start when anything asked for is missing; `--publish ` — With net=nat: publish a TCP port; repeatable; `--mount ` — This call only: map a host path into the SubOS; repeatable; `--ttl ` — End after DURATION idle (90, 30s, 10m, 2h); default: until stop + +## `xlings subos cp ` + +Copy files into or out of a SubOS + +## `xlings subos config ` + +Show or change what a SubOS may do (its policy) + +Options: `--sandbox ` — Start from a preset (dev, private, locked) or a policy package (ns:name[@version]); `--policy-upgrade` — Move to the newest version of the selected policy package; `--net ` — host, nat, none or proxy; `--proxy ` — SOCKS5h endpoint for net=proxy; `--fetch ` — Installing a missing package from inside: auto, ask or deny; `--index-update ` — Updating the index from inside: auto, ask or deny; `--observe ` — off, basic, standard or full; `--allow ` — Grant display, audio, camera, gpu, ssh-agent, dbus or host-loopback; `--disallow ` — Withdraw a grant; `--grants-allowed ` — Grants a single call may add; `--env-pass ` — Let this host variable in; NAME* for a prefix; `--mount ` — Map a host path into the SubOS, every time it is entered; `--unmount ` — Stop mapping it; `--no-degrade` — Refuse to enter when anything asked for is missing; `--degrade` — Enter and report what is missing; `--reset` — Remove the policy file; `--json` — Machine-readable output + +## `xlings subos status [name]` + +Show what a SubOS asks for and what this host gives it + +Options: `--json` — Machine-readable output + +## `xlings subos doctor [name]` + +Check each SubOS: policy, entry on this host, policy package, sessions + +Options: `--json` — Machine-readable output; `--fix` — Re-install a selected policy package whose payload is missing + +## `xlings subos requests ` + +List what a SubOS's sandbox asked for and is waiting on + +Options: `--json` — One JSON object per request + +## `xlings subos approve ` + +Run a request a SubOS's sandbox is waiting on + +## `xlings subos deny ` + +Refuse a request a SubOS's sandbox is waiting on + +## `xlings subos report [name]` + +Summarise what happened in a SubOS: sessions, programs, permissions, files + +Options: `--session ` — Only this session; `--json` — Machine-readable output + +## `xlings subos ps [name]` + +List running SubOS sessions + +Options: `--json` — One JSON object per session + +## `xlings subos log [name]` + +Show a SubOS's audit events + +Options: `--kind ` — Only this kind (ops, lifecycle, perm, exec, net, fs); repeatable; `--session ` — Only this session; `-n, --lines ` — Show the last N events (default 50); `-f, --follow` — Keep printing new events; `--json` — One JSON object per event + +## `xlings subos rollback ` + +Move a root SubOS back to an earlier generation + +Options: `--to ` — This generation (default: the one before); `--list` — List the generations + +## `xlings subos boot [name]` + +Choose the SubOS a machine boots, from the next boot on + +Options: `--once` — Only the next boot (a trial); `--fallback` — Boot it when the default fails; `--mark-good` — This boot worked: keep booting it; `--now` — Switch user space to it now, where the init can (no reboot) + +## `xlings subos export ` + +Export a root SubOS as a directory, a tarball or a disk image + +Options: `--rootfs ` — A root directory (chroot, bwrap, nspawn); `--tar ` — A root tarball (docker import, podman import, wsl --import); `--disk ` — An ext4 disk image to boot; `--size ` — Disk size (default 4G); `--with-data` — Include /root, /home, /var, /srv, /opt + +## `xlings subos diff ` + +Compare the packages of two SubOS + +## `xlings subos pack ` + +Pack a SubOS's declaration as a subos-type xpkg + +Options: `--as ` — The package it becomes; `--out ` — Where the tarball goes ## `xlings subos runtime [name]` @@ -118,6 +212,8 @@ Initialize directories Update xlings +Options: `--user` — Install into this home even when xlings is a system package's + ## `xlings self config` Show configuration @@ -136,7 +232,7 @@ Migrate old layout Verify installation -Options: `--deep` — Audit package payloads and runtime functionality; `--scope ` — Limit deep payload/runtime audit to one local package coordinate; `--subos ` — Check/repair one specific subos instead of the active one; `--fix` — Repair (implies --deep; walks every subos that owns a finding); `--dry-run` — Preview repairs without changing detection depth; `--show-ok` — Show all findings, including non-defects; `--all` — Deprecated alias for --show-ok; `--reset-metadata` — Discard unreadable metadata +Options: `--deep` — Audit package payloads and runtime functionality; `--scope ` — Limit deep payload/runtime audit to one local package coordinate; `--subos ` — Check/repair one specific subos instead of the active one; `--fix` — Repair (implies --deep; walks every subos that owns a finding); `--dry-run` — Preview repairs without changing detection depth; `--show-ok` — Show all findings, including non-defects; `--all` — Deprecated alias for --show-ok; `--reset-metadata` — Discard unreadable metadata; `--isolation` — Check what this host can isolate SubOS sandboxes with; with --fix, install a root-owned bwrap and its AppArmor profile; `--json` — Machine-readable output (with --isolation) ## `xlings script [args]...` diff --git a/docs/quick-start/multi-version.md b/docs/quick-start/multi-version.md index 3ba62123d..2d8729388 100644 --- a/docs/quick-start/multi-version.md +++ b/docs/quick-start/multi-version.md @@ -256,3 +256,7 @@ xlings remove gcc --subos probe # 只卸载 probe 这个 SubOS 里的 gcc - 版本切换仅影响当前 SubOS 环境(或宿主环境) - 卸载某版本时,若其他环境仍在引用,物理文件不会被删除 - 使用 `xlings list` 确认当前环境的活跃版本 +- 包能否在你的平台上安装,取决于**配方**而不是 xlings:只有当配方逐架构列出了自己的产物、 + 而其中没有你的架构时,`xlings install` 才会拒绝;配方只提供单一产物时照常安装,并提示 + 该配方无法确认你的架构。目前 Linux x86_64 覆盖最完整,aarch64 / macOS / Windows 上 + 不少配方只发布了部分架构的产物。 diff --git a/docs/quick-start/subos-and-agent.md b/docs/quick-start/subos-and-agent.md index 83b6c60f3..0f3b835bf 100644 --- a/docs/quick-start/subos-and-agent.md +++ b/docs/quick-start/subos-and-agent.md @@ -1,181 +1,341 @@ -> 更新日期:2026-08-03 +> 更新日期:2026-10-08 -# SubOS 与 AI Agent 协作指南 +# SubOS 使用指南:环境、沙箱与 Agent -Linux SubOS 可为 AI Agent 提供文件系统隔离。macOS 和 Windows 只重定向 -用户目录,不隔离宿主文件系统或进程,因此不能用于运行不受信代码。 +SubOS 是一个有名字的环境:自己的工具版本、自己的 home、自己的策略。它可以只是一次 PATH +切换,也可以是带私有网络的无 root 沙箱。本指南按场景组织;每个场景的命令都可以直接执行。 +行为细节见 [SubOS 隔离模型](../design/subos-isolation.md),命令全集见 +[命令参考](../generated/command-reference.md)。 -## SubOS 隔离级别 +> **平台差异。** Linux(bwrap)提供真正的隔离:文件系统、进程、网络、身份。macOS 与 +> Windows 的 `--sandbox` 只重定向 home 目录,**不是安全边界**,运行不受信任的代码请用 +> 操作系统级沙箱或虚拟机。`xlings subos status ` 显示这台机器实际给了什么。 -| 级别 | 说明 | 权限要求 | 适用场景 | -|------|------|----------|----------| -| shell | 环境切换,共享宿主文件系统 | 无 | 快速切换工具链版本 | -| FS | bwrap/proot 沙箱,独立文件系统视图 | 无需 root | Agent 日常开发、多实例隔离 | -| image | ext4 镜像挂载,完整系统环境 | 需要 root | 需要完整系统模拟的场景 | +## 速览 -## 创建 SubOS +```bash +xlings subos new work --sandbox=private # 创建,并声明它的隔离程度 +xlings subos use work # 进入:就是那个沙箱,不用再加参数 +xlings subos exec work -- make -j8 # 执行一条命令,返回它的退出码 +xlings subos status work # 它要求的 vs 这台机器给的 +xlings subos list # 所有 SubOS +xlings subos remove work # 删除(连同 home,会先确认) +``` + +**隔离程度属于实例,在创建时声明一次**(`subos new --sandbox=dev|private|locked`,之后可以用 +`subos config` 修改)。声明过的实例,无论 `use`、`exec`、`start` 还是接口调用,都按声明进入沙箱。 +单次调用上的 `--sandbox=locked`、`--net none` 之类**只能更严格**,不能放宽。没有声明的实例 +(例如场景一那样只用来切换工具链的)保持原来的行为:不加 `--sandbox` 就不进沙箱。 +`--from` 派生的实例继承源实例的声明。 + +## 场景一:一套与宿主互不干扰的工具链 -基本创建: +不需要沙箱,只想让一组工具版本和宿主分开: ```bash -xlings subos new dev-agent +xlings subos new py312 +xlings subos use py312 # 新 shell,PATH 指向 py312 的工具 +xlings install python@3.12 poetry # 装进 py312,宿主不受影响 +python --version +exit # 回到宿主 + +xlings install ninja --subos py312 # 不进入也能往里装 +xlings subos use py312 --cmd "python -V" # 在里面跑一条命令 ``` -从已有 xpkg 基础镜像创建(自动安装缺失的包): +`--from` 从一个现成的环境派生,几乎不占额外空间(安装产物是共享的): ```bash -xlings subos new rust-agent --from subos:rust-env@1.80 +xlings subos new py312-exp --from py312 +xlings subos new rust-env --from subos:rust-env@1.80 # 从 subos 类型的包创建 ``` -指定存储模式: +## 场景二:在沙箱里运行 AI Agent + +Agent 改代码、装依赖、跑测试,但不应该碰到你的 home、SSH 密钥和其他项目: ```bash -# 共享存储(默认) -xlings subos new my-agent --storage shared +xlings subos new agent-ws --sandbox=private +xlings subos config agent-ws --mount ~/code/myproj:/work # 只映射这个项目(可写) +xlings subos exec agent-ws -- sh -c 'cd /work && claude --task "fix the failing tests"' +``` + +`private` 的含义: -# 临时存储(会话结束后清除) -xlings subos new tmp-agent --storage tmpfs +| 项 | 效果 | +|---|---| +| 文件系统 | xlings home 只读,只有本实例目录可写;看不到宿主 home、其他实例和审计日志 | +| 进程 | 独立的 pid / ipc / uts 命名空间;看不到宿主进程;禁止嵌套 user namespace | +| 网络 | `nat`:能上网,但访问不到宿主本机的服务(`--allow host-loopback` 才可以) | +| 身份 | 用户名 `user`、主机名 = 实例名、`TZ=UTC` | +| 环境变量 | 清空,只放行基础变量和代理变量;需要的用 `--env-pass NAME` 放行 | +| 装包 | 需要你批准(见场景五) | -# 镜像存储(持久化 ext4) -xlings subos new full-agent --storage image +Agent 应该声明自己的身份,这样任何命令都不会停下来等待输入: + +```bash +export XLINGS_AGENT_MODE=1 +xlings subos use agent-ws # 交互式进入被拒绝(exit 2),提示改用 subos exec +``` + +通过 NDJSON 接口驱动(每行一个 JSON 事件,最后一行是结果): + +```bash +xlings interface subos_exec --args '{"name":"agent-ws","sandbox":true,"argv":["make","test"],"timeout":"10m"}' +xlings interface subos_events --args '{"name":"agent-ws","kind":"perm"}' ``` -## 在 SubOS 中运行 Agent +## 场景三:运行不受信任的代码 -核心用法:Agent 运行在 SubOS 内部,通过沙箱获得隔离。 +来路不明的脚本、要审计的仓库:不给网络,只读地看代码,事后看它做了什么: ```bash -# 进入 SubOS 并启动 agent -xlings subos use dev-agent --sandbox -# 进入后运行你的 agent 工具 -claude -# 或 -codex +xlings subos new quarantine --sandbox=locked +xlings subos config quarantine --observe full +xlings subos exec quarantine --mount ~/Downloads/suspicious-repo:/src:ro --timeout 5m -- sh -c 'cd /src && ./build.sh' +xlings subos report quarantine # 执行过的每个程序、改动过的文件 ``` -直接执行命令: +`locked` = 没有网络(只有 lo)、`--mount` 默认只读、不能装包、`observe=full` 记录每一次 +execve。一次性的任务可以不留下实例: ```bash -xlings subos use dev-agent --sandbox --cmd "claude --task 'fix lint errors'" +xlings subos exec --temp --sandbox=locked -- ./untrusted-binary # 用完即删,审计保留 ``` -```mermaid -graph LR - Host[宿主系统] --> SubOS1[SubOS: dev-agent] - SubOS1 --> Agent1[Claude Code] - Host --> SubOS2[SubOS: test-agent] - SubOS2 --> Agent2[Codex] - Host --> SubOS3[SubOS: review-agent] - SubOS3 --> Agent3[OpenCode] +单次调用的参数**只能收紧**,不能放宽实例的声明:在 `locked` 实例上用 `--net host` 会被 +拒绝(exit 125)。要放宽,由你在外面改声明:`xlings subos config quarantine --net nat`。 + +## 场景四:一个实例,多条命令(会话) + +每次 `exec` 都会重新启动沙箱。要让多条命令共享同一个 `/tmp` 和进程(例如后台服务 + 测试), +就启动一个会话: + +```bash +xlings subos start dev --ttl 30m # 后台会话,空闲 30 分钟后自动结束 +xlings subos exec dev -- sh -c 'redis-server --daemonize yes' +xlings subos exec dev -- make integration-test # 加入同一个会话,几毫秒就开始 +xlings subos ps # 正在运行的会话 +xlings subos stop dev ``` -## 多 Agent 实例并行 +`subos use dev --sandbox --keep` 也会启动会话:shell 退出后会话保留,直到 `subos stop`。 -同一台主机上运行多个独立的 Agent 实例,互不干扰: +需要从外面访问沙箱里的服务(`nat` 网络): ```bash -# 创建多个 SubOS -xlings subos new agent-frontend --from subos:node-env@22 -xlings subos new agent-backend --from subos:rust-env@1.80 -xlings subos new agent-infra --from subos:python-env@3.12 +xlings subos exec dev --sandbox=private --publish 8080:80 -- python -m http.server 80 +``` + +## 场景五:在沙箱里装包 + +沙箱里的 xlings 照常可用:读操作在本地执行,`install` / `remove` / `update` 交给宿主侧的 +broker,按实例的策略判定后在外面执行: -# 分别启动(各终端窗口中执行) -xlings subos use agent-frontend --sandbox --cmd "claude --task 'implement UI components'" -xlings subos use agent-backend --sandbox --cmd "claude --task 'add API endpoints'" -xlings subos use agent-infra --sandbox --cmd "codex --task 'write terraform modules'" +```bash +xlings subos config dev --fetch ask # auto(默认)/ ask / deny +# 沙箱内: +xlings install jq # exit 75:已排队,等待批准 +# 沙箱外: +xlings subos requests dev # 待批准的请求 +xlings subos approve dev r1a2b3c # 批准(或 deny) +``` + +细粒度规则写在策略文件里(`/config/subos//policy.json`),按顺序匹配: + +```json +{ + "extends": "private", + "permissions": { + "fetch": { + "default": "ask", + "rules": [ + { "match": "xim:*", "index": "official", "action": "auto" }, + { "match": "*", "size_gt": "2GB", "action": "deny" } + ] + } + } +} ``` -Linux sandbox 模式提供独立文件系统视图;macOS/Windows 只分离配置目录。 +只有你(owner)能在沙箱外修改策略;沙箱里的进程能读到它,但改不了。只有 owner 能做的事 +(`self update`、改策略、动别的实例、切换 xlings 版本)在沙箱里返回 13,并提示在外面执行的命令。 -## 临时任务(一次性执行) +## 场景六:需要显示、声音、SSH 或 GPU -使用 `--storage tmpfs` 创建一次性环境,会话结束后数据自动清除: +默认这些都关闭。每一项授权只打开一样东西(一个 socket 文件或一组设备节点): ```bash -# 创建临时环境执行单次任务 -xlings subos new ephemeral --storage tmpfs -xlings subos use ephemeral --sandbox --cmd "claude --task 'audit this repo for security issues'" +xlings subos config gui-app --allow display # X11 / Wayland +xlings subos config gui-app --allow audio # PulseAudio / PipeWire +xlings subos config ci --allow ssh-agent # 只有 agent socket,不是 ~/.ssh +xlings subos config ml --allow gpu # NVIDIA 节点 + /dev/dri +``` + +`--grants-allowed gpu,display` 允许单次调用自己加这些授权(`subos exec ml --allow gpu ...`)。 + +## 场景七:团队统一的策略(策略包) -# 任务完成后清理(会删除这个 SubOS 的 home;没有终端可确认时需要 -y) -xlings subos remove ephemeral -y +把策略做成一个 `type = "subos-policy"` 的包(payload 根目录放 `policy.json`),团队成员选用: + +```bash +xlings subos config ci --sandbox xim:policy-ci-strict@1 # 示例包名 +# 选择时显示:它相对于所基于的内置预设改了什么 +xlings subos config ci --policy-upgrade # 包更新后,显式升级并显示差异 +``` + +策略会复制进实例并锁定 `resolved.from` 和 `sha256`:包更新不会悄悄改变已有实例。系统管理员 +可以在 `/etc/xlings/config.json` 里限制允许的来源:`{"subos_policy_sources": ["xim:*"]}`。 + +## 场景八:在宿主和实例之间传文件 + +```bash +xlings subos cp ./data.csv dev:/tmp/ # 拷入 +xlings subos cp dev:~/results ./results # 拷出(~ 是实例的 home) ``` -适合代码审查、安全扫描、实验性修改等不需要保留结果的场景。 +只能访问实例自己的 home 和 `/tmp`。实例里的符号链接不会被跟随:实例无法借你的拷贝改写 +宿主上的文件。 -## xlings interface(程序化控制) +## 场景九:审计——它做了什么 -`xlings interface` 提供 NDJSON 协议,支持外部程序以结构化方式控制 SubOS 生命周期: +审计由宿主侧的 supervisor 写在沙箱看不到的地方(`/logs/subos//events.ndjson`, +每个会话另存 `sessions/.ndjson`)。默认记录程序名和参数数量,不记录参数值。 +locked 在审计写入失败时拒绝启动或结束会话(退出码 125);dev 会警告后继续: ```bash -# 能力发现 -xlings interface --list +xlings subos log dev # 全部事件 +xlings subos log dev --kind perm # 权限判定(装包、被拒绝的操作) +xlings subos log dev --kind exec -f # 执行过的程序,持续跟随 +xlings subos report dev --json # 每个会话的汇总 +``` + +| `--observe` | 记录 | +|---|---| +| `basic`(默认) | 会话开始 / 结束、策略变更、权限判定、每次 exec | +| `standard` | + rw 映射里改动的文件 | +| `full` | + 沙箱里执行过的每个程序 | + +只记录环境变量的名字,从不记录值。 -# 每次调用在命令行指定 capability 和 JSON 参数 -xlings interface create_subos --args '{"name":"ci-agent"}' -xlings interface list_subos --args '{}' -xlings interface remove_subos --args '{"name":"ci-agent","yes":true}' +## 场景十:Ubuntu 24.04 上沙箱进不去 + +Ubuntu 24.04 默认通过 AppArmor 限制非特权 user namespace,bwrap 会报 +`setting up uid map: Permission denied`。不需要改系统 sysctl: + +```bash +xlings self doctor --isolation # 说明原因 +xlings self doctor --isolation --fix # 一次 sudo:root 拥有的 bwrap + 一个只授权它的 AppArmor profile ``` -`remove_subos` 会删除 SubOS 的 home(用户数据),所以只有带 `"yes": true`(表示用户已确认) -才会执行;不带时什么都不改,返回将要删除的路径和数据量(exitCode=2),见 -[NDJSON 规范 §7.1](../spec/interface-ndjson-v1.md)。 +在这之前,xlings 会退回到 proot 并明确提示(proot 只是视图,不是安全边界)。也可以用 +`--sandbox landlock`:内核限制写入(只能写实例目录),但宿主文件可见、本机 socket 可连接—— +它防误写,不防恶意代码。 -stdin 只传递 `cancel`、`pause`、`resume` 和 `prompt-reply` 等运行时控制 -消息,不用于选择 capability。 +## 场景十一:一个完全由 xlings 构成的根(Luban) -可用于 CI/CD 集成或自定义编排工具。 +SubOS 也可以**作为 `/`** 呈现:它的 `/usr` 是包的链接视图,`/etc`、`/home`、`/var` 是这台"机器" +自己的状态。同一份声明可以作为沙箱实例进入、导出成容器镜像,或者由内核直接启动—— +这就是 **Luban**(kernel + xlings + 可选的 LubanOS 服务)。官方分级是 subos 类型的包: +`luban-tiny`(busybox、glibc、内核)、`luban-core`(+ bash、coreutils、gcc、curl……)、 +`luban-desktop`(+ Mesa、Wayland、X11、字体、音频),上一级 `from` 下一级。 -## 完整工作流示例 +```bash +xlings subos new box --rootfs --from subos:luban-core # 一个根;它声明的包装进去 +xlings subos use box # 进入:uid 0,自己的 /usr、/etc +xlings subos exec box -- sh -c 'gcc hello.c && ./a.out' +xlings subos exec box -- xlings install -y nginx # 在里面装包:立即出现在它的 /usr/bin +xlings subos status box # 当前代、各代、名字冲突 +xlings subos rollback box # 回到上一代(只切指针) + +xlings subos export box --tar box.tar.gz # docker import / podman import / wsl --import +xlings subos export box --disk box.img --size 8G # ext4 磁盘镜像 +qemu-system-x86_64 -m 1G -nographic \ + -kernel ~/.xlings/subos/box/root/usr/lib/modules/*/vmlinuz \ + -drive file=box.img,format=raw,if=virtio \ + -append "root=/dev/vda rw console=ttyS0 init=$HOME/.xlings/boot/xlings-init" +``` + +- 包的每一次变动都是一个新的**代**(generation):`/usr` 指向当前代,切换是一次 rename, + 回滚只是把指针切回去。glibc 坏了,静态的 xlings 照样能回滚。 +- 镜像里的 xlings 就是唯一的包管理器:`xlings install` 装进系统;`xlings subos boot --once` + 试启动另一个 SubOS(没确认就自动回到默认),`xlings subos boot ` 设为默认, + `--now` 在 init 支持时不重启内核直接切换用户态。 +- 两种布局:镜像里的系统 home 就是构建它的 home 的路径(single,一个用户等价于 root), + 或者在 `/xlings` 里构建(multi,推荐多用户;`/xlings` 由 root 拥有)。 +- 定制自己的发行版:`subos new mydistro --rootfs --from subos:luban-core`,装包, + `subos pack mydistro --as myns:mydistro@1.0` 打成包,发布到你自己的索引。 -以下演示从创建环境到任务完成的完整流程: +Linux 上,普通用户可以用 `--domain` 指定镜像的 home 路径。xlings 在用户拥有的私有 +namespace 中构建,把包安装在这个逻辑路径,数据保存在用户 home 内: ```bash -# 1. 创建基于 Rust 工具链的 SubOS -xlings subos new feature-dev --from subos:rust-env@1.80 --storage shared +xlings subos new mydistro --rootfs --domain /xlings --from subos:luban-tiny +xlings subos exec mydistro -- bash +xlings subos export mydistro --tar mydistro.tar +``` + +这需要可用的 bubblewrap 和 user namespace。系统层借用的 payload 在构建时只读, +导出时复制镜像需要的完整依赖闭包,因此镜像运行不依赖构建机器的系统层。 + +export 和 pack 的输出目标必须不存在;重用目标会返回错误并保留原文件。临时目录由命令独占创建, +失败时只清理自己的临时数据。损坏或不可读的实例元数据、启动配置会返回错误,请先修复文件后重试; +升级不会把它们当作空配置覆盖。 -# 2. 在沙箱中启动 Agent 完成开发任务 -xlings subos use feature-dev --sandbox --cmd "claude --task 'implement the caching layer in src/cache/'" +多用户机器上,管理员可以把包装进**系统层**,每个用户都能用、不用复制,自己装的版本优先: -# 3. 进入环境检查结果 -xlings subos use feature-dev --sandbox -cargo test -cargo clippy -exit +```bash +sudo xlings install --system gcc cmake # 装进 /xlings(root 拥有) +cmake --version # 普通用户:shell 里在自己的包之后、宿主之前找到它 +``` -# 4. 确认无误后,释放 keeper 进程 -xlings subos stop feature-dev +## 排错 -# 5. 不再需要时删除环境(会先确认,列出 home 里有多少数据) -xlings subos remove feature-dev +```bash +xlings subos status dev # 要求的 vs 生效的;不能进入时逐项列出原因和修法 +xlings subos doctor # 检查每个实例:策略、能否在这台机器进入(真实进入一次)、策略包、会话 +XLINGS_TRACE=caps,provider,session xlings subos exec dev --sandbox -- true # 诊断输出 ``` -```mermaid -sequenceDiagram - participant U as 用户 - participant X as xlings - participant S as SubOS - participant A as Agent +| 退出码 | 含义 | +|---|---| +| 命令自己的 | 命令正常结束 | +| 125 | 进入之前就失败(包括策略要求而这台机器给不了) | +| 126 / 127 | 命令找到了但不能执行 / 没找到 | +| 124 | `--timeout` 到期 | +| 128 + n | 被信号 n 结束 | +| 13 | `E_PERMISSION`:只有 owner 能做,或策略拒绝 | +| 75 | 请求已排队,等待批准 | +| 2 | agent 模式下需要确认(例如删除),或交互式进入被拒绝 | - U->>X: subos new feature-dev --from subos:rust-env@1.80 - X->>S: 创建隔离环境 - U->>X: subos use feature-dev --sandbox --cmd "claude ..." - X->>S: 启动沙箱 - S->>A: 运行 Agent - A->>S: 执行开发任务 - A-->>S: 任务完成 - U->>X: subos stop feature-dev - X->>S: 释放 keeper - U->>X: subos remove feature-dev - X->>S: 删除环境 +## 删除 + +```bash +xlings subos remove dev # 会先确认,并列出 home 里有多少数据 +xlings subos remove dev -y # 只在用户明确要求删除时使用 ``` -## 常用命令速查 +SubOS 的 home 是用户数据:只有用户发起并确认的删除才会删除它(接口里 `remove_subos` 需要 +`"yes": true`)。每一次删除都记录在 `/logs/destructive.ndjson`。 + +## 命令速查 | 命令 | 说明 | -|------|------| -| `xlings subos new ` | 创建 SubOS | -| `xlings subos use --sandbox` | 进入沙箱环境 | -| `xlings subos use --cmd "..."` | 在 SubOS 中执行命令 | -| `xlings subos stop ` | 释放 keeper 进程 | -| `xlings subos remove ` | 删除 SubOS(连同 home,先确认;无终端时需 `-y`) | -| `xlings interface` | 启动 NDJSON 程序化接口 | +|---|---| +| `subos new [--sandbox=preset] [--from ] [--storage shared\|tmpfs\|image]` | 创建,并声明隔离程度 | +| `subos use [--sandbox] [--cmd "..."] [--keep\|--ttl N]` | 进入;`--cmd` 执行一条命令 | +| `subos exec \|--temp [--sandbox[=preset]] [--timeout D] -- argv` | 执行一条命令,返回它的退出码 | +| `subos config [--sandbox=preset\|ns:pkg] [--net] [--fetch] [--allow] [--mount] ...` | 声明策略 | +| `subos status ` / `subos doctor []` | 生效的隔离 / 健康检查 | +| `subos start [--ttl D]` / `subos stop ` / `subos ps []` | 会话 | +| `subos requests / approve / deny [id]` | 处理沙箱里的请求 | +| `subos log [--kind K] [-f]` / `subos report ` | 审计 | +| `subos cp ` | 拷入 / 拷出(一端写成 `:`) | +| `subos list` / `subos info ` / `subos remove ` | 列出 / 详情 / 删除 | +| `self doctor --isolation [--fix]` | 这台机器能怎样隔离,以及修复 | +| `subos new --rootfs [--domain ] [--from subos:luban-*]` | 一个根:作为 `/` 进入、导出、启动 | +| `subos rollback [--to N] [--list]` | 根回到某一代 | +| `subos export --rootfs \|--tar \|--disk ` | 导出为目录 / tar 包 / 磁盘镜像 | +| `subos boot [] [--once\|--fallback\|--now\|--mark-good]` | 机器启动哪个 SubOS | +| `subos diff ` / `subos pack --as ns:pkg@ver` | 比较两个 SubOS / 打成 subos 类型的包 | +| `install --system ` | 装进系统层(多用户,root) | diff --git a/docs/spec/diagnostics.md b/docs/spec/diagnostics.md index 40e8557eb..4b278f67e 100644 --- a/docs/spec/diagnostics.md +++ b/docs/spec/diagnostics.md @@ -1,7 +1,7 @@ # 诊断信息规约 (Diagnostics) > 状态: 生效 (2026.8.22.1 起;§2.1 / §2.1b / §3 于 2026.8.22.2 修订) -> 实现: `src/core/diag.cppm` · `src/core/uimode.cppm` · `modules/theme/` +> 实现: `src/core/diag.cppm` · `src/core/uimode.cppm` · `modules/ui/src/theme.cppm` > 设计: [`.agents/docs/2026-08-22-cli-diagnostics-experience-design.md`](../../.agents/docs/2026-08-22-cli-diagnostics-experience-design.md) ## 1. 一个问题,一个严重级标记 diff --git a/docs/spec/interface-ndjson-v1.md b/docs/spec/interface-ndjson-v1.md index ee7bc1826..71b4b1c36 100644 --- a/docs/spec/interface-ndjson-v1.md +++ b/docs/spec/interface-ndjson-v1.md @@ -1,10 +1,10 @@ -> 编写日期: 2026-05-17 | 版本: 2026.9.28.2 +> 编写日期: 2026-05-17 | 更新: 2026-10-09 | 版本: 2026.10.9.1 -# NDJSON 接口协议规范 v1.5 +# NDJSON 接口协议规范 v1.6 ## 1. 概述 -`xlings interface` 提供面向程序的结构化 API,使外部客户端(IDE 插件、CI 脚本、AI agent 等)可通过标准 IO 与 xlings 交互。协议版本为 **1.5**,基于 NDJSON(Newline-Delimited JSON)。 +`xlings interface` 提供面向程序的结构化 API,使外部客户端(IDE 插件、CI 脚本、AI agent 等)可通过标准 IO 与 xlings 交互。协议版本为 **1.6**,基于 NDJSON(Newline-Delimited JSON)。 | 协议版本 | xlings | 变化 | |----------|--------|------| @@ -14,6 +14,7 @@ | 1.3 | 2026.9.28.2 起 | 增补:`download_progress` 的 `stream` 字段与发送频率的上限(§6.3.2);`prevLines` 废弃,恒为 0 | | 1.4 | 2026.9.29.1 起 | 增补:`install_packages` 的 `reconfig` 字段(§7.3)与 `configure` 进度事件(§6.1.2);已在本 scope 按当前 revision 配置过的包不再重跑 config | | 1.5 | 2026.9.30.1 起 | 增补:`install_packages` 的 `hook` 进度事件(§6.1.2);安装 hook 启动的命令的输出写入 hook 日志,不再以 `[stray stdout]` 转发到 stderr(§5);下载因本地写入失败(磁盘满、无权限)时错误码为 `E_DISK_FULL`(§6.1.3) | +| 1.6 | 2026.10.9.1 起 | `subos_exec_output` 在命令运行中发送独立 stdout/stderr 字节片段;无效 UTF-8 片段以 `encoding: base64` 编码;取消请求回收子进程 | 次版本号的变化只做增补,1.0 客户端无需修改即可读取 1.1 的输出。客户端应通过**探测能力** 判断服务端是否提供某项功能(例如 `install_targets` 事件是否出现),而不是比较版本号。 @@ -46,7 +47,7 @@ xlings interface --version 服务端输出一行后退出: ```json -{"protocol_version":"1.5"} +{"protocol_version":"1.6"} ``` ### 3.2 查询可用能力 @@ -519,3 +520,17 @@ stdout 输出(每行一个 JSON): ``` 客户端收到 `kind: result` 后即可关闭 stdin 并退出。 + +## SubOS 命令输出 + +`subos_exec` 接受 `argv`,输出片段通过 `dataKind: subos_exec_output` 发送。 +`payload.stream` 为 `stdout` 或 `stderr`,`payload.data` 为本片段内容,片段可能没有换行。 +普通片段为 UTF-8;带 `payload.encoding: base64` 的片段须先解码再按对应 stream 拼接字节。 +跨片段的 UTF-8 字符应在拼接后解码。终端 `result.exitCode` 是命令退出码; +stdin 不交给命令,控制通道的 cancel 终止本次调用并返回 `E_CANCELLED` 与 130。 + +```json +{"kind":"data","dataKind":"subos_exec_output","payload":{"stream":"stdout","data":"ready"}} +{"kind":"data","dataKind":"subos_exec_output","payload":{"stream":"stderr","encoding":"base64","data":"/w=="}} +{"kind":"result","exitCode":0} +``` diff --git a/docs/spec/themes.md b/docs/spec/themes.md index 5d2c15903..8f8518e82 100644 --- a/docs/spec/themes.md +++ b/docs/spec/themes.md @@ -1,7 +1,7 @@ # 配色主题 (Themes) > 状态: 生效 (2026.8.22.1 起) -> 实现: `modules/theme/`(独立 mcpp 成员包)· `config/themes/*.json`(随发行包交付的样例) +> 实现: `modules/ui/src/theme.cppm`(`modules/ui` 成员包)· `config/themes/*.json`(随发行包交付的样例) > 使用说明: [`../theme.md`](../theme.md) ## 1. 选一个 @@ -102,7 +102,7 @@ high-contrast config/themes/ 极端环境:强光、投影、劣质终端 ## 8. 模块边界 -`modules/theme` 是 workspace 的独立成员,**零依赖**(只用 `std` 和 JSON 读取器)。它拥有 `Rgb` 和 `Background`,而不是从 `core/palette` 借: +`xlings.theme` 模块(`modules/ui` 包)**零依赖**(只用 `std` 和 JSON 读取器)。它拥有 `Rgb` 和 `Background`,而不是从 `core/palette` 借: - `theme` 回答"用哪个颜色"; - `palette` 回答"这个终端能不能上色、背景是深是浅" —— 这个需要 `xlings.platform`。 diff --git a/luban/mcpp.toml b/luban/mcpp.toml new file mode 100644 index 000000000..3d2d56241 --- /dev/null +++ b/luban/mcpp.toml @@ -0,0 +1,27 @@ +# Luban: what exists only when a SubOS is a machine (SubOS design part 3 §8). +# +# boot which SubOS a machine boots (boot.json: default, fallback, a trial) +# stage0 the machine's first process -- before /proc, before any home or +# Config is read; its own binary, apps/luban-init +# machine the machine's /etc: factory files, sysusers +# +# It depends on modules/ (the shared layer) and never on the xlings frontend +# (src/): tools/lint_layer_deps.sh fails an `import xlings.core.*` here, which +# is how "nothing on stage-0's path reads Config" is a build rule and not a +# convention. +[package] +namespace = "luban" +name = "luban" +version = "0.1.0" + +[targets.luban] +kind = "lib" + +[dependencies] +libs = { path = "../modules/libs" } +platform = { path = "../modules/platform" } +runtime = { path = "../modules/runtime" } +subos = { path = "../modules/subos" } + +[build] +include_dirs = ["../modules/libs/src/json"] diff --git a/luban/src/boot/boot.cpp b/luban/src/boot/boot.cpp new file mode 100644 index 000000000..1c8262b0b --- /dev/null +++ b/luban/src/boot/boot.cpp @@ -0,0 +1,169 @@ +module luban.boot; + +import std; +import xlings.libs.json; +import xlings.platform; + +namespace luban::boot { + +namespace platform = xlings::platform; + +namespace { + +bool valid_name(std::string_view name) { + return std::ranges::all_of(name, [](unsigned char c) { + return std::isalnum(c) || c == '_' || c == '-'; + }); +} + +std::expected validate(const nlohmann::json& j) { + auto name_field = [&](const nlohmann::json& object, std::string_view key, + bool nullable = false) { + auto it = object.find(std::string(key)); + return it == object.end() || (nullable && it->is_null()) + || (it->is_string() && valid_name(it->get())); + }; + if (!j.is_object()) return std::unexpected("expected a JSON object"); + if (!name_field(j, "default") || !name_field(j, "fallback") || !name_field(j, "once", true)) + return std::unexpected("boot entries must be SubOS names (alphanumeric, underscore, dash)"); + if (auto tries = j.find("tries"); tries != j.end()) { + if (!tries->is_object()) return std::unexpected("tries must be an object"); + for (auto it = tries->begin(); it != tries->end(); ++it) { + if (it.key().empty() || !valid_name(it.key()) || !it->is_number_integer() + || *it < 0 || *it > std::numeric_limits::max()) + return std::unexpected("tries must map SubOS names to nonnegative integers"); + } + } + if (auto last = j.find("last"); last != j.end()) { + if (!last->is_object() || !name_field(*last, "subos")) + return std::unexpected("last must be an object naming a SubOS"); + if (auto good = last->find("good"); good != last->end() && !good->is_boolean()) + return std::unexpected("last.good must be a boolean"); + if (auto via = last->find("via"); via != last->end()) { + if (!via->is_string()) return std::unexpected("last.via must be a string"); + const auto text = via->get(); + if (!text.empty() && text != "once" && text != "default" && text != "fallback") + return std::unexpected("last.via must be once, default or fallback"); + } + } + return {}; +} + +std::expected read_document(const fs::path& file) { + std::error_code ec; + const auto status = fs::symlink_status(file, ec); + if (ec && ec != std::errc::no_such_file_or_directory) + return std::unexpected(file.string() + ": cannot be inspected: " + ec.message()); + if (status.type() == fs::file_type::not_found) return nlohmann::json::object(); + if (!fs::is_regular_file(file, ec) || ec) + return std::unexpected(file.string() + ": is not a readable regular file"); + std::ifstream in(file, std::ios::binary); + if (!in) return std::unexpected(file.string() + ": cannot be read"); + std::string bytes; + try { bytes.assign(std::istreambuf_iterator(in), {}); } + catch (const std::exception& e) { return std::unexpected(file.string() + ": " + e.what()); } + auto j = nlohmann::json::parse(bytes, nullptr, false); + if (in.bad() || j.is_discarded()) + return std::unexpected(file.string() + ": is not a boot configuration"); + if (auto ok = validate(j); !ok) return std::unexpected(file.string() + ": " + ok.error()); + return j; +} + +} // namespace + +Config from_json(const nlohmann::json& j) { + Config c; + if (!j.is_object()) return c; + c.document = j; + c.default_entry = j.value("default", c.default_entry); + c.fallback = j.value("fallback", c.fallback); + if (auto it = j.find("once"); it != j.end() && it->is_string() && !it->get().empty()) + c.once = it->get(); + if (auto it = j.find("tries"); it != j.end() && it->is_object()) + for (auto e = it->begin(); e != it->end(); ++e) + if (e.value().is_number_integer()) c.tries[e.key()] = e.value().get(); + if (auto it = j.find("last"); it != j.end() && it->is_object()) { + c.booted = it->value("subos", ""); + c.via = it->value("via", ""); + c.good = it->value("good", false); + } + return c; +} + +nlohmann::json to_json(const Config& c) { + nlohmann::json j = c.document.is_object() ? c.document : nlohmann::json::object(); + j["default"] = c.default_entry; + j["fallback"] = c.fallback; + j["once"] = c.once ? nlohmann::json(*c.once) : nlohmann::json(nullptr); + j["tries"] = nlohmann::json::object(); + for (auto& [k, v] : c.tries) j["tries"][k] = v; + if (!c.booted.empty()) { + if (!j.contains("last") || !j["last"].is_object()) j["last"] = nlohmann::json::object(); + j["last"]["subos"] = c.booted; + j["last"]["via"] = c.via; + j["last"]["good"] = c.good; + } else if (j.contains("last")) { + for (auto key : {"subos", "via", "good"}) j["last"].erase(key); + if (j["last"].empty()) j.erase("last"); + } + return j; +} + +std::expected load(const fs::path& file) { + auto j = read_document(file); + if (!j) return std::unexpected(j.error()); + return from_json(*j); +} + +std::expected save(const fs::path& file, const Config& c) { + auto document = read_document(file); + if (!document) return std::unexpected(document.error()); + auto next = c; + next.document = *document; + const auto json = to_json(next); + if (auto ok = validate(json); !ok) return std::unexpected(file.string() + ": " + ok.error()); + std::error_code ec; + if (!file.parent_path().empty()) fs::create_directories(file.parent_path(), ec); + if (ec) return std::unexpected(file.string() + ": " + ec.message()); + try { + platform::write_file_atomic(file.string(), json.dump(2) + "\n"); + } catch (const std::exception& e) { + return std::unexpected(e.what()); + } + return {}; +} + +std::vector candidates(const Config& c, + const std::function& bootable) { + std::vector out; + auto add = [&](const std::string& name, std::string via) { + if (name.empty() || !bootable(name)) return; + if (std::ranges::find(out, name, &Candidate::subos) != out.end()) return; + out.push_back({name, std::move(via)}); + }; + if (c.once) add(*c.once, "once"); + auto left = c.tries.find(c.default_entry); + if (left == c.tries.end() || left->second > 0) add(c.default_entry, "default"); + add(c.fallback, "fallback"); + return out; +} + +Config record_boot(Config c, const Candidate& chosen) { + if (chosen.via == "once") c.once.reset(); + auto it = c.tries.find(chosen.subos); + const int left = it == c.tries.end() ? kTries : it->second; + c.tries[chosen.subos] = std::max(0, left - 1); + c.booted = chosen.subos; + c.via = chosen.via; + c.good = false; + return c; +} + +Config mark_good(Config c) { + if (c.booted.empty()) return c; + c.good = true; + c.tries[c.booted] = kTries; + return c; +} + +} // namespace luban::boot diff --git a/luban/src/boot/boot.cppm b/luban/src/boot/boot.cppm new file mode 100644 index 000000000..ad2882496 --- /dev/null +++ b/luban/src/boot/boot.cppm @@ -0,0 +1,60 @@ +export module luban.boot; + +import std; +import xlings.libs.json; + +// Which SubOS a machine boots (design part 2 §8): `/boot.json`. +// +// The maintainer's model: the boot entries are configured at run time and +// take effect on the next boot. A package change inside the running host is +// live (a new generation); changing WHICH SubOS is the host happens when +// stage-0 runs. Pure: the file's content in, the choice and the content to +// write back out. Stage-0 and `subos boot` share it, and the tests drive it +// without a machine. +export namespace luban::boot { + +namespace fs = std::filesystem; + +// A boot that is not marked good this many times in a row stops being chosen. +inline constexpr int kTries = 3; + +struct Config { + std::string default_entry { "default" }; + std::string fallback { "default" }; + std::optional once; // the next boot only (a trial) + std::map tries; // boots left before falling back + // The last boot, as stage-0 recorded it. + std::string booted; + std::string via; // "once" | "default" | "fallback" + bool good { false }; + nlohmann::json document { nlohmann::json::object() }; // unknown schema fields survive updates +}; + +Config from_json(const nlohmann::json& j); +nlohmann::json to_json(const Config& c); + +// Absent is the default configuration; unreadable is an error (a machine is +// never booted from a guess about a file it could not read). +std::expected load(const fs::path& file); +// Written beside the file and renamed over it. +std::expected save(const fs::path& file, const Config& c); + +struct Candidate { + std::string subos; + std::string via; +}; + +// In order: the trial, the default (while it has tries left), the fallback. +// `bootable` answers whether a SubOS can be booted at all (it exists and has +// a root); one that cannot is skipped, never chosen. +std::vector candidates(const Config& c, + const std::function& bootable); + +// The configuration to write before exec'ing `chosen`'s init: a trial is +// consumed, the chosen entry spends a try, the boot is recorded as not yet good. +Config record_boot(Config c, const Candidate& chosen); + +// The running boot worked: its tries are restored. +Config mark_good(Config c); + +} // namespace luban::boot diff --git a/luban/src/machine/machine.cpp b/luban/src/machine/machine.cpp new file mode 100644 index 000000000..eee9ab776 --- /dev/null +++ b/luban/src/machine/machine.cpp @@ -0,0 +1,201 @@ +module luban.machine; + +import std; +import xlings.platform; +import xlings.subos.rootfs; + +namespace luban::machine { + +namespace platform = xlings::platform; + +namespace { + +using MachineDirectory = platform::machine_etc::Directory; + +std::expected, std::string> factory_directory(const fs::path& source) { + std::error_code ec; + const auto status = fs::symlink_status(source, ec); + if (ec == std::errc::no_such_file_or_directory || (!ec && !fs::exists(status))) + return std::optional{}; + if (ec) return std::unexpected("cannot inspect factory " + source.string() + ": " + ec.message()); + const auto canonical = fs::canonical(source, ec); + if (ec) return std::unexpected("cannot resolve factory " + source.string() + ": " + ec.message()); + if (!fs::is_directory(canonical, ec) || ec) + return std::unexpected("factory is not a readable directory: " + source.string()); + return std::optional{canonical}; +} + +// Projection leaves can themselves be links into payloads. Read a passwd or +// group factory through its canonical regular file; destination authority is +// always the anchored machine directory, never the source's canonical path. +std::expected factory_text(const fs::path& file) { + std::error_code ec; + const auto canonical = fs::canonical(file, ec); + if (ec) return std::unexpected("cannot resolve factory file " + file.string() + ": " + ec.message()); + auto source = MachineDirectory::open(canonical.parent_path()); + if (!source) return std::unexpected(source.error()); + auto text = source->read_regular(canonical.filename()); + if (!text) return std::unexpected(text.error()); + if (!*text) return std::unexpected("factory file disappeared: " + file.string()); + return std::move(**text); +} + +std::expected, std::string> +fill_factory(MachineDirectory& destination, const fs::path& prefix, const fs::path& factory) { + std::vector added; + auto source = factory_directory(factory); + if (!source) return std::unexpected(source.error()); + if (!*source) return added; + std::error_code ec; + for (fs::recursive_directory_iterator it(**source, ec), end; !ec && it != end; it.increment(ec)) { + // relative() resolves source symlinks and can manufacture '../...'. + // The iterator's spelling, relative to its base, owns the destination. + const auto rel = it->path().lexically_relative(**source); + const auto at = prefix / rel; + if (rel.empty() || rel.is_absolute() || std::ranges::any_of(rel, [](const fs::path& c) { return c == ".."; })) + return std::unexpected("factory entry escapes its directory: " + it->path().string()); + const auto status = it->symlink_status(ec); + if (ec) break; + if (fs::is_directory(status)) { + auto directory = destination.ensure_directory(at); + if (!directory) return std::unexpected(directory.error()); + if (!*directory) it.disable_recursion_pending(); + continue; + } + if (!fs::is_regular_file(status) && !fs::is_symlink(status)) + return std::unexpected("unsupported factory entry: " + it->path().string()); + std::expected created { false }; + if (at == "passwd" || at == "group") { + auto existing = destination.read_regular(at, true); + if (!existing) return std::unexpected(existing.error()); + if (*existing) continue; + auto text = factory_text(it->path()); + if (!text) return std::unexpected(text.error()); + created = destination.write_missing(at, *text); + } else { + // Keep the projection's original logical spelling across prefixes. + created = destination.link_missing(at, factory / rel); + } + if (!created) return std::unexpected(created.error()); + if (*created) added.push_back(at.generic_string()); + } + if (ec) return std::unexpected("cannot enumerate factory " + factory.string() + ": " + ec.message()); + return added; +} + +} // namespace + +std::expected, std::string> fill_etc(const fs::path& etc, const fs::path& factory) { + auto destination = MachineDirectory::open(etc, true); + if (!destination) return std::unexpected(destination.error()); + return fill_factory(*destination, {}, factory); +} + +std::expected, std::string> fill_machine_etc(const fs::path& etc, const fs::path& subos) { + constexpr std::array kViewEtc{"passwd", "group", "hosts", "nsswitch.conf"}; + auto destination = MachineDirectory::open(etc, true); + if (!destination) return std::unexpected(destination.error()); + auto added = fill_factory(*destination, {}, subos / std::string(xlings::subos::rootfs::kPointer) / "usr" / "share" / "factory" / "etc"); + if (!added) return std::unexpected(added.error()); + auto source = factory_directory(subos / "etc"); + if (!source) return std::unexpected(source.error()); + if (!*source) return added; + std::error_code ec; + for (fs::directory_iterator it(**source, ec), end; !ec && it != end; it.increment(ec)) { + const auto n = it->path().filename().string(); + if (std::ranges::find(kViewEtc, n) != kViewEtc.end()) continue; + const auto status = it->symlink_status(ec); + if (ec) break; + if (fs::is_directory(status)) { + auto directory = destination->ensure_directory(n); + if (!directory) return std::unexpected(directory.error()); + if (!*directory) continue; + auto files = fill_factory(*destination, n, subos / "etc" / n); + if (!files) return std::unexpected(files.error()); + added->insert(added->end(), files->begin(), files->end()); + } else if (fs::is_regular_file(status) || fs::is_symlink(status)) { + auto created = destination->link_missing(n, subos / "etc" / n); + if (!created) return std::unexpected(created.error()); + if (*created) added->push_back(n); + } else return std::unexpected("unsupported SubOS /etc entry: " + it->path().string()); + } + if (ec) return std::unexpected("cannot enumerate SubOS /etc: " + ec.message()); + return added; +} + +namespace { + +// "u name uid \"gecos\" home shell" -> fields, quotes honoured. +std::vector sysusers_fields(const std::string& line) { + std::vector f; + std::string cur; + bool quoted = false, any = false; + for (char c : line) { + if (c == '"') { quoted = !quoted; any = true; continue; } + if (!quoted && (c == ' ' || c == '\t')) { + if (any || !cur.empty()) { f.push_back(cur); cur.clear(); any = false; } + continue; + } + cur.push_back(c); + } + if (any || !cur.empty()) f.push_back(cur); + return f; +} + +} // namespace + +std::expected, std::string> apply_sysusers(const fs::path& etc, const fs::path& usr) { + auto destination = MachineDirectory::open(etc, true); + if (!destination) return std::unexpected(destination.error()); + // A factory DB is copied exclusively before this step. Unknown links and + // shared inodes must never be materialized or appended through. + for (const auto name : {"passwd", "group"}) { + auto observed = destination->read_regular(name, true); + if (!observed) return std::unexpected(observed.error()); + } + std::vector> users; + std::vector> groups; + auto add_group = [&](const std::string& name, const std::string& gid) { + groups.emplace_back(name, std::format("{}:x:{}:\n", name, gid)); + }; + auto add_user = [&](const std::string& name, const std::string& uid, const std::string& gecos, + const std::string& home, const std::string& shell) { + add_group(name, uid); + users.emplace_back(name, std::format("{}:x:{}:{}:{}:{}:{}\n", name, uid, uid, gecos, home, shell)); + }; + add_user("root", "0", "root", "/root", "/bin/sh"); + auto confDirectory = factory_directory(usr / "lib" / "sysusers.d"); + if (!confDirectory) return std::unexpected(confDirectory.error()); + if (*confDirectory) { + std::vector confs; + std::error_code ec; + for (fs::directory_iterator it(**confDirectory, ec), end; !ec && it != end; it.increment(ec)) + if (it->path().extension() == ".conf") confs.push_back(it->path()); + if (ec) return std::unexpected("cannot enumerate sysusers.d: " + ec.message()); + std::ranges::sort(confs); + for (const auto& conf : confs) { + auto text = factory_text(conf); + if (!text) return std::unexpected(text.error()); + std::istringstream in{*text}; + for (std::string line; std::getline(in, line);) { + auto f = sysusers_fields(line); + if (f.size() < 3 || f[0].starts_with('#')) continue; + if (f[0] == "g") add_group(f[1], f[2]); + else if (f[0] == "u") + add_user(f[1], f[2], f.size() > 3 && f[3] != "-" ? f[3] : f[1], + f.size() > 4 && f[4] != "-" ? f[4] : "/", + f.size() > 5 && f[5] != "-" ? f[5] : "/bin/sh"); + } + } + } + auto groupNames = destination->append_named("group", groups); + if (!groupNames) return std::unexpected(groupNames.error()); + auto userNames = destination->append_named("passwd", users); + if (!userNames) return std::unexpected(userNames.error()); + std::vector added; + for (const auto& name : *groupNames) added.push_back("group " + name); + for (const auto& name : *userNames) added.push_back("user " + name); + return added; +} + +} // namespace luban::machine diff --git a/luban/src/machine/machine.cppm b/luban/src/machine/machine.cppm new file mode 100644 index 000000000..7e1ea53ff --- /dev/null +++ b/luban/src/machine/machine.cppm @@ -0,0 +1,31 @@ +export module luban.machine; + +import std; + +// A machine's own /etc (SubOS design part 2 §6.4, part 3 §8): what a root +// needs when it is a machine -- factory files linked in where the machine has +// none, users and groups its packages declare. Machine state is user data: +// nothing that exists is replaced. +export namespace luban::machine { + +namespace fs = std::filesystem; + +// Factory /etc (design part 2 §6.4): every file under `factory` that `etc` +// does not have is linked to it -- a link the user may replace with a file of +// their own. Nothing that exists is touched. passwd/group are private regular +// copies for sysusers; directory symlinks and write/read failures are errors. +std::expected, std::string> fill_etc(const fs::path& etc, const fs::path& factory); + +// A root's machine /etc from what its SubOS provides: the projection's +// usr/share/factory/etc, and the SubOS's sysroot /etc (certificates a package +// placed) minus the files that stand in for a host in a sandbox view. +// fill_etc's rule: only what is missing. Returns what it added. +std::expected, std::string> fill_machine_etc(const fs::path& etc, const fs::path& subos); + +// sysusers.d (systemd's format, without systemd): `u name uid "gecos" home +// shell` and `g name gid` lines from `/lib/sysusers.d/*.conf`. Users and +// groups missing from etc/passwd and etc/group are appended; root always +// exists. Nothing is rewritten. Returns the names added. +std::expected, std::string> apply_sysusers(const fs::path& etc, const fs::path& usr); + +} // namespace luban::machine diff --git a/luban/src/stage0/stage0.cpp b/luban/src/stage0/stage0.cpp new file mode 100644 index 000000000..d23e23b17 --- /dev/null +++ b/luban/src/stage0/stage0.cpp @@ -0,0 +1,234 @@ +// See stage0.cppm for the boot boundary: no Config and no guessing unreadable state. +module luban.stage0; + +import std; +import xlings.libs.json; +import xlings.platform; +import luban.boot; +import luban.machine; +import xlings.subos.rootfs; +import xlings.subos.library_cache; +import xlings.subos.home_view; + +namespace luban::stage0 { + +namespace platform = xlings::platform; +namespace rootfs = xlings::subos::rootfs; +namespace library_cache = xlings::subos::library_cache; +using xlings::subos::HomeView; + +namespace { + +// The name it was started as: luban-init, or xlings-init (xlings itself). +std::string& self_name() { + static std::string name = "luban-init"; + return name; +} + +void say(std::string_view msg) { + std::cerr << self_name() << ": " << msg << std::endl; +} + +std::expected read_json(const fs::path& path) { + std::error_code ec; + const auto status = fs::symlink_status(path, ec); + if (ec && ec != std::errc::no_such_file_or_directory) + return std::unexpected(path.string() + ": cannot be inspected: " + ec.message()); + if (status.type() == fs::file_type::not_found) return nlohmann::json::object(); + if (!fs::is_regular_file(path, ec) || ec) + return std::unexpected(path.string() + ": is not a readable regular file"); + std::ifstream in(path, std::ios::binary); + if (!in) return std::unexpected(path.string() + ": cannot be read"); + std::string bytes; + try { bytes.assign(std::istreambuf_iterator(in), {}); } + catch (const std::exception& e) { return std::unexpected(path.string() + ": " + e.what()); } + const auto document = nlohmann::json::parse(bytes, nullptr, false); + if (in.bad() || !document.is_object()) + return std::unexpected(path.string() + ": expected a JSON object"); + return document; +} + +std::expected is_root_subos(const HomeView& home, std::string_view name) { + std::error_code ec; + const auto status = fs::status(home.instance(name), ec); + if (ec && ec != std::errc::no_such_file_or_directory) + return std::unexpected("cannot inspect boot instance: " + ec.message()); + if (!fs::is_directory(status)) return false; + const auto file = home.instance_file(name); + const auto document = read_json(file); + if (!document) return std::unexpected(document.error()); + const auto kind = document->find("kind"); + if (kind == document->end()) return false; + if (!kind->is_string() || (*kind != "rootfs" && *kind != "view")) + return std::unexpected(file.string() + ": unknown or invalid SubOS kind"); + if (*kind == "view") return false; + return rootfs::current(home.instance(name)).has_value(); +} + +void log_event(const HomeView& home, const nlohmann::json& event) { + std::error_code ec; + fs::create_directories(home.home / "logs", ec); + std::ofstream out(home.home / "logs" / "boot.ndjson", std::ios::app); + out << event.dump() << "\n"; + out.close(); + if (ec || !out) say("cannot append boot audit"); +} + +[[noreturn]] void recover(std::string_view reason) { + say(std::string(reason) + "; entering recovery; repair the named metadata before rebooting"); + for (;;) { + for (auto shell : {"/bin/sh", "/usr/bin/sh"}) { + std::error_code ec; + if (!fs::exists(shell, ec)) continue; + say("starting a recovery shell; exiting it returns to recovery"); + // Keep stage-0 as PID 1: an exiting recovery shell must not panic the kernel. + platform::run_argv({shell}); + break; + } + std::this_thread::sleep_for(std::chrono::seconds(10)); + } +} + +} // namespace + +std::expected read_home_anchor(const fs::path& anchor) { + std::error_code ec; + const auto status = fs::symlink_status(anchor, ec); + if (ec && ec != std::errc::no_such_file_or_directory) + return std::unexpected(anchor.string() + ": cannot be inspected: " + ec.message()); + if (status.type() == fs::file_type::not_found) return fs::path("/xlings"); + const auto document = read_json(anchor); + if (!document) return std::unexpected(document.error()); + const auto value = document->find("home"); + if (value == document->end() || !value->is_string()) + return std::unexpected(anchor.string() + ": home must be an absolute path string"); + const fs::path home = value->get(); + if (home.empty() || !home.is_absolute() || home.lexically_normal() != home) + return std::unexpected(anchor.string() + ": home must be a nonempty normalized absolute path"); + return home; +} + +std::expected, std::string> init_of(const HomeView& home, + std::string_view name) { + const auto usr = rootfs::usr_of(home.instance(name)); + const auto file = home.instance_file(name); + const auto document = read_json(file); + if (!document) return std::unexpected(document.error()); + if (const auto kind = document->find("kind"); kind != document->end() + && (!kind->is_string() || (*kind != "rootfs" && *kind != "view"))) + return std::unexpected(file.string() + ": unknown or invalid SubOS kind"); + auto in_usr = [&](const fs::path& path) -> std::expected, std::string> { + if (!path.is_absolute() || path == "/" || path.lexically_normal() != path) + return std::unexpected(file.string() + ": init must be a normalized absolute executable path"); + const auto rel = path.relative_path(); + const auto first = rel.begin()->string(); + const fs::path at = first == "usr" ? usr / rel.lexically_relative("usr") + : (first == "sbin" || first == "bin") ? usr / "bin" / rel.lexically_relative(first) + : path; + std::error_code ec; + const auto entry = fs::symlink_status(at, ec); + if (ec && ec != std::errc::no_such_file_or_directory) + return std::unexpected("cannot inspect init " + at.string() + ": " + ec.message()); + if (entry.type() == fs::file_type::not_found) return std::nullopt; + const auto status = fs::status(at, ec); + if (ec || !fs::is_regular_file(status)) + return std::unexpected("init is unreadable or not a regular executable: " + at.string()); + if ((status.permissions() & (fs::perms::owner_exec | fs::perms::group_exec | fs::perms::others_exec)) + == fs::perms::none) + return std::unexpected("init is not executable: " + at.string()); + return std::optional{path}; + }; + const auto declared = document->find("init"); + if (declared != document->end()) { + if (!declared->is_string() || declared->get().empty()) + return std::unexpected(file.string() + ": init must be a nonempty absolute path string"); + return in_usr(declared->get()); + } + for (auto candidate : {"/sbin/init", "/usr/bin/init"}) { + auto init = in_usr(candidate); + if (!init || init->has_value()) return init; + } + return std::nullopt; +} + +int run(int argc, char* argv[]) { + if (argc >= 1 && argv[0] && *argv[0]) self_name() = std::filesystem::path(argv[0]).filename().string(); + if (!platform::is_pid1()) { + say("runs as a machine's first process (the kernel's init=); this is not PID 1"); + return 1; + } + const auto started = std::chrono::steady_clock::now(); + for (auto [type, target] : {std::pair{"proc", "/proc"}, std::pair{"sysfs", "/sys"}, + std::pair{"devtmpfs", "/dev"}, std::pair{"tmpfs", "/run"}}) { + if (!platform::mount_kernel_fs(type, target)) + say(std::format("{} on {}: {} (continuing)", type, target, + platform::error_text(platform::last_error()))); + } + if (!platform::remount_root_rw()) recover("/ stays read-only; boot state cannot be recorded"); + const auto anchor = read_home_anchor(std::string(kAnchor)); + if (!anchor) recover(anchor.error()); + const HomeView home{*anchor}; + auto config = boot::load(home.boot_file()); + if (!config) recover(config.error()); + + std::map bootable; + for (const auto& name : {config->default_entry, config->fallback, config->once.value_or("")}) { + if (name.empty() || bootable.contains(name)) continue; + const auto root = is_root_subos(home, name); + if (!root) recover(root.error()); + bootable[name] = *root; + } + if (config->once && !bootable[*config->once]) { + auto next = *config; + next.once.reset(); + if (auto saved = boot::save(home.boot_file(), next); !saved) recover(saved.error()); + say(std::format("the trial '{}' is not a bootable SubOS; dropped", *config->once)); + config = next; + } + const auto candidates = boot::candidates(*config, [&](std::string_view name) { + return bootable[std::string(name)]; + }); + for (const auto& candidate : candidates) { + const auto init = init_of(home, candidate.subos); + if (!init) recover(init.error()); + if (!init->has_value()) { + say(std::format("'{}' has no init in its /usr; trying the next entry", candidate.subos)); + log_event(home, {{"event", "boot-skip"}, {"subos", candidate.subos}, {"why", "no init"}}); + if (candidate.via == "once") { + auto next = *config; + next.once.reset(); + if (auto saved = boot::save(home.boot_file(), next); !saved) recover(saved.error()); + config = next; + } + continue; + } + const auto dir = home.instance(candidate.subos); + if (auto laid = rootfs::lay_out("/", rootfs::usr_of(dir), home.home); !laid) { + say(laid.error()); + continue; + } + if (auto etc = luban::machine::fill_machine_etc("/etc", dir); !etc) recover("machine /etc: " + etc.error()); + if (auto users = luban::machine::apply_sysusers("/etc", rootfs::usr_of(dir)); !users) recover("machine /etc: " + users.error()); + if (auto cache = library_cache::refresh("/", home, candidate.subos); !cache) recover(cache.error()); + const auto next = boot::record_boot(*config, candidate); + if (auto saved = boot::save(home.boot_file(), next); !saved) recover(saved.error()); + const auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - started).count(); + log_event(home, {{"event", "boot"}, {"subos", candidate.subos}, {"via", candidate.via}, + {"generation", rootfs::current(dir).value_or(0)}, {"init", (*init)->string()}, + {"stage0_elapsed_us", elapsed}}); + say(std::format("booting '{}' ({}), generation {}", candidate.subos, candidate.via, + rootfs::current(dir).value_or(0))); + const std::map env{ + {"PATH", "/usr/local/sbin:/usr/local/bin:/usr/bin:/bin"}, {"HOME", "/root"}, + {"TERM", "linux"}, {"XLINGS_HOME", home.home.string()}}; + const int error = platform::exec_path({(*init)->string()}, env); + say(std::format("{} did not start: {}; trying the next entry", (*init)->string(), + platform::error_text(error))); + log_event(home, {{"event", "boot-failed"}, {"subos", candidate.subos}, {"init", (*init)->string()}}); + config = next; + } + recover("nothing could be booted"); +} + +} // namespace luban::stage0 diff --git a/luban/src/stage0/stage0.cppm b/luban/src/stage0/stage0.cppm new file mode 100644 index 000000000..a977f025e --- /dev/null +++ b/luban/src/stage0/stage0.cppm @@ -0,0 +1,44 @@ +export module luban.stage0; + +import std; +import xlings.subos.home_view; + +// Stage-0 (design part 2 §8.2): xlings as the first process of a machine +// whose root is a SubOS -- the kernel's `init=/boot/xlings-init`. +// +// It is the static xlings, so it depends on no generation of any SubOS: a +// broken glibc, a half-written /usr, a SubOS that will not boot -- stage-0 is +// still there to choose another. In order: +// +// 1. the kernel's file systems, / read-write; +// 2. the system home from /etc/xlings/root.json (default /xlings), and +// which SubOS to boot from its boot.json (a trial, the default while it +// has tries left, the fallback); +// 3. /usr pointed at that SubOS's current generation, its factory /etc and +// sysusers applied; +// 4. its init exec'd -- and when that fails, the next candidate. +// +// Before any home or Config is read: at this point /proc is not mounted, so +// nothing that finds a home by its executable could. +namespace luban::stage0 { using xlings::subos::HomeView; } + +export namespace luban::stage0 { + +namespace fs = std::filesystem; + +inline constexpr std::string_view kName = "xlings-init"; // xlings itself, under this name +inline constexpr std::string_view kStandalone = "luban-init"; // apps/luban-init +inline constexpr std::string_view kAnchor = "/etc/xlings/root.json"; + +// What a SubOS boots into: the `init` its instance.json declares, else the +// first of /sbin/init, /usr/bin/init present in its /usr. +// Missing legacy anchor defaults to /xlings; present unreadable or malformed +// metadata is never a reason to guess a machine home or init. +std::expected read_home_anchor(const fs::path& anchor); +std::expected, std::string> init_of(const HomeView& home, + std::string_view name); + +// Runs as PID 1. Returns only when it cannot be stage-0 (not PID 1). +int run(int argc, char* argv[]); + +} // namespace luban::stage0 diff --git a/mcpp.lock b/mcpp.lock index 89009ff95..9b6fb0b34 100644 --- a/mcpp.lock +++ b/mcpp.lock @@ -5,6 +5,11 @@ # file must not change depending on which command ran last. version = 2 +[package."mcpplibs.xpkg"] +version = "912720f7aa2204a4c48f0d989331af993bb3c1e4" +source = "git+https://github.com/openxlings/libxpkg.git#rev=912720f7aa2204a4c48f0d989331af993bb3c1e4" +hash = "fnv1a:84ad1e27eb935f69" + [package."compat.bzip2"] namespace = "compat" version = "1.0.8" @@ -71,12 +76,6 @@ version = "0.2.9" source = "index+mcpplibs@0.2.9" hash = "fnv1a:a01132893163f775" -[package."mcpplibs.xpkg"] -namespace = "mcpplibs" -version = "0.0.60" -source = "index+mcpplibs@0.0.60" -hash = "fnv1a:86ae98702bf16787" - [package."mcpplibs.capi.lua"] namespace = "mcpplibs.capi" version = "0.0.3" diff --git a/mcpp.toml b/mcpp.toml index 451d783fb..d6d260e16 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,41 +1,55 @@ # Members are independent packages with their own mcpp.toml, referenced by # path. Listed leaves-first, which is also dependency order: # -# cancellation leaf, std only -# platform -> cancellation (81 of 211 TUs import it) -# theme leaf, std + the JSON header -# apps/gui the GUI binary's library half +# libs json, sha256, tinyhttps: self-contained, nothing of xlings +# runtime -> libs: cancellation, guard (confirmation), observe (journal) +# platform -> runtime: the OS boundary -- every system header and call +# ui -> platform: theme and i18n +# store -> libs, platform: what keeps a payload alive (GC roots, the +# retained ledger) +# subos -> libs, runtime, platform, store: the SubOS core +# confine -> subos: the sandbox implementations, their selector, the +# platform matrix +# carrier -> subos: where a SubOS runs (local, wsl2, vz) and how this +# xlings reaches it +# luban -> modules: what exists only when a SubOS is a machine -- +# boot entries, stage-0 (apps/luban-init), the machine's /etc +# testkit -> libs: end-to-end test library (dev-dependency only) +# apps/gui the GUI binary's library half +# apps/xdev the development tool (never linked into xlings) # # `apps/` holds what becomes a binary; `modules/` holds what gets linked into -# one. `src/` is what has not been separated yet -- core, cli, ui, runtime -- -# and shrinking it is the direction. -# -# The vendored units moved too (json wraps nlohmann's header, tinyhttps wraps -# mcpplibs'): a module this project builds and exports is a module of this -# project, whoever wrote the code inside. Keeping them under `src/libs` while -# `modules/` existed meant two names for one idea, and `libs/` vs `src/libs/` -# already read as the same thing. +# one, a package per responsibility (not per file). `src/` is what has not +# been separated yet -- core, cli, ui, runtime -- and shrinking it is the +# direction. [workspace] members = [ - "modules/json", - "modules/sha256", - "modules/cancellation", + "modules/libs", + "modules/runtime", "modules/platform", - "modules/i18n", - "modules/tinyhttps", - "modules/theme", + "modules/ui", + "modules/store", + "modules/subos", + "modules/confine", + "modules/carrier", + "luban", + "apps/luban-init", + "modules/testkit", "apps/gui", + "apps/xdev", ] [package] name = "xlings" -version = "2026.10.8.1" +version = "2026.10.9.1" description = "Universal package management infrastructure tool with SubOS isolation" license = "Apache-2.0" repo = "https://github.com/openxlings/xlings" [build] -include_dirs = ["modules/json/src/json"] +# modules/testkit/include holds xtest.hpp (the XTEST macro). Only test sources +# include it; it is here because test TUs are compiled with the root's flags. +include_dirs = ["modules/libs/src/json", "modules/testkit/include"] # Minimum supported macOS for produced binaries (mcpp 0.0.50+ honors # this; the MACOSX_DEPLOYMENT_TARGET env in CI/release remains as the # second line of assurance and covers older bootstrap mcpp versions). @@ -116,26 +130,28 @@ linkage = "static" linkage = "static" [dependencies] -theme = { path = "modules/theme" } -platform = { path = "modules/platform" } -cancellation = { path = "modules/cancellation" } -i18n = { path = "modules/i18n" } -json = { path = "modules/json" } -sha256 = { path = "modules/sha256" } -# Named `xhttp` here to avoid colliding with `mcpplibs.tinyhttps`, which this -# member wraps. mcpp resolves dependency names in one namespace, so a member -# that wraps a registry package cannot share its bare name. -xhttp = { path = "modules/tinyhttps" } +libs = { path = "modules/libs" } +runtime = { path = "modules/runtime" } +platform = { path = "modules/platform" } +ui = { path = "modules/ui" } +store = { path = "modules/store" } +subos = { path = "modules/subos" } +confine = { path = "modules/confine" } +carrier = { path = "modules/carrier" } +luban = { path = "luban" } [dependencies.compat] ftxui = "6.1.9" libarchive = "3.8.7" [dependencies.mcpplibs] +xpkg = { git = "https://github.com/openxlings/libxpkg.git", rev = "912720f7aa2204a4c48f0d989331af993bb3c1e4" } cmdline = "0.0.2" -xpkg = "0.0.60" capi.lua = "0.0.3" +[dev-dependencies] +testkit = { path = "modules/testkit" } + [dev-dependencies.compat] gtest = "1.15.2" diff --git a/modules/cancellation/mcpp.toml b/modules/cancellation/mcpp.toml deleted file mode 100644 index 3d8717b2a..000000000 --- a/modules/cancellation/mcpp.toml +++ /dev/null @@ -1,13 +0,0 @@ -# Cooperative cancellation, as its own package. -# -# A leaf: it imports `std` and nothing else. That is what lets `platform` -# depend on it without dragging anything along -- `platform` is imported by 81 -# of this repository's 211 translation units, so anything it depends on is -# effectively depended on by the whole product. -[package] -namespace = "xlings" -name = "cancellation" -version = "0.1.0" - -[targets.cancellation] -kind = "lib" diff --git a/modules/carrier/mcpp.toml b/modules/carrier/mcpp.toml new file mode 100644 index 000000000..b60e7d943 --- /dev/null +++ b/modules/carrier/mcpp.toml @@ -0,0 +1,29 @@ +# Carriers, as their own package (SubOS design part 3 §5). +# +# Where a SubOS runs and how the xlings on this machine reaches it: +# +# local this machine's kernel -- the SubOS is here (every Linux SubOS, +# and a macOS / Windows one of the host's own ABI) +# wsl2 a WSL2 distribution per home, a Luban (tiny) whose /xlings is the +# home's Linux side; reached through wsl.exe +# vz a Virtualization.framework VM per home, the same shape on macOS +# +# One protocol, the NDJSON interface: the xlings there answers `xlings +# interface ` as the one here does, and a terminal runs `xlings +# ` there with this terminal attached. Nothing new is spoken. +[package] +namespace = "xlings" +name = "carrier" +version = "0.1.0" + +[targets.carrier] +kind = "lib" + +[dependencies] +libs = { path = "../libs" } +platform = { path = "../platform" } +runtime = { path = "../runtime" } +subos = { path = "../subos" } + +[build] +include_dirs = ["../libs/src/json"] diff --git a/modules/carrier/src/carrier.cpp b/modules/carrier/src/carrier.cpp new file mode 100644 index 000000000..b56195705 --- /dev/null +++ b/modules/carrier/src/carrier.cpp @@ -0,0 +1,135 @@ +module xlings.carrier; + +import std; +import xlings.libs.json; +import xlings.platform; +import xlings.platform.stream; +import xlings.subos.home_view; +import xlings.carrier.wsl2; +import xlings.carrier.vz; + +namespace xlings::carrier { + +namespace { + +Carrier local() { + Carrier c; + c.name = "local"; + c.probe = [](const HomeView&) { return Probe{.supported = true, .reason = "this machine's kernel"}; }; + c.ensure = [](const HomeView&) -> std::expected { + return Endpoint{.carrier = "local", .xlings = platform::get_executable_path().string()}; + }; + c.stop = [](const Endpoint&) -> std::expected { return {}; }; + c.grant = [](const Endpoint&, const fs::path& host, bool, std::string_view) -> std::expected { + return host.string(); // it is already here + }; + return c; +} + +std::vector command(const Endpoint& at, std::span args, + const std::map& env) { + std::vector argv = at.launcher; + argv.push_back(at.xlings); + if (!at.launcher.empty() && !env.empty()) { + // A launcher may not carry variables across (wsl.exe does not), and + // a guest has no userland of its own to do it (no env(1)): the xlings + // there sets them and goes on as `xlings `. + argv.push_back("__carrier-env"); + for (const auto& [k, v] : env) argv.push_back(k + "=" + v); + argv.push_back("--"); + } + argv.insert(argv.end(), args.begin(), args.end()); + return argv; +} + +} // namespace + +std::span carriers() { + static const std::vector all{local(), wsl2::make(), vz::make()}; + return all; +} + +namespace { +ImageSource& image_source_slot() { + static ImageSource source; + return source; +} +} // namespace + +void set_image_source(ImageSource source) { image_source_slot() = std::move(source); } +const ImageSource& image_source() { return image_source_slot(); } + +const Carrier* find(std::string_view name) { + for (const auto& c : carriers()) + if (c.name == name) return &c; + return nullptr; +} + +std::vector carriers_of(std::string_view platform) { + if (platform == "windows") return {"local", "wsl2"}; + if (platform == "macos") return {"local", "vz"}; + // XLINGS_WSL_EXE / XLINGS_VZ_HELPER (stand-ins) let the guest carriers' + // lifecycles be exercised on a Linux CI host; nothing else enables them. + std::vector out{"local"}; + if (const char* seam = std::getenv("XLINGS_WSL_EXE"); seam && *seam) out.push_back("wsl2"); + if (const char* seam = std::getenv("XLINGS_VZ_HELPER"); seam && *seam) out.push_back("vz"); + return out; +} + +int terminal(const Endpoint& at, std::span args, const std::map& env) { + if (at.launcher.empty()) + for (const auto& [k, v] : env) platform::set_env_variable(k, v); + return platform::run_argv(command(at, args, env)); +} + +std::expected control(const Endpoint& at, std::string_view capability, const nlohmann::json& args) { + const std::vector call{"interface", std::string(capability), "--args", args.dump()}; + std::string out, err; + const int rc = platform::stream::run(command(at, call, {}), [&](std::string_view stream, std::string_view bytes) { + (stream == "stderr" ? err : out).append(bytes); + }); + Reply reply; + reply.diagnostics = std::move(err); + std::istringstream lines(out); + for (std::string line; std::getline(lines, line);) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + auto j = nlohmann::json::parse(line, nullptr, false); + if (j.is_discarded() || !j.is_object()) continue; + if (j.value("kind", "") == "result") reply.result = std::move(j); + else reply.events.push_back(std::move(j)); + } + if (!reply.result.is_object()) { + return std::unexpected(std::format("{} carrier: `xlings interface {}` answered no result (exit {}){}{}", + at.carrier, capability, rc, reply.diagnostics.empty() ? "" : ": ", + reply.diagnostics)); + } + reply.exit_code = reply.result.value("exitCode", rc); + return reply; +} + +std::expected choose(std::string_view platform, const Want& want, + const std::function& probe) { + const auto available = carriers_of(platform); + auto checked = [&](std::string_view name, std::string why) -> std::expected { + const auto p = probe(name); + if (!p.supported) return std::unexpected(Refused{p.reason, p.route}); + return Choice{std::string(name), std::move(why)}; + }; + if (!want.requested.empty() && want.requested != "auto") { + if (std::ranges::find(available, want.requested) == available.end()) + return std::unexpected(Refused{std::format("the {} carrier does not exist on {}", want.requested, platform), + std::format("--carrier {}", available.front())}); + if (want.requested == "local" && platform != "linux" && (want.abi == "linux" || want.root)) + return std::unexpected(Refused{std::format("a Linux {} cannot run on {}'s own kernel", + want.root ? "root" : "SubOS", platform), + std::format("--carrier {}", available.back())}); + return checked(want.requested, "asked for"); + } + if (available.size() == 1) return checked(available.front(), "this machine's kernel"); + if (want.abi == "linux") return checked(available.back(), "its packages are Linux programs"); + if (want.root) return checked(available.back(), "a root is a Linux userland"); + if (want.strong) return checked(available.back(), "its policy needs isolation this kernel cannot give here"); + return checked(available.front(), "this machine's own programs"); +} + +} // namespace xlings::carrier diff --git a/modules/carrier/src/carrier.cppm b/modules/carrier/src/carrier.cppm new file mode 100644 index 000000000..66e930c4c --- /dev/null +++ b/modules/carrier/src/carrier.cppm @@ -0,0 +1,105 @@ +export module xlings.carrier; + +import std; +import xlings.libs.json; +import xlings.subos.home_view; + +// A carrier: where a SubOS runs, and how this xlings reaches it +// (SubOS design part 3 §5). +export namespace xlings::carrier { + +namespace fs = std::filesystem; +using xlings::subos::HomeView; + +// How to run something where the SubOS is. +struct Endpoint { + std::string carrier; // local | wsl2 | vz + std::string instance; // the guest's name (a WSL distribution); empty: local + std::vector launcher; // argv prefix that runs a command there; empty: here + std::string xlings; // the xlings to run there, as a path there +}; + +// Whether this machine can use a carrier, and the route when it cannot. +struct Probe { + bool supported { false }; + std::string reason; + std::string route; // a command or a step that brings it + std::string evidence; // what was measured (a version line, a probe's output) +}; + +struct Carrier { + std::string_view name; + // Measured, never assumed. + std::function probe; + // The endpoint for this home, created or started when needed. + std::function(const HomeView&)> ensure; + // Stop it (idle); a no-op for local. + std::function(const Endpoint&)> stop; + // Make a host directory visible there under `name`; its path there. + std::function(const Endpoint&, const fs::path& host, bool writable, + std::string_view name)> grant; +}; + +std::span carriers(); +const Carrier* find(std::string_view name); + +// `xlings ` where the endpoint is, this terminal attached, `env` added. +// The exit code is the command's (125 when it never started). +int terminal(const Endpoint& at, std::span args, + const std::map& env = {}); + +// One call of the NDJSON interface where the endpoint is. +struct Reply { + int exit_code { 125 }; + std::vector events; // every line before the result + nlohmann::json result; // the `result` line + std::string diagnostics; // stderr, for a failure message +}; +std::expected control(const Endpoint& at, std::string_view capability, + const nlohmann::json& args); + +// Which carrier a SubOS gets (part 3 §5.6). `requested`: "" or "auto" lets +// this decide; a name is honoured or refused. `abi`: "native" or "linux". +// `root`: it is a root (`--rootfs`). `strong`: its policy requires an +// isolation this machine's own kernel cannot give (a Must). +struct Want { + std::string requested; + std::string abi { "native" }; + bool root { false }; + bool strong { false }; +}; +struct Choice { + std::string carrier; + std::string why; // for `subos status` +}; +struct Refused { + std::string reason; + std::string route; +}; +std::expected choose(std::string_view platform, const Want& want, + const std::function& probe); + +// The carriers a platform has, in preference order (the first is native). +std::vector carriers_of(std::string_view platform); + +// ── a guest's image ────────────────────────────────────────────────── +// +// What a guest carrier imports the first time: the Linux build of this +// release at /xlings/bin/xlings (a static binary: no userland needed to run +// it, and it is the home's package manager there), and the few machine files +// a carrier declares. Built by the xlings core (it knows the index and the +// downloader), declared here (the carrier knows its machine). +struct ImageFile { + std::string path; // inside the image, relative ("etc/wsl.conf") + std::string content; // a regular file's bytes + unsigned mode { 0644 }; + std::string link; // non-empty: a symbolic link to this target + bool directory { false }; +}; +// Returns the image tarball (root-owned entries) for `files` plus the Linux +// xlings at xlings/bin/xlings. +using ImageSource = std::function(const HomeView&, std::span)>; +void set_image_source(ImageSource source); +const ImageSource& image_source(); + +} // namespace xlings::carrier diff --git a/modules/carrier/src/vz.cpp b/modules/carrier/src/vz.cpp new file mode 100644 index 000000000..3628b20fa --- /dev/null +++ b/modules/carrier/src/vz.cpp @@ -0,0 +1,145 @@ +module xlings.carrier.vz; + +import std; +import xlings.carrier; +import xlings.libs.sha256; +import xlings.platform; +import xlings.platform.stream; +import xlings.subos.home_view; +import xlings.subos.ports; +import xlings.subos.tools; + +namespace xlings::carrier::vz { + +namespace { + +constexpr int kStopped = 3; +constexpr int kAbsent = 4; + +// The helper: XLINGS_VZ_HELPER names a stand-in (the tests' seam); otherwise +// the tool table's xlings-vm payload. +std::optional helper(const subos::HomeView& home) { + if (const char* seam = std::getenv("XLINGS_VZ_HELPER"); seam && *seam) return fs::path(seam); + if (auto found = subos::tools::first("xlings-vm", home, subos::Ports{})) return found->bin; + return std::nullopt; +} + +struct Ran { + int rc { 126 }; + std::string out; +}; + +Ran run(const fs::path& h, std::vector args) { + std::vector argv{h.string()}; + argv.insert(argv.end(), args.begin(), args.end()); + Ran r; + r.rc = platform::stream::run(argv, [&](std::string_view, std::string_view bytes) { r.out.append(bytes); }); + return r; +} + +std::string trimmed(std::string s) { + while (!s.empty() && (s.back() == '\n' || s.back() == '\r' || s.back() == ' ')) s.pop_back(); + return s; +} + +Probe probe(const subos::HomeView& home) { + const bool seam = std::getenv("XLINGS_VZ_HELPER") != nullptr; + if (!seam && !platform::is_macos) + return {.supported = false, .reason = "the vz carrier exists on macOS", .route = "--carrier local"}; + const auto h = helper(home); + if (!h) + return {.supported = false, + .reason = "the vz carrier needs xlings-vm, a signed Virtualization.framework helper -- not " + "installed (and not published yet: design part 3 §5.4)", + .route = "xlings install xlings-vm (once it is published)"}; + auto r = run(*h, {"probe"}); + if (r.rc != 0) + return {.supported = false, .reason = "this Mac cannot run the carrier's VM: " + trimmed(r.out), + .route = "macOS 13 or later on Apple silicon or a Mac with Virtualization.framework", + .evidence = trimmed(r.out)}; + return {.supported = true, .reason = "Virtualization.framework", .evidence = trimmed(r.out)}; +} + +std::expected ensure(const subos::HomeView& home) { + const auto h = helper(home); + if (!h) return std::unexpected("the VM helper (xlings-vm) is not installed"); + const auto name = vm_name(home.home); + Endpoint at{.carrier = "vz", .instance = name, + .launcher = {h->string(), "exec", "--name", name, "--"}, .xlings = "/xlings/bin/xlings"}; + auto status = run(*h, {"status", "--name", name}); + if (status.rc == kAbsent) { + if (!image_source()) return std::unexpected("this build cannot make a carrier image"); + auto image = image_source()(home, image_files()); + if (!image) return std::unexpected("cannot make the carrier image: " + image.error()); + const auto dir = home.home / "carriers" / "vz"; + std::error_code ec; + fs::create_directories(dir, ec); + auto created = run(*h, {"create", "--name", name, "--dir", dir.string(), "--image", image->string()}); + if (created.rc != 0) + return std::unexpected(std::format("xlings-vm create {} failed (exit {}): {}", name, created.rc, + trimmed(created.out))); + status.rc = kStopped; + } + if (status.rc == kStopped) { + if (auto started = run(*h, {"start", "--name", name}); started.rc != 0) + return std::unexpected(std::format("xlings-vm start {} failed (exit {}): {}", name, started.rc, + trimmed(started.out))); + } else if (status.rc != 0) { + return std::unexpected(std::format("xlings-vm status {} answered {}: {}", name, status.rc, trimmed(status.out))); + } + return at; +} + +} // namespace + +std::string vm_name(const fs::path& home) { + return "xlings-" + sha256::hex(home.lexically_normal().generic_string()).substr(0, 12); +} + +std::vector image_files() { + return { + {.path = "etc", .directory = true}, + {.path = "etc/passwd", .content = "root:x:0:0:root:/root:/xlings/bin/xlings\n"}, + {.path = "etc/group", .content = "root:x:0:\n"}, + {.path = "etc/hostname", .content = "xlings-carrier\n"}, + {.path = "etc/xlings", .directory = true}, + {.path = "etc/xlings/root.json", .content = "{ \"home\": \"/xlings\" }\n"}, + {.path = "xlings", .directory = true}, + {.path = "xlings/bin", .directory = true}, + {.path = "xlings/.xlings.json", .content = "{}\n"}, + {.path = "root", .mode = 0700, .directory = true}, + {.path = "tmp", .mode = 01777, .directory = true}, + {.path = "proc", .directory = true}, + {.path = "sys", .directory = true}, + {.path = "dev", .directory = true}, + {.path = "run", .directory = true}, + {.path = "grant", .mode = 0755, .directory = true}, + {.path = "usr", .directory = true}, + {.path = "usr/bin", .directory = true}, + {.path = "usr/bin/xlings", .link = "/xlings/bin/xlings"}, + }; +} + +Carrier make() { + Carrier c; + c.name = "vz"; + c.probe = probe; + c.ensure = ensure; + c.stop = [](const Endpoint& at) -> std::expected { + if (at.launcher.empty()) return std::unexpected("not a vz endpoint"); + if (auto r = run(at.launcher.front(), {"stop", "--name", at.instance}); r.rc != 0) + return std::unexpected(std::format("xlings-vm stop {} failed: {}", at.instance, trimmed(r.out))); + return {}; + }; + c.grant = [](const Endpoint& at, const fs::path& host, bool writable, + std::string_view name) -> std::expected { + if (at.launcher.empty()) return std::unexpected("not a vz endpoint"); + auto r = run(at.launcher.front(), {"share", "--name", at.instance, "--host", host.string(), "--tag", + std::string(name), "--mode", writable ? "rw" : "ro"}); + if (r.rc != 0) return std::unexpected(std::format("cannot share {} with the carrier: {}", host.string(), trimmed(r.out))); + return trimmed(r.out); + }; + return c; +} + +} // namespace xlings::carrier::vz diff --git a/modules/carrier/src/vz.cppm b/modules/carrier/src/vz.cppm new file mode 100644 index 000000000..0a91e000e --- /dev/null +++ b/modules/carrier/src/vz.cppm @@ -0,0 +1,34 @@ +export module xlings.carrier.vz; + +import std; +import xlings.carrier; + +// The vz carrier (SubOS design part 3 §5.4): the wsl2 carrier's shape on +// macOS -- one lightweight Linux VM per xlings home, a Luban machine with +// /xlings as its home, reached with the same NDJSON interface. +// +// Virtualization.framework is driven by a helper, `xlings-vm`, a payload in +// the tool table (it must be signed with the virtualization entitlement, which +// xlings itself is not). The carrier speaks to it through one contract: +// +// xlings-vm probe 0: this Mac can run VMs (stdout: evidence) +// xlings-vm status --name N 0 running, 3 stopped, 4 absent +// xlings-vm create --name N --dir D --image T a VM from a root-owned rootfs tarball +// xlings-vm start --name N +// xlings-vm stop --name N +// xlings-vm share --name N --host P --tag G --mode rw|ro +// virtiofs; stdout: the path inside +// xlings-vm exec --name N -- over vsock; stdio attached, exit code passed +export namespace xlings::carrier::vz { + +namespace fs = std::filesystem; + +Carrier make(); + +// "xlings-" and 12 hex digits of the home's path. +std::string vm_name(const fs::path& home); + +// The files of the image besides the xlings binary. +std::vector image_files(); + +} // namespace xlings::carrier::vz diff --git a/modules/carrier/src/wsl2.cpp b/modules/carrier/src/wsl2.cpp new file mode 100644 index 000000000..e08b01148 --- /dev/null +++ b/modules/carrier/src/wsl2.cpp @@ -0,0 +1,209 @@ +module xlings.carrier.wsl2; + +import std; +import xlings.carrier; +import xlings.libs.sha256; +import xlings.platform; +import xlings.platform.stream; +import xlings.subos.home_view; + +namespace xlings::carrier::wsl2 { + +namespace { + +// wsl.exe: Windows' own, under %SystemRoot%. XLINGS_WSL_EXE names another -- +// the seam the tests use to run this carrier's lifecycle against a stand-in. +std::optional wsl_exe() { + if (const char* seam = std::getenv("XLINGS_WSL_EXE"); seam && *seam) return fs::path(seam); + if constexpr (!platform::is_windows) return std::nullopt; + const char* root = std::getenv("SystemRoot"); + const fs::path exe = fs::path(root && *root ? root : "C:\\Windows") / "System32" / "wsl.exe"; + std::error_code ec; + if (!fs::exists(exe, ec)) return std::nullopt; + return exe; +} + +struct Ran { + int rc { 126 }; + std::string out; +}; + +// wsl.exe answers its own commands in UTF-16 unless told otherwise. +Ran run(const fs::path& wsl, std::vector args) { + platform::set_env_variable("WSL_UTF8", "1"); + std::vector argv{wsl.string()}; + argv.insert(argv.end(), args.begin(), args.end()); + Ran r; + r.rc = platform::stream::run(argv, [&](std::string_view, std::string_view bytes) { r.out.append(bytes); }); + return r; +} + +std::string first_lines(const std::string& text, int n) { + std::string out; + std::istringstream in(text); + for (std::string line; n-- > 0 && std::getline(in, line);) { + std::erase(line, '\0'); + std::erase(line, '\r'); + if (!line.empty()) out += (out.empty() ? "" : " | ") + line; + } + return out; +} + +constexpr std::string_view kInstall = "wsl --install --no-distribution (once, as administrator; then restart)"; + +Probe probe(const subos::HomeView&) { + const auto wsl = wsl_exe(); + if (!wsl) { + if constexpr (!platform::is_windows) + return {.supported = false, .reason = "the wsl2 carrier exists on Windows", .route = "--carrier local"}; + return {.supported = false, .reason = "WSL is not installed", .route = std::string(kInstall)}; + } + auto status = run(*wsl, {"--status"}); + if (status.rc != 0) + return {.supported = false, .reason = "WSL did not answer: " + first_lines(status.out, 3), + .route = std::string(kInstall), .evidence = first_lines(status.out, 6)}; + // WSL1 translates system calls: no namespaces, so no sandbox and no root. + std::string flat = status.out; + std::erase(flat, '\0'); + if (flat.find("WSL 2 is not supported") != std::string::npos || + flat.find("requires an update to its kernel component") != std::string::npos) + return {.supported = false, .reason = "only WSL1 is available here; a SubOS needs WSL2's kernel", + .route = "enable the Virtual Machine Platform, then: wsl --update", .evidence = first_lines(flat, 6)}; + return {.supported = true, .reason = "WSL2", .evidence = first_lines(flat, 6)}; +} + +fs::path carrier_dir(const subos::HomeView& home) { return home.home / "carriers" / "wsl2"; } + +std::expected ensure(const subos::HomeView& home) { + const auto wsl = wsl_exe(); + if (!wsl) return std::unexpected("WSL is not installed"); + const auto name = distro_name(home.home); + Endpoint at{.carrier = "wsl2", .instance = name, .launcher = launcher(*wsl, name), .xlings = "/xlings/bin/xlings"}; + auto listed = run(*wsl, {"--list", "--quiet"}); + if (listed.rc == 0 && std::ranges::contains(parse_list(listed.out), name)) return at; + + // First use for this home: its machine, imported once. + if (!image_source()) return std::unexpected("this build cannot make a carrier image"); + auto image = image_source()(home, image_files()); + if (!image) return std::unexpected("cannot make the carrier image: " + image.error()); + const auto disk = carrier_dir(home) / "disk"; + std::error_code ec; + fs::create_directories(disk, ec); + auto imported = run(*wsl, {"--import", name, disk.string(), image->string(), "--version", "2"}); + if (imported.rc != 0) + return std::unexpected(std::format("wsl --import {} failed (exit {}): {}", name, imported.rc, + first_lines(imported.out, 4))); + // It answers as an xlings home before it is handed out. + std::vector check = at.launcher; + check.insert(check.end(), {at.xlings, "--version"}); + if (const int rc = platform::run_argv(check); rc != 0) + return std::unexpected(std::format("the carrier {} was imported but its xlings does not run (exit {})", name, rc)); + return at; +} + +} // namespace + +std::string distro_name(const fs::path& home) { + const auto digest = sha256::hex(home.lexically_normal().generic_string()); + return "xlings-" + digest.substr(0, 12); +} + +std::vector parse_list(std::string_view output) { + std::string text(output); + std::erase(text, '\0'); // UTF-16LE of ASCII names + if (text.starts_with("\xEF\xBB\xBF")) text.erase(0, 3); // UTF-8 BOM + if (text.starts_with("\xFF\xFE")) text.erase(0, 2); // UTF-16 BOM, NULs gone + std::vector names; + std::istringstream in(text); + for (std::string line; std::getline(in, line);) { + std::erase(line, '\r'); + while (!line.empty() && (line.back() == ' ' || line.back() == '\t')) line.pop_back(); + while (!line.empty() && (line.front() == ' ' || line.front() == '\t')) line.erase(0, 1); + if (!line.empty()) names.push_back(line); + } + return names; +} + +std::vector image_files() { + return { + {.path = "etc", .directory = true}, + // No Windows drives, no Windows programs: the host reaches a SubOS + // only through a grant (CARRIER-WSL-INTEROP-OFF). + {.path = "etc/wsl.conf", + .content = "# xlings carrier (SubOS design part 3 §5.3)\n" + "[automount]\nenabled = false\nmountFsTab = false\n\n" + "[interop]\nenabled = false\nappendWindowsPath = false\n\n" + "[user]\ndefault = root\n\n" + "[boot]\nsystemd = false\n"}, + {.path = "etc/passwd", .content = "root:x:0:0:root:/root:/xlings/bin/xlings\n"}, + {.path = "etc/group", .content = "root:x:0:\n"}, + {.path = "etc/hostname", .content = "xlings-carrier\n"}, + {.path = "etc/xlings", .directory = true}, + {.path = "etc/xlings/root.json", .content = "{ \"home\": \"/xlings\" }\n"}, + {.path = "xlings", .directory = true}, + {.path = "xlings/bin", .directory = true}, + {.path = "xlings/.xlings.json", .content = "{}\n"}, + {.path = "root", .mode = 0700, .directory = true}, + {.path = "tmp", .mode = 01777, .directory = true}, + {.path = "proc", .directory = true}, + {.path = "sys", .directory = true}, + {.path = "dev", .directory = true}, + {.path = "run", .directory = true}, + {.path = "grant", .mode = 0755, .directory = true}, + {.path = "sbin", .directory = true}, + // drvfs is mounted by WSL's /init when it runs as mount.drvfs. + {.path = "sbin/mount.drvfs", .link = "/init"}, + {.path = "usr", .directory = true}, + {.path = "usr/bin", .directory = true}, + {.path = "usr/bin/xlings", .link = "/xlings/bin/xlings"}, + }; +} + +std::vector launcher(const fs::path& wsl, std::string_view name) { + return {wsl.string(), "-d", std::string(name), "-u", "root", "--exec"}; +} + +int guest_grant(std::span args) { + if (args.size() != 3 || (args[2] != "rw" && args[2] != "ro")) { + std::println(std::cerr, "usage: xlings __carrier-grant "); + return 2; + } + const fs::path target(args[1]); + if (!target.is_absolute() || !target.lexically_normal().generic_string().starts_with("/grant/")) { + std::println(std::cerr, "a grant is mounted under /grant/, never elsewhere"); + return 2; + } + std::error_code ec; + fs::create_directories(target, ec); + if (ec) { + std::println(std::cerr, "cannot create {}: {}", target.string(), ec.message()); + return 1; + } + return platform::run_argv({"/sbin/mount.drvfs", args[0], target.string(), "-o", args[2] == "rw" ? "rw" : "ro"}); +} + +Carrier make() { + Carrier c; + c.name = "wsl2"; + c.probe = probe; + c.ensure = ensure; + c.stop = [](const Endpoint& at) -> std::expected { + const auto wsl = wsl_exe(); + if (!wsl) return std::unexpected("WSL is not installed"); + if (auto r = run(*wsl, {"--terminate", at.instance}); r.rc != 0) + return std::unexpected(std::format("wsl --terminate {} failed: {}", at.instance, first_lines(r.out, 2))); + return {}; + }; + c.grant = [](const Endpoint& at, const fs::path& host, bool writable, + std::string_view name) -> std::expected { + const auto target = "/grant/" + std::string(name); + std::vector argv = at.launcher; + argv.insert(argv.end(), {at.xlings, "__carrier-grant", host.string(), target, writable ? "rw" : "ro"}); + if (const int rc = platform::run_argv(argv); rc != 0) + return std::unexpected(std::format("cannot grant {} to the carrier (exit {})", host.string(), rc)); + return target; + }; + return c; +} + +} // namespace xlings::carrier::wsl2 diff --git a/modules/carrier/src/wsl2.cppm b/modules/carrier/src/wsl2.cppm new file mode 100644 index 000000000..b1606ee3b --- /dev/null +++ b/modules/carrier/src/wsl2.cppm @@ -0,0 +1,47 @@ +export module xlings.carrier.wsl2; + +import std; +import xlings.carrier; +import xlings.subos.home_view; + +// The wsl2 carrier (SubOS design part 3 §5.3). +// +// One WSL2 distribution per xlings home, named for the home, kept in +// \carriers\wsl2. It is a Luban machine: /xlings is its system home +// (the prefix domain official images use), and /xlings/bin/xlings is the +// Linux build of this release -- static, so it runs before anything else is +// there, and installs the rest from the index. What a SubOS needs inside +// (busybox, glibc, bwrap) arrives the way it does on any Linux host. +// +// The user never enters WSL: `xlings subos ...` on Windows runs `xlings ...` +// there, through `wsl.exe -d -u root --exec`. +// +// Isolation from Windows is the distribution's /etc/wsl.conf: no automount +// of the Windows drives and no interop (running a .exe from inside is a way +// out to the host). A host directory reaches a SubOS only as a grant: one +// drvfs mount of exactly that directory, for the session. +export namespace xlings::carrier::wsl2 { + +namespace fs = std::filesystem; + +Carrier make(); + +// "xlings-" and 12 hex digits of the home's path: one distribution per home. +std::string distro_name(const fs::path& home); + +// `wsl.exe --list --quiet`, in whichever encoding it answered (UTF-16LE +// without WSL_UTF8, with a BOM or without; CRLF). +std::vector parse_list(std::string_view output); + +// The files of the image besides the xlings binary. +std::vector image_files(); + +// The launcher that runs a command in distribution `name`. +std::vector launcher(const fs::path& wsl, std::string_view name); + +// `xlings __carrier-grant `, run INSIDE the +// distribution: create the mount point and mount the Windows directory there +// with drvfs (WSL's /init, as mount.drvfs). +int guest_grant(std::span args); + +} // namespace xlings::carrier::wsl2 diff --git a/modules/confine/mcpp.toml b/modules/confine/mcpp.toml new file mode 100644 index 000000000..bc12f6d18 --- /dev/null +++ b/modules/confine/mcpp.toml @@ -0,0 +1,28 @@ +# Confinement implementations, as their own package (SubOS design part 3 +# §6.2). +# +# The SubOS core (modules/subos) says what a sandbox should be: the policy, +# lowered into an Intent. This package says how a backend makes one: each +# implementation -- linux-bwrap, linux-proot, linux-landlock, home-redirect, +# fake -- states whether it can run here, plans its view from the Intent, +# and builds the argv that starts it. The selector picks one; the matrix +# `subos status` prints is what the implementations report, not a table. +# +# Nothing here reads a policy, a config file or a workspace: an Intent and +# the host's capabilities in, a SandboxSpec (a pure value) out. +[package] +namespace = "xlings" +name = "confine" +version = "0.1.0" + +[targets.confine] +kind = "lib" + +[dependencies] +libs = { path = "../libs" } +platform = { path = "../platform" } +runtime = { path = "../runtime" } +subos = { path = "../subos" } + +[build] +include_dirs = ["../libs/src/json"] diff --git a/modules/confine/src/backends/home_redirect.cpp b/modules/confine/src/backends/home_redirect.cpp new file mode 100644 index 000000000..1472668a9 --- /dev/null +++ b/modules/confine/src/backends/home_redirect.cpp @@ -0,0 +1,65 @@ +module xlings.confine.home_redirect; + +import std; +import xlings.confine.implementation; +import xlings.confine.common; +import xlings.subos.home_view; +import xlings.subos.caps; +import xlings.subos.intent; +import xlings.subos.policy; +import xlings.subos.spec; + +namespace xlings::confine::backends { + +using common::posix; + +Implementation home_redirect() { + Implementation impl; + impl.backend = sp::Backend::HomeRedirect; + impl.name = "home-redirect"; + impl.advisory_fs = "this platform redirects the home directory only (advisory)"; + impl.available = [](const Caps&, const in::Intent&, fs::path&) -> std::optional { + return std::nullopt; + }; + impl.view = [](const in::Intent&, const HomeView&, sp::SandboxSpec&) -> std::optional { + return std::nullopt; + }; + impl.processes = [](const in::Intent&, sp::SandboxSpec&) {}; + impl.finish = [](const in::Intent& i, const HomeView&, const Caps& caps, + const std::map&, sp::SandboxSpec& s) { + const bool windows = caps.platform == "windows"; + auto native = [&](const fs::path& p) { return windows ? p.string() : posix(p); }; + const auto sandbox_home = native(i.instance_dir / "home" / i.id.login); + const auto sandbox_tmp = native(i.instance_dir / "tmp"); + if (windows) { + s.env["USERPROFILE"] = sandbox_home; + s.env["APPDATA"] = sandbox_home + "\\AppData\\Roaming"; + s.env["LOCALAPPDATA"] = sandbox_home + "\\AppData\\Local"; + s.env["TEMP"] = sandbox_tmp; + s.env["TMP"] = sandbox_tmp; + s.env["XDG_CONFIG_HOME"] = sandbox_home + "\\.config"; + s.env["XDG_DATA_HOME"] = sandbox_home + "\\.local\\share"; + s.env["XDG_CACHE_HOME"] = sandbox_home + "\\.cache"; + } else { + s.env["HOME"] = sandbox_home; + s.env["TMPDIR"] = sandbox_tmp; + s.env["XDG_CONFIG_HOME"] = sandbox_home + "/.config"; + s.env["XDG_DATA_HOME"] = sandbox_home + "/.local/share"; + s.env["XDG_CACHE_HOME"] = sandbox_home + "/.cache"; + s.env["XDG_STATE_HOME"] = sandbox_home + "/.local/state"; + } + s.cwd = sandbox_home; + s.argv = i.argv; + common::proxy_env(s); + }; + impl.gates = [](const Caps& c) -> std::vector { + if (c.platform != "macos" && c.platform != "windows") return {}; + const bool mac = c.platform == "macos"; + return {{"FsGate", true, Enforced::Advisory, "home directory redirect only", + mac ? "Seatbelt profile (sandbox_init)" : "AppContainer / restricted token"}, + {"IdentityShim", true, Enforced::Advisory, "environment only", ""}}; + }; + return impl; +} + +} // namespace xlings::confine::backends diff --git a/modules/confine/src/backends/home_redirect.cppm b/modules/confine/src/backends/home_redirect.cppm new file mode 100644 index 000000000..4e4858a13 --- /dev/null +++ b/modules/confine/src/backends/home_redirect.cppm @@ -0,0 +1,11 @@ +export module xlings.confine.home_redirect; + +import std; +import xlings.confine.implementation; + +// The home redirect of macOS and Windows: HOME / USERPROFILE and the XDG +// directories pointed into the instance -- dotfile isolation, advisory, and +// said to be. The boundary these hosts get is a carrier's (part 3 §5). +export namespace xlings::confine::backends { +Implementation home_redirect(); +} diff --git a/modules/confine/src/backends/linux_bwrap.cpp b/modules/confine/src/backends/linux_bwrap.cpp new file mode 100644 index 000000000..ef96fff93 --- /dev/null +++ b/modules/confine/src/backends/linux_bwrap.cpp @@ -0,0 +1,161 @@ +module xlings.confine.linux_bwrap; + +import std; +import xlings.confine.implementation; +import xlings.confine.common; +import xlings.subos.home_view; +import xlings.subos.caps; +import xlings.subos.intent; +import xlings.subos.policy; +import xlings.subos.spec; + +namespace xlings::confine::backends { + +namespace policy = xlings::subos::policy; +using sp::MountKind; +using common::posix; + +namespace { + +std::optional namespaces_view(const in::Intent& i, const HomeView& home, sp::SandboxSpec& s) { + if (!i.root.empty()) { + // The instance's own tree is `/` (design part 2 §6.1): its /usr is + // the projection, its /etc, /root, /var are its machine state. The + // home is where it always is, read-only -- the projection's links and + // every payload's RPATH name it -- with this instance writable at its + // own path and, nested, as the root's `default` (§3.3): the xlings + // inside is the package manager of this root. + s.mounts.push_back({MountKind::Bind, posix(i.root), "/"}); + s.mounts.push_back({MountKind::Dev, "", "/dev"}); + s.mounts.push_back({MountKind::Proc, "", "/proc"}); + if (i.root_mounts.empty() || std::ranges::any_of(i.root_mounts, [](const sp::MountOp& mount) { + return mount.kind != MountKind::RoBind; + })) + return sp::Unmet{"root", "a root needs an owner-checked read-only store closure", + "run owner `xlings install --reconfig` and retry", policy::Need::Must}; + s.mounts.insert(s.mounts.end(), i.root_mounts.begin(), i.root_mounts.end()); + if (i.net.mode != policy::Net::None && i.exists("/etc/resolv.conf")) + s.mounts.push_back({MountKind::RoBind, "/etc/resolv.conf", "/etc/resolv.conf"}); + s.unshare_user = true; + s.uid = 0; + s.gid = 0; + if (i.gpu) { + auto g = common::gpu_mounts(i); + s.mounts.insert(s.mounts.end(), g.begin(), g.end()); + } + return std::nullopt; + } + s.mounts.push_back({MountKind::Dev, "", "/dev"}); + s.mounts.push_back({MountKind::Proc, "", "/proc"}); + common::host_userland(s.mounts, i, i.storage, !i.id.neutral); + common::instance_files(s.mounts, home, i, i.storage, /*read_only_home=*/true); + if (i.gpu) { + auto g = common::gpu_mounts(i); + s.mounts.insert(s.mounts.end(), g.begin(), g.end()); + } + if (i.storage == sp::Storage::Tmpfs) { + s.mounts.push_back({MountKind::Tmpfs, "", i.id.home_inside}); + s.mounts.push_back({MountKind::Tmpfs, "", "/tmp"}); + } + if (i.storage == sp::Storage::Image) s.mounts.push_back({MountKind::Tmpfs, "", "/tmp"}); + return std::nullopt; +} + +void namespaces_processes(const in::Intent& i, sp::SandboxSpec& s) { + s.unshare_pid = true; // host processes invisible (F4) + s.unshare_ipc = true; + s.unshare_uts = true; // hostname changes stay inside + s.die_with_parent = true; // nothing outlives the session's owner + // TIOCSTI from inside would type into the user's shell (F8). A + // non-interactive run gets a new session (no controlling terminal); an + // interactive one keeps its terminal for job control and gets the + // seccomp filter instead. + if (i.interactive) s.block_tiocsti = true; + else s.new_session = true; +} + +} // namespace + +namespace { + +void root_finish(const in::Intent& i, const HomeView& home, const std::map& grant_env, + sp::SandboxSpec& s) { + // The root's own userland and its own root user. /run/xlings last: the + // client hosting the session, whatever the root holds. + if (i.id.neutral) { + s.env["TZ"] = i.id.tz.empty() ? "UTC" : i.id.tz; + s.env["LANG"] = "C.UTF-8"; + } + for (auto& [k, v] : grant_env) s.env[k] = v; + s.env.erase("XLINGS_ACTIVE_SUBOS"); + s.env["XLINGS_ROOT_INSTANCE"] = i.instance; + s.env["HOME"] = "/root"; + s.env["USER"] = "root"; + s.env["LOGNAME"] = "root"; + s.env["SHELL"] = "/bin/sh"; + s.env["XLINGS_HOME"] = posix(home.home); + s.env["PATH"] = "/usr/local/sbin:/usr/local/bin:/usr/bin:/bin:/run/xlings"; + common::explicit_env(i, s, i.instance); + s.cwd = i.cwd.empty() ? fs::path("/root") : fs::path(i.cwd); + if (i.argv.empty()) { + s.argv = {"/bin/sh"}; + if (i.interactive) s.argv.push_back("-i"); + } else { + s.argv = i.argv; + } + common::proxy_env(s); +} + +Implementation namespaces(sp::Backend backend, std::string_view name) { + Implementation impl; + impl.backend = backend; + impl.name = name; + impl.kernel = true; + impl.presents_root = true; + impl.view = namespaces_view; + impl.processes = namespaces_processes; + impl.finish = [](const in::Intent& i, const HomeView& home, const Caps&, + const std::map& grant_env, sp::SandboxSpec& s) { + if (!i.root.empty()) root_finish(i, home, grant_env, s); + else common::view_finish(i, home, grant_env, s); + }; + return impl; +} + +} // namespace + +Implementation linux_bwrap() { + auto impl = namespaces(sp::Backend::Bwrap, "linux-bwrap"); + impl.available = [](const Caps& caps, const in::Intent&, fs::path& chosen) -> std::optional { + if (!caps.bwrap) return sp::Unmet{"backend", "bwrap not installed", "xlings install bwrap", policy::Need::Must}; + if (!caps.bwrap->usable) + return sp::Unmet{"backend", "bwrap probe failed: " + caps.bwrap->probe_output, + "xlings self doctor --isolation", policy::Need::Must}; + chosen = caps.bwrap->bin; + return std::nullopt; + }; + impl.gates = [](const Caps& c) -> std::vector { + if (c.platform != "linux" || !c.bwrap || !c.bwrap->usable) return {}; + return { + {"FsGate", true, Enforced::Kernel, "bwrap mount namespace", ""}, + {"ProcessScope", true, Enforced::Kernel, "pid namespace", ""}, + {"NetGate", true, Enforced::Kernel, + c.pasta ? "net namespace; nat via pasta, proxy via SOCKS5h relay" : "net namespace (host, none, SOCKS5h proxy)", ""}, + {"DeviceGate", true, Enforced::Kernel, "minimal /dev, named grants", ""}, + {"IdentityShim", true, Enforced::Kernel, "uts namespace + passwd template", ""}, + {"ExecTracer", c.seccomp, Enforced::Kernel, "seccomp user notification (observe=full)", ""}, + {"RootfsRuntime", true, Enforced::Kernel, "bwrap root tree with uid 0 mapping", ""}, + }; + }; + return impl; +} + +Implementation fake() { + auto impl = namespaces(sp::Backend::Fake, "fake"); + impl.available = [](const Caps&, const in::Intent&, fs::path&) -> std::optional { + return std::nullopt; + }; + return impl; +} + +} // namespace xlings::confine::backends diff --git a/modules/confine/src/backends/linux_bwrap.cppm b/modules/confine/src/backends/linux_bwrap.cppm new file mode 100644 index 000000000..dbe561267 --- /dev/null +++ b/modules/confine/src/backends/linux_bwrap.cppm @@ -0,0 +1,12 @@ +export module xlings.confine.linux_bwrap; + +import std; +import xlings.confine.implementation; + +// bwrap: mount, pid, ipc and uts namespaces (and net, user when asked) -- +// the kernel's boundary. `fake` plans the same view and starts nothing: the +// tests' way to see a full plan on any host. +export namespace xlings::confine::backends { +Implementation linux_bwrap(); +Implementation fake(); +} diff --git a/modules/confine/src/backends/linux_landlock.cpp b/modules/confine/src/backends/linux_landlock.cpp new file mode 100644 index 000000000..204a1ea77 --- /dev/null +++ b/modules/confine/src/backends/linux_landlock.cpp @@ -0,0 +1,90 @@ +module xlings.confine.linux_landlock; + +import std; +import xlings.confine.implementation; +import xlings.confine.common; +import xlings.subos.home_view; +import xlings.subos.caps; +import xlings.subos.intent; +import xlings.subos.policy; +import xlings.subos.spec; + +namespace xlings::confine::backends { + +namespace policy = xlings::subos::policy; +using common::posix; + +Implementation linux_landlock() { + Implementation impl; + impl.backend = sp::Backend::Landlock; + impl.name = "linux-landlock"; + impl.sockets = Implementation::Sockets::PassVariables; + impl.mounts = Implementation::Mounts::Fence; + impl.available = [](const Caps& caps, const in::Intent& i, fs::path&) -> std::optional { + // Only when asked for: it restricts writes and hides nothing, so it + // never stands in for bwrap on its own. + if (caps.landlock_abi < 1) + return sp::Unmet{"backend", "Landlock is not available on this kernel " + "(5.13 or later, with landlock in the LSM list)", + "xlings self doctor --isolation", policy::Need::Must}; + if (i.storage != sp::Storage::Shared) + return sp::Unmet{"storage", std::string(sp::to_string(i.storage)) + + " storage needs a mount namespace (bwrap)", + "--sandbox bwrap", policy::Need::Must}; + return std::nullopt; + }; + impl.view = [](const in::Intent& i, const HomeView&, sp::SandboxSpec& s) -> std::optional { + // Its own tree (home and tmp included) and devices (a terminal, + // /dev/null, /dev/shm). Not the host's /tmp: a rule can only allow, + // so allowing /tmp would allow every home that lives under it. + // `--mount /tmp` opens it on purpose; --mount adds its sources. + s.landlock_rw = {i.instance_dir, "/dev", "/proc"}; + return std::nullopt; + }; + impl.processes = [](const in::Intent&, sp::SandboxSpec& s) { + s.degraded.push_back({"fs", "Landlock restricts writes; the host's files stay visible", + "--sandbox bwrap", policy::Need::Should}); + // Not a boundary for untrusted code: a session bus or an agent socket + // runs things on the host for whoever connects. + s.degraded.push_back({"sockets", "the host's unix sockets (D-Bus, agents, other sessions) " + "stay reachable -- a write fence, not a boundary for untrusted code", + "--sandbox bwrap", policy::Need::Should}); + for (auto dim : {"pid", "ipc", "terminal"}) + s.degraded.push_back({dim, "Landlock has no namespaces", "--sandbox bwrap", policy::Need::Should}); + }; + impl.finish = [](const in::Intent& i, const HomeView& home, const Caps&, + const std::map& grant_env, sp::SandboxSpec& s) { + // The host's filesystem with the instance's home and binaries first. + const auto sandbox_home = posix(i.instance_dir / "home" / i.id.login); + for (auto& [k, v] : grant_env) s.env[k] = v; + s.env["HOME"] = sandbox_home; + s.env["XLINGS_HOME"] = posix(home.home); + s.env["XDG_CONFIG_HOME"] = sandbox_home + "/.config"; + s.env["XDG_DATA_HOME"] = sandbox_home + "/.local/share"; + s.env["XDG_CACHE_HOME"] = sandbox_home + "/.cache"; + s.env["XDG_STATE_HOME"] = sandbox_home + "/.local/state"; + s.env["TMPDIR"] = posix(i.instance_dir / "tmp"); + s.env["PATH"] = std::format("{0}/subos/{1}/bin:{0}/bin:/usr/local/bin:/usr/bin:/bin", + posix(home.home), i.instance); + s.env["XLINGS_BROKER_SOCKET"] = posix(home.broker_socket(i.instance)); + if (s.clear_env) { + s.env["USER"] = i.id.user; + s.env["LOGNAME"] = i.id.user; + s.env["SHELL"] = i.shell; + } + s.cwd = sandbox_home; + s.argv = i.argv.empty() ? std::vector{i.shell} : i.argv; + if (i.argv.empty() && i.interactive) s.argv.push_back("-i"); + common::proxy_env(s); + }; + impl.gates = [](const Caps& c) -> std::vector { + if (c.platform != "linux" || c.landlock_abi <= 0) return {}; + return {{"FsGate", true, Enforced::Kernel, + std::format("Landlock ABI {}: writes restricted, host files visible (--sandbox landlock)", + c.landlock_abi), + "xlings self doctor --isolation"}}; + }; + return impl; +} + +} // namespace xlings::confine::backends diff --git a/modules/confine/src/backends/linux_landlock.cppm b/modules/confine/src/backends/linux_landlock.cppm new file mode 100644 index 000000000..a16ca768a --- /dev/null +++ b/modules/confine/src/backends/linux_landlock.cppm @@ -0,0 +1,10 @@ +export module xlings.confine.linux_landlock; + +import std; +import xlings.confine.implementation; + +// Landlock: a write fence the program raises on itself -- the host stays +// visible, so only when asked for by name, never in bwrap's place. +export namespace xlings::confine::backends { +Implementation linux_landlock(); +} diff --git a/modules/confine/src/backends/linux_proot.cpp b/modules/confine/src/backends/linux_proot.cpp new file mode 100644 index 000000000..59385dccc --- /dev/null +++ b/modules/confine/src/backends/linux_proot.cpp @@ -0,0 +1,62 @@ +module xlings.confine.linux_proot; + +import std; +import xlings.confine.implementation; +import xlings.confine.common; +import xlings.subos.home_view; +import xlings.subos.caps; +import xlings.subos.intent; +import xlings.subos.policy; +import xlings.subos.spec; + +namespace xlings::confine::backends { + +namespace policy = xlings::subos::policy; +using sp::MountKind; + +Implementation linux_proot() { + Implementation impl; + impl.backend = sp::Backend::Proot; + impl.name = "linux-proot"; + impl.available = [](const Caps& caps, const in::Intent& i, fs::path& chosen) -> std::optional { + if (!caps.proot) return sp::Unmet{"backend", "proot not installed", "xlings install proot", policy::Need::Must}; + if (i.storage != sp::Storage::Shared) { + std::string hint = "xlings install bwrap"; + if (caps.bwrap && !caps.bwrap->usable) hint = "xlings self doctor --isolation"; + return sp::Unmet{"storage", + std::string(sp::to_string(i.storage)) + + " storage requires bwrap (proot does not support mount namespace)", + hint, policy::Need::Must}; + } + chosen = caps.proot->bin; + return std::nullopt; + }; + impl.view = [](const in::Intent& i, const HomeView& home, sp::SandboxSpec& s) -> std::optional { + // proot passes /proc, /sys and /dev through whole; --gpu is a no-op. + s.mounts.push_back({MountKind::Bind, "/proc", "/proc"}); + s.mounts.push_back({MountKind::Bind, "/sys", "/sys"}); + s.mounts.push_back({MountKind::Bind, "/dev", "/dev"}); + common::host_userland(s.mounts, i, sp::Storage::Shared, !i.id.neutral); + // proot binds read-write only: the home stays writable, and says so. + common::instance_files(s.mounts, home, i, sp::Storage::Shared, /*read_only_home=*/false); + s.proot_root = i.instance_dir / "sandbox-root"; + return std::nullopt; + }; + impl.processes = [](const in::Intent&, sp::SandboxSpec& s) { + for (auto dim : {"fs", "pid", "ipc", "terminal"}) + s.degraded.push_back({dim, "proot has no namespaces", "xlings self doctor --isolation", + policy::Need::Should}); + }; + impl.finish = [](const in::Intent& i, const HomeView& home, const Caps&, + const std::map& grant_env, sp::SandboxSpec& s) { + common::view_finish(i, home, grant_env, s); + }; + impl.gates = [](const Caps& c) -> std::vector { + if (c.platform != "linux" || !c.proot) return {}; + return {{"FsGate", true, Enforced::Advisory, "proot (ptrace) is a view, not a security boundary", + "xlings self doctor --isolation"}}; + }; + return impl; +} + +} // namespace xlings::confine::backends diff --git a/modules/confine/src/backends/linux_proot.cppm b/modules/confine/src/backends/linux_proot.cppm new file mode 100644 index 000000000..8e7d7e256 --- /dev/null +++ b/modules/confine/src/backends/linux_proot.cppm @@ -0,0 +1,10 @@ +export module xlings.confine.linux_proot; + +import std; +import xlings.confine.implementation; + +// proot: a ptrace view of the host with binds -- no namespaces, so a view, +// never a boundary; it says so, dimension by dimension. +export namespace xlings::confine::backends { +Implementation linux_proot(); +} diff --git a/modules/confine/src/common.cpp b/modules/confine/src/common.cpp new file mode 100644 index 000000000..022b3f05f --- /dev/null +++ b/modules/confine/src/common.cpp @@ -0,0 +1,134 @@ +module xlings.confine.common; + +import std; +import xlings.subos.home_view; +import xlings.subos.intent; +import xlings.subos.policy; +import xlings.subos.spec; +import xlings.subos.gpu; + +namespace xlings::confine::common { + +namespace policy = xlings::subos::policy; +namespace gpu = xlings::subos::gpu; +using sp::MountKind; + +std::string posix(const fs::path& p) { return p.generic_string(); } + +void host_userland(std::vector& m, const in::Intent& i, sp::Storage, bool host_time) { + auto try_ro = [&](const char* src, const char* dst) { + if (i.exists(src)) m.push_back({MountKind::RoBind, src, dst}); + }; + try_ro("/usr", "/usr"); + try_ro("/bin", "/bin"); + try_ro("/usr/lib", "/lib"); + try_ro("/usr/lib64", "/lib64"); + try_ro("/etc/resolv.conf", "/etc/resolv.conf"); + try_ro("/etc/ld.so.cache", "/etc/ld.so.cache"); + try_ro("/etc/ssl", "/etc/ssl"); + try_ro("/etc/pki", "/etc/pki"); + try_ro("/etc/alternatives", "/etc/alternatives"); + if (host_time) try_ro("/etc/localtime", "/etc/localtime"); +} + +namespace { + +// The xlings home as a sandbox sees it (#640 F1, F6; design §16). +// +// At its own absolute path -- xvm alias targets, RPATH and INTERP are +// absolute host paths baked at install time -- and READ-ONLY: the shims, the +// profile, the payloads and the home config are code the host runs, and a +// sandbox that can write them has escaped. Writable inside it is only this +// instance's own tree. Other instances, the audit (logs/), the sockets +// (run/) and the host facts (state/) are covered by empty tmpfs; config/ +// stays readable, so the xlings inside can read its own policy and cannot +// change it. +void home_view_ro(std::vector& m, const HomeView& home, const in::Intent& i) { + const auto h = posix(home.home); + m.push_back({MountKind::RoBind, h, h}); + m.push_back({MountKind::Tmpfs, "", posix(home.subos_root())}); + m.push_back({MountKind::Bind, posix(i.instance_dir), posix(home.instance(i.instance))}); + for (auto dir : {"logs", "run", "state"}) + m.push_back({MountKind::Tmpfs, "", posix(home.home / dir)}); +} + +} // namespace + +void instance_files(std::vector& m, const HomeView& home, const in::Intent& i, + sp::Storage storage, bool read_only_home) { + const auto dir = i.instance_dir; + if (storage == sp::Storage::Shared) { + m.push_back({MountKind::Bind, posix(dir / "home"), "/home"}); + m.push_back({MountKind::Bind, posix(dir / "tmp"), "/tmp"}); + } else if (storage == sp::Storage::Image) { + m.push_back({MountKind::Bind, posix(i.image_mountpoint), "/home"}); + } + if (read_only_home) home_view_ro(m, home, i); + else m.push_back({MountKind::Bind, posix(home.home), posix(home.home)}); + for (auto f : {"passwd", "group", "hosts", "nsswitch.conf"}) + m.push_back({MountKind::Bind, posix(i.id.etc_dir / f), std::string("/etc/") + f}); +} + +std::vector gpu_mounts(const in::Intent& i) { + std::vector m; + auto args = gpu::passthrough_args([&](const std::string& p) { return i.exists(p); }); + for (std::size_t k = 0; k + 2 < args.size(); k += 3) { + auto kind = args[k] == "--dev-bind" ? MountKind::DevBind : MountKind::RoBind; + m.push_back({kind, args[k + 1], args[k + 2]}); + } + return m; +} + +void proxy_env(sp::SandboxSpec& s) { + if (!s.net_proxy) return; + s.env["ALL_PROXY"] = "socks5h://127.0.0.1:1080"; + s.env["all_proxy"] = s.env["ALL_PROXY"]; + for (const auto* key : {"http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY", "no_proxy", "NO_PROXY"}) + s.env[key] = ""; +} + +void explicit_env(const in::Intent& i, sp::SandboxSpec& s, std::string_view config_name) { + for (auto& [k, v] : i.explicit_env) { + if (i.env.explicit_any || policy::env_name_matches(k, i.env.pass)) s.env[k] = v; + else s.degraded.push_back({"env", "--env " + k + " is not in the policy's env_pass", + std::format("xlings subos config {} --env-pass {}", config_name, k), + policy::Need::Should}); + } +} + +// The environment of a view of the host (bwrap, fake, proot). +void view_finish(const in::Intent& i, const HomeView& home, const std::map& grant_env, + sp::SandboxSpec& s) { + if (i.id.neutral) { + s.env["TZ"] = i.id.tz.empty() ? "UTC" : i.id.tz; + s.env["LANG"] = "C.UTF-8"; + std::erase_if(s.env, [](const auto& kv) { return kv.first.starts_with("LC_"); }); + } + for (auto& [k, v] : grant_env) s.env[k] = v; + s.env["HOME"] = i.id.home_inside; + s.env["XLINGS_HOME"] = posix(home.home); + // /run/xlings last: the client that hosts the session, there whatever the + // home holds -- every instance has an xlings (design §8). The home's own + // entry comes first and stays the authority on what dispatches. + s.env["PATH"] = std::format("{0}/subos/{1}/bin:{0}/bin:/usr/local/bin:/usr/bin:/bin:/run/xlings", + posix(home.home), i.instance); + if (s.backend == sp::Backend::Proot) s.env["PROOT_NO_SECCOMP"] = "1"; + if (s.clear_env) { + s.env["USER"] = i.id.user; + s.env["LOGNAME"] = i.id.user; + s.env["SHELL"] = i.shell; + } + explicit_env(i, s, ""); + s.cwd = i.cwd.empty() ? fs::path(i.id.home_inside) : fs::path(i.cwd); + if (i.argv.empty()) { + s.argv = {i.shell}; + // `sh -i` prints prompts and job-control warnings into a pipe; -i only + // on a terminal, and never under proot (it did not pass it before). + if (i.interactive && s.backend != sp::Backend::Proot) s.argv.push_back("-i"); + } else { + s.argv = i.argv; + } + proxy_env(s); +} + +} // namespace xlings::confine::common diff --git a/modules/confine/src/common.cppm b/modules/confine/src/common.cppm new file mode 100644 index 000000000..78281c266 --- /dev/null +++ b/modules/confine/src/common.cppm @@ -0,0 +1,43 @@ +export module xlings.confine.common; + +import std; +import xlings.subos.home_view; +import xlings.subos.intent; +import xlings.subos.spec; + +// The pieces of a POSIX view more than one implementation shares. +export namespace xlings::confine::common { + +namespace fs = std::filesystem; +namespace sp = xlings::subos::spec; +namespace in = xlings::subos::intent; +using xlings::subos::HomeView; + +// A POSIX sandbox's paths are POSIX paths, whichever host compiled the spec. +std::string posix(const fs::path& p); + +// The host userland every POSIX sandbox needs read-only, and nothing else of +// /etc: the loader cache, DNS, certificates, the zone file. +void host_userland(std::vector& m, const in::Intent& i, sp::Storage storage, bool host_time); + +// The instance's own files at the places a POSIX userland looks for them. +void instance_files(std::vector& m, const HomeView& home, const in::Intent& i, + sp::Storage storage, bool read_only_home); + +// The GPU device nodes and driver files this host has. +std::vector gpu_mounts(const in::Intent& i); + +// The proxy variables of net=proxy: everything through the in-sandbox +// SOCKS5h gateway, the host's own proxies emptied. +void proxy_env(sp::SandboxSpec& s); + +// `--env K=V` the policy admits, the rest degraded with the command that +// admits it. `config_name` is how the hint names the instance. +void explicit_env(const in::Intent& i, sp::SandboxSpec& s, std::string_view config_name); + +// The environment, directory and command of a view of the host (bwrap, +// fake, proot): the instance's home, the home's shims first on PATH. +void view_finish(const in::Intent& i, const HomeView& home, const std::map& grant_env, + sp::SandboxSpec& s); + +} // namespace xlings::confine::common diff --git a/modules/confine/src/confine.cpp b/modules/confine/src/confine.cpp new file mode 100644 index 000000000..d43013b09 --- /dev/null +++ b/modules/confine/src/confine.cpp @@ -0,0 +1,302 @@ +module xlings.confine; + +import std; +import xlings.confine.implementation; +import xlings.confine.linux_bwrap; +import xlings.confine.linux_proot; +import xlings.confine.linux_landlock; +import xlings.confine.home_redirect; +import xlings.confine.provider; +import xlings.confine.common; +import xlings.subos.home_view; +import xlings.subos.caps; +import xlings.subos.intent; +import xlings.subos.network; +import xlings.subos.policy; +import xlings.subos.spec; + +namespace xlings::confine { + +namespace policy = xlings::subos::policy; +namespace network = xlings::subos::network; +using sp::MountKind; +using common::posix; + +std::span implementations() { + static const std::vector all{ + backends::linux_bwrap(), backends::linux_proot(), backends::linux_landlock(), + backends::home_redirect(), backends::fake()}; + return all; +} + +const Implementation* find(sp::Backend backend) { + for (const auto& impl : implementations()) + if (impl.backend == backend) return &impl; + return nullptr; +} + +namespace { + +std::unexpected refuse(sp::Unmet u) { + u.need = policy::Need::Must; + return std::unexpected(sp::Refusal{.missing = {std::move(u)}}); +} + +// Which implementation runs it here. macOS and Windows have one (the home +// redirect); on Linux a named backend is honoured or refused, and otherwise +// bwrap when it works, then proot -- never Landlock on its own: it hides +// nothing, so it is only ever asked for by name. +std::expected, sp::Refusal> +select(const in::Intent& intent, const Caps& caps, std::optional preferred) { + fs::path chosen; + auto take = [&](sp::Backend b) -> std::expected, sp::Refusal> { + const auto* impl = find(b); + if (auto why = impl->available(caps, intent, chosen)) return refuse(*why); + return std::pair{impl, chosen}; + }; + if (caps.platform == "macos" || caps.platform == "windows") return take(sp::Backend::HomeRedirect); + if (preferred == sp::Backend::Fake) return take(sp::Backend::Fake); + if (preferred && *preferred != sp::Backend::HomeRedirect) return take(*preferred); + if (caps.bwrap && caps.bwrap->usable) return take(sp::Backend::Bwrap); + if (caps.proot) return take(sp::Backend::Proot); + return refuse({"backend", "no sandbox backend available", "xlings install bwrap (or: xlings install proot)", + policy::Need::Must}); +} + +} // namespace + +std::expected compile(const policy::Policy& pol, const HomeView& home, + const Caps& caps, const sp::Request& r) { + const auto i = subos::intent::lower(pol, home, r); + auto selected = select(i, caps, r.preferred); + if (!selected) return std::unexpected(selected.error()); + const auto& impl = *selected->first; + sp::SandboxSpec s; + s.backend = impl.backend; + s.backend_bin = selected->second; + + // ── filesystem ─────────────────────────────────────────────────── + const bool rootfs = !i.root.empty(); + if (rootfs && !impl.presents_root) + return refuse({"root", std::string(sp::to_string(s.backend)) + " cannot present a root: a rootfs SubOS needs bwrap", + "xlings self doctor --isolation", policy::Need::Must}); + if (auto refused = impl.view(i, home, s)) return refuse(*refused); + + // ── processes and terminal (S0, design §16) ────────────────────── + impl.processes(i, s); + + // ── network, identity, nested namespaces (design §19) ──────────── + // What the policy asks for and this backend cannot give is collected; + // Must-items refuse below, Should-items degrade with a reason. + std::vector unmet; + const bool kernel = impl.kernel; + const auto backend_name = std::string(sp::to_string(s.backend)); + if (i.net.mode == policy::Net::None) { + if (kernel) s.unshare_net = true; + else unmet.push_back({"net", backend_name + " cannot isolate the network", + "xlings self doctor --isolation", i.need("net")}); + } else if (i.net.mode == policy::Net::Nat || i.net.mode == policy::Net::Proxy) { + if (kernel && i.net.mode == policy::Net::Nat && caps.pasta) { + s.unshare_net = true; + s.net_nat = true; + s.pasta_bin = *caps.pasta; + s.host_loopback = i.net.host_loopback; + s.publish = i.net.publish; + } else if (!kernel) { + unmet.push_back({"net", backend_name + " cannot isolate the network", + "xlings self doctor --isolation", + i.net.mode == policy::Net::Proxy ? policy::Need::Must : i.need("net")}); + } else if (i.net.mode == policy::Net::Nat) { + unmet.push_back({"net", "net=nat needs pasta: " + (caps.pasta_missing.empty() + ? std::string("pasta (passt) is not installed") : caps.pasta_missing), + "install passt (e.g. apt install passt), or --net none for no network", + i.need("net")}); + } else if (caps.platform != "linux") { + unmet.push_back({"net", "net=proxy requires a Linux network namespace", "--net none", + policy::Need::Must}); + } else if (const auto proxy = network::parse_proxy(i.net.proxy); !proxy) { + unmet.push_back({"net", proxy.error(), "set isolation.proxy to socks5h://HOST:PORT", policy::Need::Must}); + } else { + s.unshare_net = true; + s.net_proxy = true; + s.proxy_url = i.net.proxy; + } + } + if (!i.net.publish.empty() && !s.net_nat) + unmet.push_back({"publish", "--publish needs net=nat (a private network to publish from)", + "--net nat", i.need("publish")}); + if (i.id.neutral) { + if (kernel) s.hostname = i.instance; + else unmet.push_back({"identity", "the host name cannot be changed without namespaces", "", + i.need("identity")}); + } + if (i.disable_userns) { + if (kernel) { + s.unshare_user = true; + s.disable_userns = true; + } else { + unmet.push_back({"userns", "nested user namespaces cannot be forbidden here", "", i.need("userns")}); + } + } + if (!kernel) { + for (auto& d : s.degraded) d.need = i.need(d.dimension); + for (auto& d : s.degraded) unmet.push_back(d); + s.degraded.clear(); + if (impl.advisory_fs) + unmet.push_back({"fs", *impl.advisory_fs, "not implemented on this platform yet", i.need("fs")}); + } + + // ── mounts (design §11) and named grants (§21.2) ───────────────── + for (const auto& m : i.mounts) { + if (m.refused) { + unmet.push_back({"mount", *m.refused, "", policy::Need::Must}); + continue; + } + if (impl.mounts == Implementation::Mounts::Fence) { + // No view to map into: a path is reachable where it is, and only + // a read-write one is added to what may be written. + if (posix(m.inside) != posix(m.source)) { + unmet.push_back({"mount", m.source + ": Landlock cannot map a path elsewhere (" + m.inside + ")", + "--mount " + m.source + " (the same path inside), or --sandbox bwrap", + policy::Need::Must}); + continue; + } + if (m.writable) s.landlock_rw.push_back(m.source); + continue; + } + if (!m.writable && !kernel) + unmet.push_back({"mount", m.source + ": read-only needs bwrap; mapped read-write", + "xlings self doctor --isolation", i.need("fs")}); + s.mounts.push_back({m.writable ? MountKind::Bind : MountKind::RoBind, posix(m.source), posix(m.inside)}); + } + // Each grant opens exactly one thing: a socket file bound in, its + // variable pointed at it -- never the host's whole runtime directory. + std::map grant_env; + { + const auto& grants = i.grants; + auto host_env = [&](std::string_view k) -> std::string { + auto it = i.host_env.find(std::string(k)); + return it == i.host_env.end() ? std::string{} : it->second; + }; + const std::string runtime = "/tmp/.xlings-runtime"; + bool used_runtime = false; + auto bind_socket = [&](const std::string& src, const std::string& dst) { + s.mounts.push_back({MountKind::Bind, src, dst}); + }; + if (grants.contains("display")) { + bool any = false; + if (auto d = host_env("DISPLAY"); !d.empty() && i.exists("/tmp/.X11-unix")) { + bind_socket("/tmp/.X11-unix", "/tmp/.X11-unix"); + grant_env["DISPLAY"] = d; + if (auto xa = host_env("XAUTHORITY"); !xa.empty() && i.exists(xa)) { + s.mounts.push_back({MountKind::RoBind, xa, "/tmp/.xlings-xauthority"}); + grant_env["XAUTHORITY"] = "/tmp/.xlings-xauthority"; + } + any = true; + } + if (auto w = host_env("WAYLAND_DISPLAY"), xdg = host_env("XDG_RUNTIME_DIR"); + !w.empty() && !xdg.empty() && i.exists(xdg + "/" + w)) { + bind_socket(xdg + "/" + w, runtime + "/" + w); + grant_env["WAYLAND_DISPLAY"] = w; + used_runtime = true; + any = true; + } + if (!any) unmet.push_back({"display", "no X11 or Wayland display on this host", "", policy::Need::Should}); + } + if (grants.contains("audio")) { + bool any = false; + const auto xdg = host_env("XDG_RUNTIME_DIR"); + if (!xdg.empty() && i.exists(xdg + "/pulse/native")) { + bind_socket(xdg + "/pulse/native", runtime + "/pulse/native"); + grant_env["PULSE_SERVER"] = "unix:" + runtime + "/pulse/native"; + used_runtime = any = true; + } + if (!xdg.empty() && i.exists(xdg + "/pipewire-0")) { + bind_socket(xdg + "/pipewire-0", runtime + "/pipewire-0"); + used_runtime = any = true; + } + if (!any) unmet.push_back({"audio", "no PulseAudio or PipeWire socket on this host", "", policy::Need::Should}); + } + if (grants.contains("camera")) { + bool any = false; + for (int k = 0; k < 10; ++k) { + auto dev = std::format("/dev/video{}", k); + if (i.exists(dev)) { s.mounts.push_back({MountKind::DevBind, dev, dev}); any = true; } + } + if (!any) unmet.push_back({"camera", "no /dev/video* on this host", "", policy::Need::Should}); + } + if (grants.contains("ssh-agent")) { + if (auto sock = host_env("SSH_AUTH_SOCK"); !sock.empty() && i.exists(sock)) { + bind_socket(sock, "/tmp/.xlings-ssh-agent"); + grant_env["SSH_AUTH_SOCK"] = "/tmp/.xlings-ssh-agent"; + } else { + unmet.push_back({"ssh-agent", "SSH_AUTH_SOCK is not set to a socket on this host", "", + policy::Need::Should}); + } + } + if (grants.contains("dbus")) { + auto addr = host_env("DBUS_SESSION_BUS_ADDRESS"); + auto path = addr.starts_with("unix:path=") ? addr.substr(10, addr.find(',') - 10) : std::string{}; + if (!path.empty() && i.exists(path)) { + bind_socket(path, "/tmp/.xlings-dbus"); + grant_env["DBUS_SESSION_BUS_ADDRESS"] = "unix:path=/tmp/.xlings-dbus"; + } else { + unmet.push_back({"dbus", addr.empty() ? "no session bus on this host" + : "the session bus is not a socket file (abstract address)", + "", policy::Need::Should}); + } + } + if (used_runtime) grant_env["XDG_RUNTIME_DIR"] = runtime; + if (impl.sockets == Implementation::Sockets::PassVariables) { + // Nothing to bind: the host's sockets are where they are. A grant + // passes the variables that name them, and only those. + s.mounts.clear(); + grant_env.clear(); + auto pass = [&](std::initializer_list names) { + for (auto n : names) if (auto v = host_env(n); !v.empty()) grant_env[n] = v; + }; + if (grants.contains("display")) pass({"DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR"}); + if (grants.contains("audio")) pass({"PULSE_SERVER", "XDG_RUNTIME_DIR"}); + if (grants.contains("ssh-agent")) pass({"SSH_AUTH_SOCK"}); + if (grants.contains("dbus")) pass({"DBUS_SESSION_BUS_ADDRESS"}); + } + } + + sp::Refusal refusal; + for (auto& u : unmet) { + if (u.need == policy::Need::Must) refusal.missing.push_back(u); + else s.degraded.push_back(u); + } + if (!refusal.missing.empty()) return std::unexpected(std::move(refusal)); + + // ── environment and command ────────────────────────────────────── + s.clear_env = i.env.clear; + if (s.clear_env) { + for (auto& [k, v] : i.host_env) + if (policy::env_name_matches(k, i.env.pass)) s.env[k] = v; + } + s.env["XLINGS_ACTIVE_SUBOS"] = i.instance; + s.env["XLINGS_SUBOS_MODE"] = "sandbox"; + const bool windows = caps.platform == "windows"; + s.env["XLINGS_SUBOS_LIB"] = windows ? (i.instance_dir / "lib").string() : posix(i.instance_dir / "lib"); + impl.finish(i, home, caps, grant_env, s); + return s; +} + +std::vector launch_argv(const sp::SandboxSpec& s, std::optional seccomp_fd, + const std::string& self) { + switch (s.backend) { + case sp::Backend::Bwrap: return provider::bwrap_argv(s, seccomp_fd); + case sp::Backend::Proot: return provider::proot_argv(s); + case sp::Backend::Landlock: + case sp::Backend::HomeRedirect: { + if (s.argv.empty()) return {}; + auto argv = s.argv; + argv[0] = self; + return argv; + } + default: return {}; + } +} + +} // namespace xlings::confine diff --git a/modules/confine/src/confine.cppm b/modules/confine/src/confine.cppm new file mode 100644 index 000000000..b33712a06 --- /dev/null +++ b/modules/confine/src/confine.cppm @@ -0,0 +1,39 @@ +export module xlings.confine; + +import std; +export import xlings.confine.implementation; +import xlings.subos.home_view; +import xlings.subos.caps; +import xlings.subos.policy; +import xlings.subos.spec; + +// Policy + host capabilities -> a sandbox (SubOS design part 3 §6.1-6.2). +// +// intent::lower the policy and the call, as backend-free decisions +// select which implementation runs it here (or why none can) +// compile the implementation's plan, with the policy's +// semantics kept here: what is Must refuses, what is +// Should degrades and says so +// launch_argv the command that starts it (empty for one that starts +// in-process) +// +// The spec is pure data, so isolation is testable without a sandbox; the +// golden (INTENT-EQ) pins it. +export namespace xlings::confine { + +// Every implementation, in the order the selector prefers them. +std::span implementations(); +const Implementation* find(sp::Backend backend); + +std::expected compile(const subos::policy::Policy& policy, + const HomeView& home, const Caps& caps, + const sp::Request& request); + +// The command that starts `s` (`seccomp_fd`: a terminal-injection filter the +// caller has open). Landlock and the home redirect have no view to start a +// program in: their argv is `s.argv` run as `self` (this binary) -- Landlock's +// fences itself before it starts anything. Empty for fake. +std::vector launch_argv(const sp::SandboxSpec& s, std::optional seccomp_fd = std::nullopt, + const std::string& self = {}); + +} // namespace xlings::confine diff --git a/modules/confine/src/gates.cpp b/modules/confine/src/gates.cpp new file mode 100644 index 000000000..653382171 --- /dev/null +++ b/modules/confine/src/gates.cpp @@ -0,0 +1,75 @@ +module xlings.confine.gates; + +import std; +import xlings.confine; +import xlings.confine.implementation; +import xlings.subos.caps; + +namespace xlings::confine::gates { + +std::string_view to_string(Enforced e) { + switch (e) { + case Enforced::Kernel: return "kernel"; + case Enforced::Advisory: return "advisory"; + default: return "none"; + } +} + +namespace { + +constexpr std::array kGates{ + "FsGate", "ProcessScope", "NetGate", "DeviceGate", "IdentityShim", "ExecTracer", "SessionHost", "RootfsRuntime"}; + +int strength(const GateClaim& c) { + if (!c.supported) return 0; + return c.enforced == Enforced::Kernel ? 3 : c.enforced == Enforced::Advisory ? 2 : 1; +} + +// What is not an implementation's: the session host (supervisor, fork, +// a socket that carries descriptors) -- Linux has one today. +std::vector session_claims(const subos::caps::Caps& c) { + if (c.platform != "linux") return {}; + return {{"SessionHost", true, Enforced::Kernel, "supervisor + session-init, fork + unix socket + SCM_RIGHTS", ""}}; +} + +// What the matrix says when nothing here claims an interface: why, and the +// route that would bring it. +GateClaim missing(std::string_view gate, const subos::caps::Caps& c) { + const std::string g(gate); + if (c.platform == "linux") { + if (gate == "FsGate") return {g, false, Enforced::None, "no usable sandbox backend", "xlings self doctor --isolation"}; + if (gate == "IdentityShim") return {g, false, Enforced::Advisory, "environment only", ""}; + if (gate == "RootfsRuntime") + return {g, false, Enforced::None, "needs usable bwrap", "xlings self doctor --isolation"}; + return {g, false, Enforced::None, "needs bwrap", ""}; + } + const bool mac = c.platform == "macos"; + std::string route; + if (gate == "ProcessScope") route = mac ? "process group" : "Job Object"; + else if (gate == "NetGate") route = mac ? "Seatbelt network deny" : "AppContainer without network capability"; + else if (gate == "DeviceGate") route = mac ? "Seatbelt device operations" : "AppContainer capabilities"; + else if (gate == "ExecTracer") route = mac ? "Endpoint Security" : "Job Object new-process notifications"; + else if (gate == "SessionHost") route = mac ? "fork + unix socket + SCM_RIGHTS" : "CreateProcess + Job Object + DuplicateHandle"; + else if (gate == "RootfsRuntime") route = mac ? "a Lima-style VM" : "WSL2 --import"; + return {g, false, Enforced::None, "not implemented on this platform yet", route}; +} + +} // namespace + +std::vector probe(const subos::caps::Caps& c) { + std::vector claims; + for (const auto& impl : implementations()) + if (impl.gates) for (auto& claim : impl.gates(c)) claims.push_back(std::move(claim)); + for (auto& claim : session_claims(c)) claims.push_back(std::move(claim)); + std::vector out; + for (const auto gate : kGates) { + const GateClaim* best = nullptr; + for (const auto& claim : claims) + if (claim.gate == gate && (!best || strength(claim) > strength(*best))) best = &claim; + const auto chosen = best ? *best : missing(gate, c); + out.push_back({chosen.gate, chosen.supported, chosen.enforced, chosen.reason, chosen.route}); + } + return out; +} + +} // namespace xlings::confine::gates diff --git a/modules/confine/src/gates.cppm b/modules/confine/src/gates.cppm new file mode 100644 index 000000000..3a3028085 --- /dev/null +++ b/modules/confine/src/gates.cppm @@ -0,0 +1,30 @@ +export module xlings.confine.gates; + +import std; +import xlings.confine.implementation; +import xlings.subos.caps; + +// The platform matrix (design part 1 §17): for each interface, whether this +// host supports it, whether the kernel enforces it or it is advisory, and -- +// when it is not there -- why and which route would bring it. It is what the +// implementations claim on this host (xlings.confine), the strongest claim +// per interface; `subos status` prints THIS, so the matrix in the +// documentation is a measurement, not a table someone keeps in sync. +export namespace xlings::confine::gates { + +using Enforced = xlings::confine::Enforced; + +struct Status { + std::string gate; // FsGate, ProcessScope, NetGate, DeviceGate, + // IdentityShim, ExecTracer, SessionHost, RootfsRuntime + bool supported { false }; + Enforced enforced { Enforced::None }; + std::string reason; // why not (or what makes it advisory) + std::string route; // the way it would be supported, when it is not +}; + +std::string_view to_string(Enforced e); + +std::vector probe(const xlings::subos::caps::Caps& caps); + +} // namespace xlings::confine::gates diff --git a/modules/confine/src/implementation.cppm b/modules/confine/src/implementation.cppm new file mode 100644 index 000000000..ec2bb045b --- /dev/null +++ b/modules/confine/src/implementation.cppm @@ -0,0 +1,69 @@ +export module xlings.confine.implementation; + +import std; +import xlings.subos.home_view; +import xlings.subos.caps; +import xlings.subos.intent; +import xlings.subos.spec; + +// One way to make a sandbox (SubOS design part 3 §6.2). +// +// An implementation answers four questions about itself and nothing about +// the policy: can it run on this host (and why not), what view does it give +// an Intent (mounts, a write fence, a ptrace root), what does it do about +// processes, and what environment and command does the sandboxed program +// start with. The compiler (xlings.confine) asks them in a fixed order and +// keeps the policy's semantics -- what is Must, what degrades -- for itself. +export namespace xlings::confine { + +namespace fs = std::filesystem; +namespace sp = xlings::subos::spec; +namespace in = xlings::subos::intent; +using xlings::subos::HomeView; +using xlings::subos::caps::Caps; + +// What an implementation provides for one interface of the platform matrix +// (FsGate, ProcessScope, ...) on this host (design part 1 §17). +enum class Enforced { Kernel, Advisory, None }; +struct GateClaim { + std::string gate; + bool supported { true }; + Enforced enforced { Enforced::Kernel }; + std::string reason; + std::string route; +}; + +struct Implementation { + sp::Backend backend { sp::Backend::Fake }; + std::string_view name; + // The boundary is the kernel's (namespaces): network, identity, nested + // user namespaces and read-only mounts can be enforced. + bool kernel { false }; + // A rootfs instance can be entered (its tree as `/`). + bool presents_root { false }; + // How a granted socket reaches the sandbox: bound into its view, or -- + // no view to bind into -- passed by the variables that name it. + enum class Sockets { Bind, PassVariables } sockets { Sockets::Bind }; + // How a `--mount` is honoured: a bind into the view, or added to the + // paths a write fence allows (no mapping elsewhere). + enum class Mounts { Bind, Fence } mounts { Mounts::Bind }; + + // Can it run here for this Intent? A refusal names the dimension, why, + // and the least-privilege fix. `chosen` is set to the binary. + std::function(const Caps&, const in::Intent&, fs::path& chosen)> available; + // The filesystem view, appended to `s.mounts` / `s.landlock_rw` / + // `s.proot_root`; a requirement of the view it cannot meet refuses. + std::function(const in::Intent&, const HomeView&, sp::SandboxSpec&)> view; + // Process, IPC and terminal isolation; what it cannot do goes to `s.degraded`. + std::function processes; + // The program's environment, directory and command, given the base + // environment and the grants' variables. Returns when it fully decided. + std::function& grant_env, sp::SandboxSpec&)> finish; + // An advisory boundary says so as a `fs` requirement it cannot meet. + std::optional advisory_fs; + // Its rows of the platform matrix on this host (none when it cannot run). + std::function(const Caps&)> gates; +}; + +} // namespace xlings::confine diff --git a/modules/confine/src/provider.cpp b/modules/confine/src/provider.cpp new file mode 100644 index 000000000..afb7e4cb6 --- /dev/null +++ b/modules/confine/src/provider.cpp @@ -0,0 +1,87 @@ +module xlings.confine.provider; + +import std; +import xlings.subos.spec; +import xlings.subos.session; + +namespace xlings::confine::provider { + +namespace spec = xlings::subos::spec; +namespace session = xlings::subos::session; + +using spec::MountKind; + +std::vector bwrap_argv(const spec::SandboxSpec& s, std::optional seccomp_fd) { + std::vector a{ s.backend_bin.generic_string() }; + if (s.unshare_user) a.push_back("--unshare-user"); + if (s.uid) a.insert(a.end(), {"--uid", std::to_string(*s.uid)}); + if (s.gid) a.insert(a.end(), {"--gid", std::to_string(*s.gid)}); + if (s.disable_userns) a.push_back("--disable-userns"); + if (s.unshare_pid) a.push_back("--unshare-pid"); + if (s.unshare_ipc) a.push_back("--unshare-ipc"); + if (s.unshare_uts) a.push_back("--unshare-uts"); + // nat: the network namespace already exists, pasta attached to it before + // bwrap starts (session::host); bwrap runs inside it, it does not make one. + if (s.unshare_net && !s.net_nat && !s.net_proxy) a.push_back("--unshare-net"); + if (!s.hostname.empty()) a.insert(a.end(), {"--hostname", s.hostname}); + if (s.die_with_parent) a.push_back("--die-with-parent"); + if (s.new_session) a.push_back("--new-session"); + for (const auto& m : s.mounts) { + switch (m.kind) { + case MountKind::Dev: a.insert(a.end(), {"--dev", m.dst}); break; + case MountKind::Proc: a.insert(a.end(), {"--proc", m.dst}); break; + case MountKind::Tmpfs: a.insert(a.end(), {"--tmpfs", m.dst}); break; + case MountKind::Dir: a.insert(a.end(), {"--dir", m.dst}); break; + case MountKind::RoBind: a.insert(a.end(), {"--ro-bind", m.src, m.dst}); break; + case MountKind::Bind: a.insert(a.end(), {"--bind", m.src, m.dst}); break; + case MountKind::DevBind: a.insert(a.end(), {"--dev-bind", m.src, m.dst}); break; + } + } + if (seccomp_fd) a.insert(a.end(), {"--seccomp", std::to_string(*seccomp_fd)}); + a.insert(a.end(), {"--chdir", s.cwd.generic_string(), "--"}); + a.insert(a.end(), s.argv.begin(), s.argv.end()); + return a; +} + +std::vector proot_argv(const spec::SandboxSpec& s) { + std::vector a{ s.backend_bin.generic_string(), "-r", s.proot_root.generic_string() }; + for (const auto& m : s.mounts) { + if (m.kind == MountKind::RoBind || m.kind == MountKind::Bind || m.kind == MountKind::DevBind) + a.push_back(std::format("--bind={}:{}", m.src, m.dst)); + } + a.push_back(std::format("--cwd={}", s.cwd.generic_string())); + a.insert(a.end(), s.argv.begin(), s.argv.end()); + return a; +} + +std::map process_env(const spec::SandboxSpec& s, + const std::map& inherited) { + std::map env; + if (!s.clear_env) env = inherited; + for (const auto& [k, v] : s.env) env[k] = v; + if (s.backend == spec::Backend::Landlock) { + std::string rw; + for (const auto& p : s.landlock_rw) rw += p.generic_string() + "\n"; + env[std::string(session::kLandlockRwEnv)] = rw; + } + return env; +} + +} // namespace xlings::confine::provider + +namespace xlings::confine::provider { + + + +std::vector pasta_args(const spec::SandboxSpec& s) { + std::vector a{ s.pasta_bin.string(), "--config-net", "--quiet" }; + // Nothing in, unless published; nothing from the sandbox to the host's + // own loopback, unless granted (design §19, §21.2). + if (s.publish.empty()) a.insert(a.end(), {"-t", "none"}); + for (const auto& p : s.publish) a.insert(a.end(), {"-t", p}); + a.insert(a.end(), {"-u", "none"}); + if (!s.host_loopback) a.insert(a.end(), {"-T", "none", "-U", "none", "--no-map-gw"}); + return a; +} + +} // namespace xlings::confine::provider diff --git a/modules/confine/src/provider.cppm b/modules/confine/src/provider.cppm new file mode 100644 index 000000000..3caedbcc1 --- /dev/null +++ b/modules/confine/src/provider.cppm @@ -0,0 +1,34 @@ +export module xlings.confine.provider; + +import std; +import xlings.subos.spec; + +// Providers translate a SandboxSpec and decide nothing (design §16-§17). +// +// Each one is a pure function of the spec, so the argv a sandbox runs with is +// a golden-testable value. A capability the spec asks for that a backend +// cannot express is the COMPILER's problem (it reports it as unmet); a +// provider that silently dropped one would be a second, invisible policy. +namespace xlings::confine::provider { namespace spec = xlings::subos::spec; } + +export namespace xlings::confine::provider { + +// `bwrap ... -- `. `seccomp_fd` is a filter the caller has open +// (--seccomp ), when the spec blocks terminal injection. +std::vector bwrap_argv(const spec::SandboxSpec& s, + std::optional seccomp_fd = std::nullopt); + +// `proot -r --bind=... --cwd=... `. proot has no read-only bind +// and no namespaces: it is a view, not a boundary. +std::vector proot_argv(const spec::SandboxSpec& s); + +// The environment the backend process is started with: exactly spec.env +// when the spec clears it, otherwise `inherited` with spec.env on top. +std::map process_env(const spec::SandboxSpec& s, + const std::map& inherited); + +// `pasta --config-net ... ` without its target PID (session::host appends +// it): the nat options the spec asks for. +std::vector pasta_args(const spec::SandboxSpec& s); + +} // namespace xlings::confine::provider diff --git a/modules/i18n/mcpp.toml b/modules/i18n/mcpp.toml deleted file mode 100644 index 1c568167e..000000000 --- a/modules/i18n/mcpp.toml +++ /dev/null @@ -1,34 +0,0 @@ -# Message catalogues, as their own package. -# -# WHY BUILT IN, WHEN THEMES ARE NOT -# -# The test is whether a user ever AUTHORS one. `mono.json` and -# `high-contrast.json` exist to be copied and edited, so they are files -# (config/themes/). Nobody edits a translation table, so embedding it costs -# nothing and removes an entire delivery chain -- packaged, copied, read, -# parsed -- along with every way that chain can fail. A binary with no -# accessible files still speaks. -# -# ONE FILE PER LANGUAGE -# -# `src/i18n/en.cppm` is the base and must be complete; every other language -# states only what it changes and falls back to en per message. Same shape as -# the themes it deliberately differs from in storage: a full built-in default -# plus difference-only overlays. -# -# Adding a language is ADDING A FILE. The bilingual struct-array this replaces -# would have had every row edited for each new language -- unreviewable by the -# third one. -[package] -namespace = "xlings" -name = "i18n" -version = "0.1.0" - -[targets.i18n] -kind = "lib" - -# Members may depend on members (mcpp docs, workspace §2.3); only depending on -# the ROOT package is forbidden. So `language()` keeps resolving "auto" through -# platform's system probe instead of making every caller inject it. -[dependencies] -platform = { path = "../platform" } diff --git a/modules/json/mcpp.toml b/modules/json/mcpp.toml deleted file mode 100644 index f8b511909..000000000 --- a/modules/json/mcpp.toml +++ /dev/null @@ -1,16 +0,0 @@ -# nlohmann/json, wrapped as a module. -# -# `src/json/json.hpp` is the vendored upstream header (with its LICENSE); -# `src/json.cppm` is the thin module that exports it so the rest of the -# project can `import` rather than `#include`. A leaf -- it imports nothing, -# not even `std`. -[package] -namespace = "xlings" -name = "json" -version = "0.1.0" - -[targets.json] -kind = "lib" - -[build] -include_dirs = ["src/json"] diff --git a/modules/libs/mcpp.toml b/modules/libs/mcpp.toml new file mode 100644 index 000000000..86d3db96d --- /dev/null +++ b/modules/libs/mcpp.toml @@ -0,0 +1,27 @@ +# Self-contained libraries: what any program could use, nothing of xlings. +# +# json nlohmann/json wrapped as a module (`src/json/json.hpp` is the +# vendored header, with its LICENSE). Imports nothing, not even +# `std`. +# sha256 SHA-256 in process: hashes are checked while a download is +# verified, and shelling out to `sha256sum` would make that +# depend on what the machine has installed. +# tinyhttps HTTPS fetch, wrapping mcpplibs.tinyhttps. It reports the proxy +# it used through its return value: a library that logs has an +# opinion about where its caller's output goes. +# +# One package, three module units: each is a leaf, none is big enough to be a +# package of its own, and they share one rule -- nothing here imports xlings. +[package] +namespace = "xlings" +name = "libs" +version = "0.1.0" + +[targets.libs] +kind = "lib" + +[dependencies.mcpplibs] +tinyhttps = "0.2.9" + +[build] +include_dirs = ["src/json"] diff --git a/modules/json/src/json.cppm b/modules/libs/src/json.cppm similarity index 100% rename from modules/json/src/json.cppm rename to modules/libs/src/json.cppm diff --git a/modules/json/src/json/LICENSE b/modules/libs/src/json/LICENSE similarity index 100% rename from modules/json/src/json/LICENSE rename to modules/libs/src/json/LICENSE diff --git a/modules/json/src/json/json.hpp b/modules/libs/src/json/json.hpp similarity index 100% rename from modules/json/src/json/json.hpp rename to modules/libs/src/json/json.hpp diff --git a/modules/sha256/src/sha256.cpp b/modules/libs/src/sha256.cpp similarity index 100% rename from modules/sha256/src/sha256.cpp rename to modules/libs/src/sha256.cpp diff --git a/modules/sha256/src/sha256.cppm b/modules/libs/src/sha256.cppm similarity index 100% rename from modules/sha256/src/sha256.cppm rename to modules/libs/src/sha256.cppm diff --git a/modules/tinyhttps/src/tinyhttps.cpp b/modules/libs/src/tinyhttps.cpp similarity index 99% rename from modules/tinyhttps/src/tinyhttps.cpp rename to modules/libs/src/tinyhttps.cpp index d2249c53c..d19e10044 100644 --- a/modules/tinyhttps/src/tinyhttps.cpp +++ b/modules/libs/src/tinyhttps.cpp @@ -352,6 +352,8 @@ DownloadFileResult download_file(const DownloadOptions& opts) { anyLive = true; auto r = opts.transferOverride ? opts.transferOverride(url, opts.destFile) + : opts.loopbackHttp && url.starts_with("http://127.0.0.1:") + ? opts.loopbackHttp(url, opts.destFile) : detail_::download_once(url, opts.destFile, opts.connectTimeoutSec, opts.maxTimeSec, lowSpeedBytes, lowSpeedSecs, diff --git a/modules/tinyhttps/src/tinyhttps.cppm b/modules/libs/src/tinyhttps.cppm similarity index 97% rename from modules/tinyhttps/src/tinyhttps.cppm rename to modules/libs/src/tinyhttps.cppm index e15b5803b..9dd50cd5c 100644 --- a/modules/tinyhttps/src/tinyhttps.cppm +++ b/modules/libs/src/tinyhttps.cppm @@ -71,6 +71,9 @@ struct DownloadOptions { // change the verdict). Used for sha256 integrity: a mirror may win // the latency race yet serve corrupted bytes. std::function onVerify; + // An optional real loopback HTTP transport supplied by the platform adapter. + // The candidate loop still verifies length, digest and failure attribution. + std::function loopbackHttp; // TEST SEAM: when set, replaces the network transfer for one URL // attempt (must write destFile on success). Lets unit tests exercise // the candidate loop / verify fallback without sockets. diff --git a/modules/platform/mcpp.toml b/modules/platform/mcpp.toml index 5d7fa99bf..075aaa5e3 100644 --- a/modules/platform/mcpp.toml +++ b/modules/platform/mcpp.toml @@ -25,8 +25,8 @@ # -DLIBARCHIVE_STATIC not needed -- `archive` appears nowhere in platform # include_dirs not needed -- every #include is a system header # -# So this file declares no [build] section on purpose. Re-run that check before -# adding a source file here. +# Re-run that macro check before adding a source file here. The Windows +# loopback TCP boundary now declares its Winsock link dependency below. [package] namespace = "xlings" name = "platform" @@ -36,4 +36,9 @@ version = "0.1.0" kind = "lib" [dependencies] -cancellation = { path = "../cancellation" } +runtime = { path = "../runtime" } # cancellation + +# Loopback TCP fixtures use Winsock on Windows; run_elevated / is_elevated +# use shell32 and advapi32 (also named by os/windows_syslibs.cpp for MSVC). +[target.windows.build] +ldflags = ["-lws2_32", "-lshell32", "-ladvapi32"] diff --git a/modules/platform/src/fs/asset_paths.cpp b/modules/platform/src/fs/asset_paths.cpp new file mode 100644 index 000000000..089302d39 --- /dev/null +++ b/modules/platform/src/fs/asset_paths.cpp @@ -0,0 +1,31 @@ +module; +#if defined(__linux__) +#include +#include +#include +#include +#elif defined(__APPLE__) +#include +#include +#endif +module xlings.platform; +import :asset_paths; +import std; +namespace xlings::platform { +std::expected exchange_paths(const std::filesystem::path& first, + const std::filesystem::path& second) { +#if defined(__linux__) + constexpr unsigned EXCHANGE { 2 }; + if (::syscall(SYS_renameat2, AT_FDCWD, first.c_str(), AT_FDCWD, second.c_str(), EXCHANGE) == 0) return true; +#elif defined(__APPLE__) + if (::renamex_np(first.c_str(), second.c_str(), RENAME_SWAP) == 0) return true; +#else + return false; +#endif +#if defined(__linux__) || defined(__APPLE__) + if (errno == ENOSYS || errno == EINVAL || errno == EOPNOTSUPP) return false; + return std::unexpected("cannot exchange " + first.string() + " with " + second.string() + ": " + + std::error_code(errno, std::generic_category()).message()); +#endif +} +} diff --git a/modules/platform/src/fs/asset_paths.cppm b/modules/platform/src/fs/asset_paths.cppm new file mode 100644 index 000000000..7ba94172c --- /dev/null +++ b/modules/platform/src/fs/asset_paths.cppm @@ -0,0 +1,8 @@ +export module xlings.platform:asset_paths; +import std; +export namespace xlings::platform { +// Swap two entries without discarding either. False means the platform or +// filesystem lacks exchange; callers may use non-overwriting guarded moves. +std::expected exchange_paths(const std::filesystem::path& first, + const std::filesystem::path& second); +} diff --git a/modules/platform/src/fs/machine_etc.cpp b/modules/platform/src/fs/machine_etc.cpp new file mode 100644 index 000000000..f386846bf --- /dev/null +++ b/modules/platform/src/fs/machine_etc.cpp @@ -0,0 +1,237 @@ +module; +#if !defined(_WIN32) +#include +#include +#include +#include +#include +#endif +module xlings.platform; +import std; +import :machine_etc; + +namespace xlings::platform::machine_etc { +namespace { +namespace fs = std::filesystem; +#if !defined(_WIN32) +struct Fd { + int value { -1 }; + explicit Fd(int fd) : value{fd} {} + Fd(const Fd&) = delete; + Fd& operator=(const Fd&) = delete; + ~Fd() { if (value >= 0) ::close(value); } + int release() { return std::exchange(value, -1); } +}; +std::string failure(std::string_view action, const fs::path& path) { + return std::format("{} {}: {} (machine /etc paths must not traverse symlinks)", + action, path.string(), std::error_code(errno, std::generic_category()).message()); +} +std::expected, std::string> components(const fs::path& path) { + if (path.is_absolute()) return std::unexpected("machine /etc entry must be relative: " + path.string()); + std::vector out; + for (const auto& part : path) { + const auto name = part.string(); + if (name.empty() || name == ".") continue; + if (name == "..") return std::unexpected("machine /etc entry cannot contain '..': " + path.string()); + out.push_back(name); + } + return out; +} +std::expected walk(int root, const fs::path& path, bool create, + std::optional mode = {}) { + auto parts = components(path); + if (!parts) return std::unexpected(parts.error()); + Fd current{::fcntl(root, F_DUPFD_CLOEXEC, 0)}; + if (current.value < 0) return std::unexpected(failure("cannot duplicate directory", path)); + for (std::size_t index { 0 }; index < parts->size(); ++index) { + const auto& name = (*parts)[index]; + bool created { false }; + int next = ::openat(current.value, name.c_str(), O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (next < 0 && errno == ENOENT && create) { + if (::mkdirat(current.value, name.c_str(), 0755) == 0) created = true; + else if (errno != EEXIST) return std::unexpected(failure("cannot create directory", path)); + next = ::openat(current.value, name.c_str(), O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + } + if (next < 0) return std::unexpected(failure("cannot open directory", path)); + Fd opened{next}; + if (created && mode && index + 1 == parts->size() && ::fchmod(next, static_cast(*mode)) < 0) + return std::unexpected(failure("cannot set directory mode", path)); + ::close(current.value); + current.value = opened.release(); + } + return current.release(); +} +std::expected parent(int root, const fs::path& relative, bool create) { + auto parts = components(relative); + if (!parts) return std::unexpected(parts.error()); + if (parts->empty()) return std::unexpected("machine /etc file name is empty"); + return walk(root, relative.parent_path(), create); +} +std::expected read_bytes(int fd, const fs::path& path, bool singleLink) { + struct stat status {}; + if (::fstat(fd, &status) < 0) return std::unexpected(failure("cannot inspect file", path)); + if (!S_ISREG(status.st_mode) || (singleLink && status.st_nlink != 1)) + return std::unexpected("machine /etc file must be a regular, unshared file: " + path.string()); + if (::lseek(fd, 0, SEEK_SET) < 0) return std::unexpected(failure("cannot seek file", path)); + std::string out; + std::array buffer; + for (;;) { + const auto count = ::read(fd, buffer.data(), buffer.size()); + if (count < 0 && errno == EINTR) continue; + if (count < 0) return std::unexpected(failure("cannot read file", path)); + if (count == 0) break; + out.append(buffer.data(), static_cast(count)); + } + return out; +} +std::expected write_bytes(int fd, std::string_view bytes, const fs::path& path) { + while (!bytes.empty()) { + const auto count = ::write(fd, bytes.data(), bytes.size()); + if (count < 0 && errno == EINTR) continue; + if (count <= 0) return std::unexpected(failure("cannot write file", path)); + bytes.remove_prefix(static_cast(count)); + } + if (::fsync(fd) < 0) return std::unexpected(failure("cannot sync file", path)); + return {}; +} +#endif +} + +Directory::Directory(int descriptor) : descriptor_{descriptor} {} +Directory::Directory(Directory&& other) noexcept : descriptor_{std::exchange(other.descriptor_, -1)} {} +Directory& Directory::operator=(Directory&& other) noexcept { + if (this != &other) { +#if !defined(_WIN32) + if (descriptor_ >= 0) ::close(descriptor_); +#endif + descriptor_ = std::exchange(other.descriptor_, -1); + } + return *this; +} +Directory::~Directory() { +#if !defined(_WIN32) + if (descriptor_ >= 0) ::close(descriptor_); +#endif +} +std::expected Directory::open(const fs::path& root, bool create) { +#if !defined(_WIN32) + std::error_code ec; + auto absolute = fs::absolute(root, ec); + if (ec) return std::unexpected("cannot resolve machine directory: " + ec.message()); +#if defined(__APPLE__) + // These fixed OS aliases precede the caller-owned tree, never a user alias. + if (absolute == "/tmp" || absolute.string().starts_with("/tmp/")) absolute = "/private" / absolute.relative_path(); + if (absolute == "/var" || absolute.string().starts_with("/var/")) absolute = "/private" / absolute.relative_path(); +#endif + Fd anchor{::open("/", O_RDONLY | O_DIRECTORY | O_CLOEXEC)}; + if (anchor.value < 0) return std::unexpected(failure("cannot open filesystem root", root)); + auto fd = walk(anchor.value, absolute.relative_path(), create); + if (!fd) return std::unexpected(fd.error()); + return Directory{*fd}; +#else + return std::unexpected("anchored machine /etc writes are unsupported on Windows"); +#endif +} +std::expected Directory::ensure_directory(const fs::path& relative, + std::optional mode) { +#if !defined(_WIN32) + auto parts = components(relative); + if (!parts) return std::unexpected(parts.error()); + if (parts->empty()) return true; + auto fd = parent(descriptor_, relative, true); + if (!fd) return std::unexpected(fd.error()); + Fd holder{*fd}; + struct stat status {}; + const auto name = relative.filename().string(); + if (::fstatat(holder.value, name.c_str(), &status, AT_SYMLINK_NOFOLLOW) == 0) { + if (S_ISREG(status.st_mode)) return false; + if (!S_ISDIR(status.st_mode)) return std::unexpected("machine directory is not a real directory: " + relative.string()); + } else if (errno != ENOENT) return std::unexpected(failure("cannot inspect directory", relative)); + auto opened = walk(descriptor_, relative, true, mode); + if (!opened) return std::unexpected(opened.error()); + Fd child{*opened}; + return true; +#else + return std::unexpected("anchored machine /etc writes are unsupported on Windows"); +#endif +} +std::expected Directory::link_missing(const fs::path& relative, const fs::path& target) { +#if !defined(_WIN32) + auto fd = parent(descriptor_, relative, true); + if (!fd) return std::unexpected(fd.error()); + Fd holder{*fd}; + if (::symlinkat(target.c_str(), holder.value, relative.filename().c_str()) == 0) return true; + if (errno == EEXIST) return false; + return std::unexpected(failure("cannot create factory link", relative)); +#else + return std::unexpected("anchored machine /etc writes are unsupported on Windows"); +#endif +} +std::expected Directory::write_missing(const fs::path& relative, std::string_view bytes) { +#if !defined(_WIN32) + auto fd = parent(descriptor_, relative, true); + if (!fd) return std::unexpected(fd.error()); + Fd holder{*fd}; + Fd file{::openat(holder.value, relative.filename().c_str(), O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0644)}; + if (file.value < 0) { + if (errno == EEXIST) return false; + return std::unexpected(failure("cannot create machine file", relative)); + } + auto written = write_bytes(file.value, bytes, relative); + if (!written) return std::unexpected(written.error()); + return true; +#else + return std::unexpected("anchored machine /etc writes are unsupported on Windows"); +#endif +} +std::expected, std::string> +Directory::read_regular(const fs::path& relative, bool singleLink) const { +#if !defined(_WIN32) + auto fd = parent(descriptor_, relative, false); + if (!fd) return std::unexpected(fd.error()); + Fd holder{*fd}; + Fd file{::openat(holder.value, relative.filename().c_str(), O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK)}; + if (file.value < 0) { + if (errno == ENOENT) return std::optional{}; + return std::unexpected(failure("cannot open machine file", relative)); + } + auto bytes = read_bytes(file.value, relative, singleLink); + if (!bytes) return std::unexpected(bytes.error()); + return std::optional{std::move(*bytes)}; +#else + return std::unexpected("anchored machine /etc reads are unsupported on Windows"); +#endif +} +std::expected, std::string> +Directory::append_named(const fs::path& relative, std::span> rows) { +#if !defined(_WIN32) + auto fd = parent(descriptor_, relative, false); + if (!fd) return std::unexpected(fd.error()); + Fd holder{*fd}; + Fd file{::openat(holder.value, relative.filename().c_str(), O_RDWR | O_CREAT | O_APPEND | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK, 0644)}; + if (file.value < 0) return std::unexpected(failure("cannot open machine database", relative)); + if (::flock(file.value, LOCK_EX | LOCK_NB) < 0) return std::unexpected(failure("cannot lock machine database", relative)); + auto bytes = read_bytes(file.value, relative, true); + if (!bytes) return std::unexpected(bytes.error()); + std::set names; + std::istringstream stream{*bytes}; + for (std::string line; std::getline(stream, line);) + if (const auto colon = line.find(':'); colon != std::string::npos && colon > 0) names.insert(line.substr(0, colon)); + std::vector added; + std::string extra; + for (const auto& [name, text] : rows) { + if (!names.insert(name).second) continue; + if (extra.empty() && !bytes->empty() && bytes->back() != '\n') extra += '\n'; + extra += text; + added.push_back(name); + } + if (!extra.empty()) { + auto written = write_bytes(file.value, extra, relative); + if (!written) return std::unexpected(written.error()); + } + return added; +#else + return std::unexpected("anchored machine /etc writes are unsupported on Windows"); +#endif +} +} diff --git a/modules/platform/src/fs/machine_etc.cppm b/modules/platform/src/fs/machine_etc.cppm new file mode 100644 index 000000000..7d7f8bdb5 --- /dev/null +++ b/modules/platform/src/fs/machine_etc.cppm @@ -0,0 +1,35 @@ +export module xlings.platform:machine_etc; +import std; + +export namespace xlings::platform::machine_etc { + +// All destination components are opened relative to an anchored directory, +// without following symlinks. POSIX only; unsupported platforms fail closed. +class Directory { + int descriptor_ { -1 }; + explicit Directory(int descriptor); +public: + Directory(const Directory&) = delete; + Directory& operator=(const Directory&) = delete; + Directory(Directory&& other) noexcept; + Directory& operator=(Directory&& other) noexcept; + ~Directory(); + static std::expected open(const std::filesystem::path& root, + bool create = false); + // False means an existing regular file blocks this directory. Symlinks + // and unreadable entries are errors, never missing directories. + std::expected ensure_directory(const std::filesystem::path& relative, + std::optional mode = {}); + std::expected link_missing(const std::filesystem::path& relative, + const std::filesystem::path& target); + std::expected write_missing(const std::filesystem::path& relative, + std::string_view bytes); + std::expected, std::string> + read_regular(const std::filesystem::path& relative, bool singleLink = false) const; + // Reads names and appends missing rows through the same locked regular FD. + // Each pair is {name, complete row}; no pathname is reopened for writing. + std::expected, std::string> + append_named(const std::filesystem::path& relative, + std::span> rows); +}; +} diff --git a/modules/platform/src/isolation/domain_mount.cpp b/modules/platform/src/isolation/domain_mount.cpp new file mode 100644 index 000000000..9089cc268 --- /dev/null +++ b/modules/platform/src/isolation/domain_mount.cpp @@ -0,0 +1,21 @@ +module; +#if !defined(_WIN32) +#include +#include +#include +#endif +module xlings.platform; +import :domain_mount; +import std; +namespace xlings::platform { +std::expected read_only_mount(const std::filesystem::path& path) { +#if !defined(_WIN32) + struct statvfs status {}; + if (::statvfs(path.c_str(), &status) != 0) + return std::unexpected(path.string() + ": cannot observe mount flags: " + std::strerror(errno)); + return (status.f_flag & ST_RDONLY) != 0; +#else + return std::unexpected(path.string() + ": read-only namespace mount proof is unavailable on Windows"); +#endif +} +} diff --git a/modules/platform/src/isolation/domain_mount.cppm b/modules/platform/src/isolation/domain_mount.cppm new file mode 100644 index 000000000..ec1cc7dec --- /dev/null +++ b/modules/platform/src/isolation/domain_mount.cppm @@ -0,0 +1,5 @@ +export module xlings.platform:domain_mount; +import std; +export namespace xlings::platform { +std::expected read_only_mount(const std::filesystem::path& path); +} diff --git a/modules/platform/src/isolation/isolation.cpp b/modules/platform/src/isolation/isolation.cpp new file mode 100644 index 000000000..16d1cf359 --- /dev/null +++ b/modules/platform/src/isolation/isolation.cpp @@ -0,0 +1,601 @@ +module; + +#include +#include +#include +#include +#if defined(__linux__) +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "seccomp_abi.hpp" +#elif defined(__APPLE__) +#include +#include +#endif + +module xlings.platform; + +import std; + +namespace xlings::platform { + +namespace fs = std::filesystem; + +#if defined(__linux__) + +// ── namespaces ─────────────────────────────────────────────────────── + +bool enter_private_network(unsigned uid, unsigned gid) { + if (::unshare(CLONE_NEWUSER | CLONE_NEWNET) != 0) return false; + auto put = [](const char* path, const std::string& text) { + const int fd = ::open(path, O_WRONLY | O_CLOEXEC); + if (fd < 0) return false; + const bool ok = ::write(fd, text.data(), text.size()) == static_cast(text.size()); + ::close(fd); + return ok; + }; + put("/proc/self/setgroups", "deny"); + return put("/proc/self/uid_map", std::format("{} {} 1\n", uid, uid)) + && put("/proc/self/gid_map", std::format("{} {} 1\n", gid, gid)); +} + +// ── Landlock ───────────────────────────────────────────────────────── + +namespace landlock { + +namespace { + +constexpr long kCreateRuleset = 444, kAddRule = 445, kRestrictSelf = 446; +constexpr std::uint32_t kCreateRulesetVersion = 1u << 0; +constexpr int kRulePathBeneath = 1; + +// Every right that changes the filesystem, by the ABI that introduced it. +constexpr std::uint64_t kWriteAbi1 = + (1ull << 1) /* WRITE_FILE */ | (1ull << 4) /* REMOVE_DIR */ | (1ull << 5) /* REMOVE_FILE */ | + (1ull << 6) /* MAKE_CHAR */ | (1ull << 7) /* MAKE_DIR */ | (1ull << 8) /* MAKE_REG */ | + (1ull << 9) /* MAKE_SOCK */ | (1ull << 10) /* MAKE_FIFO */ | (1ull << 11) /* MAKE_BLOCK */ | + (1ull << 12) /* MAKE_SYM */; +constexpr std::uint64_t kRefer = 1ull << 13; // ABI 2: rename / link across directories +constexpr std::uint64_t kTruncate = 1ull << 14; // ABI 3 + +struct RulesetAttr { std::uint64_t handled_access_fs; }; +struct [[gnu::packed]] PathBeneathAttr { std::uint64_t allowed_access; std::int32_t parent_fd; }; + +} // namespace + +int abi() { + const long v = ::syscall(kCreateRuleset, nullptr, 0, kCreateRulesetVersion); + return v < 0 ? 0 : static_cast(v); +} + +std::expected restrict_writes(std::span rw) { + const int version = abi(); + if (version < 1) return std::unexpected("Landlock is not available on this kernel"); + std::uint64_t handled = kWriteAbi1; + if (version >= 2) handled |= kRefer; + if (version >= 3) handled |= kTruncate; + + RulesetAttr attr{ handled }; + const int ruleset = static_cast(::syscall(kCreateRuleset, &attr, sizeof attr, 0)); + if (ruleset < 0) return std::unexpected(std::format("landlock_create_ruleset: {}", std::strerror(errno))); + for (const auto& p : rw) { + const int fd = ::open(p.c_str(), O_PATH | O_CLOEXEC); + if (fd < 0) continue; // a path that is not there grants nothing + PathBeneathAttr rule{ handled, fd }; + // A file (not a directory) takes only the rights that apply to files. + struct stat st{}; + if (::fstat(fd, &st) == 0 && !S_ISDIR(st.st_mode)) + rule.allowed_access = handled & ((1ull << 1) | kTruncate); + const long r = ::syscall(kAddRule, ruleset, kRulePathBeneath, &rule, 0); + const int err = errno; + ::close(fd); + if (r < 0) { + ::close(ruleset); + return std::unexpected(std::format("landlock_add_rule {}: {}", p.string(), std::strerror(err))); + } + } + if (::prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0 + || ::syscall(kRestrictSelf, ruleset, 0) != 0) { + const int err = errno; + ::close(ruleset); + return std::unexpected(std::format("landlock_restrict_self: {}", std::strerror(err))); + } + ::close(ruleset); + return {}; +} + +} // namespace landlock + +// ── seccomp ────────────────────────────────────────────────────────── + +namespace seccomp { + +namespace { + +// +constexpr std::uint16_t BPF_LD = 0x00, BPF_JMP = 0x05, BPF_RET = 0x06; +constexpr std::uint16_t BPF_W = 0x00, BPF_ABS = 0x20, BPF_JEQ = 0x10, BPF_K = 0x00; +// +constexpr std::uint32_t SECCOMP_RET_ALLOW = 0x7fff0000U; +constexpr std::uint32_t SECCOMP_RET_ERRNO = 0x00050000U; +constexpr std::uint32_t EPERM_ = 1; +// struct seccomp_data { int nr; __u32 arch; __u64 ip; __u64 args[6]; } +constexpr std::uint32_t OFF_NR = 0, OFF_ARCH = 4, OFF_ARG1_LO = 16 + 8; // little endian +// +constexpr std::uint32_t AUDIT_ARCH_X86_64 = 0xC000003EU; +constexpr std::uint32_t AUDIT_ARCH_I386 = 0x40000003U; +constexpr std::uint32_t AUDIT_ARCH_AARCH64 = 0xC00000B7U; +// ioctl numbers per ABI; x86_64's x32 ABI shares the native one plus a bit. +constexpr std::uint32_t NR_IOCTL_X86_64 = 16, NR_IOCTL_I386 = 54, NR_IOCTL_AARCH64 = 29; +constexpr std::uint32_t X32_BIT = 0x40000000U; +// , identical on x86 and arm64 +constexpr std::uint32_t kTiocsti = 0x5412, kTioclinux = 0x541C; + +struct Insn { + std::uint16_t code; + std::uint8_t jt, jf; + std::uint32_t k; +}; + +Insn stmt(std::uint16_t code, std::uint32_t k) { return {code, 0, 0, k}; } +Insn jump(std::uint16_t code, std::uint32_t k, std::uint8_t jt, std::uint8_t jf) { + return {code, jt, jf, k}; +} + +std::vector encode(const std::vector& prog) { + std::vector out; + out.reserve(prog.size() * 8); + for (const auto& i : prog) { + auto put16 = [&](std::uint16_t v) { out.push_back(v & 0xff); out.push_back(v >> 8); }; + auto put32 = [&](std::uint32_t v) { + for (int b = 0; b < 4; ++b) out.push_back(static_cast(v >> (8 * b))); + }; + put16(i.code); + out.push_back(i.jt); + out.push_back(i.jf); + put32(i.k); + } + return out; +} + +// The tail shared by every ABI: the syscall number is loaded; refuse the two +// commands on ioctl, allow everything else. +// [0] nr == ioctl ? -> [1] : -> allow +std::vector ioctl_check(std::uint32_t nr_ioctl) { + const std::uint16_t LD_W_ABS = BPF_LD | BPF_W | BPF_ABS; + const std::uint16_t JEQ_K = BPF_JMP | BPF_JEQ | BPF_K; + const std::uint16_t RET_K = BPF_RET | BPF_K; + return { + jump(JEQ_K, nr_ioctl, 0, 5), // not ioctl -> allow + stmt(LD_W_ABS, OFF_ARG1_LO), + jump(JEQ_K, kTiocsti, 2, 0), + jump(JEQ_K, kTioclinux, 1, 0), + stmt(RET_K, SECCOMP_RET_ALLOW), + stmt(RET_K, SECCOMP_RET_ERRNO | EPERM_), + stmt(RET_K, SECCOMP_RET_ALLOW), + }; +} + +} // namespace + +std::vector block_terminal_injection() { + const std::uint16_t LD_W_ABS = BPF_LD | BPF_W | BPF_ABS; + const std::uint16_t JEQ_K = BPF_JMP | BPF_JEQ | BPF_K; + std::vector p; +#if defined(__x86_64__) + // arch == x86_64 -> native block; arch == i386 -> compat block; else allow + // [0] ld arch + // [1] jeq x86_64 -> [3] + // [2] jeq i386 -> compat + p.push_back(stmt(LD_W_ABS, OFF_ARCH)); + auto native = ioctl_check(NR_IOCTL_X86_64); + auto x32 = ioctl_check(NR_IOCTL_X86_64 | X32_BIT); + auto compat = ioctl_check(NR_IOCTL_I386); + // Layout: [ld arch][jeq x86_64][jeq i386][ret allow] + // native: [ld nr][jeq x32? -> x32 block][native block...] + // x32 block, compat: [ld nr][compat block] + const std::uint8_t native_len = static_cast(2 + native.size()); + p.push_back(jump(JEQ_K, AUDIT_ARCH_X86_64, 2, 0)); + p.push_back(jump(JEQ_K, AUDIT_ARCH_I386, static_cast(1 + native_len + x32.size()), 0)); + p.push_back(stmt(BPF_RET | BPF_K, SECCOMP_RET_ALLOW)); + p.push_back(stmt(LD_W_ABS, OFF_NR)); + p.push_back(jump(JEQ_K, NR_IOCTL_X86_64 | X32_BIT, static_cast(native.size()), 0)); + p.insert(p.end(), native.begin(), native.end()); + p.insert(p.end(), x32.begin(), x32.end()); + p.push_back(stmt(LD_W_ABS, OFF_NR)); + p.insert(p.end(), compat.begin(), compat.end()); +#elif defined(__aarch64__) + p.push_back(stmt(LD_W_ABS, OFF_ARCH)); + p.push_back(jump(JEQ_K, AUDIT_ARCH_AARCH64, 1, 0)); + p.push_back(stmt(BPF_RET | BPF_K, SECCOMP_RET_ALLOW)); + p.push_back(stmt(LD_W_ABS, OFF_NR)); + auto native = ioctl_check(NR_IOCTL_AARCH64); + p.insert(p.end(), native.begin(), native.end()); +#else + return {}; +#endif + return encode(p); +} + +std::size_t instruction_count(std::span program) { + return program.size() / 8; +} + +namespace { + +// execve / execveat as user notifications. +using SeccompNotif = seccomp_abi::Request; +using SeccompNotifResp = seccomp_abi::Response; +constexpr auto kNotifRecv = seccomp_abi::kRecv, kNotifSend = seccomp_abi::kSend; +constexpr auto kNotifIdValid = seccomp_abi::kIdValid; +constexpr auto kRetUserNotif = seccomp_abi::kNotify, kRetAllow = seccomp_abi::kAllow; +constexpr auto kSetModeFilter = seccomp_abi::kFilter, kFlagNewListener = seccomp_abi::kNewListener; +constexpr auto kUserNotifContinue = seccomp_abi::kContinue; +#if defined(__x86_64__) +constexpr std::uint32_t kArch = 0xC000003EU, kExecve = 59, kExecveat = 322; +#elif defined(__aarch64__) +constexpr std::uint32_t kArch = 0xC00000B7U, kExecve = 221, kExecveat = 281; +#else +constexpr std::uint32_t kArch = 0, kExecve = 0, kExecveat = 0; +#endif + +// A NUL-terminated string from another process's memory. +std::string read_remote_string(pid_t pid, std::uint64_t addr) { + std::string out; + char buf[256]; + for (int chunk = 0; chunk < 16 && addr; ++chunk) { + iovec local{buf, sizeof(buf)}; + iovec remote{reinterpret_cast(addr + chunk * sizeof(buf)), sizeof(buf)}; + const auto n = ::process_vm_readv(pid, &local, 1, &remote, 1, 0); + if (n <= 0) break; + const auto len = ::strnlen(buf, static_cast(n)); + out.append(buf, len); + if (len < static_cast(n)) break; + } + return out; +} + +} // namespace + +int exec_listener() { + if (kArch == 0) return -1; + Insn prog[] = { + {0x20, 0, 0, 4}, // ld [arch] + {0x15, 1, 0, kArch}, // jeq arch, +1 + {0x06, 0, 0, kRetAllow}, // foreign ABI: allow + {0x20, 0, 0, 0}, // ld [nr] + {0x15, 2, 0, kExecve}, // jeq execve -> notify + {0x15, 1, 0, kExecveat}, // jeq execveat -> notify + {0x06, 0, 0, kRetAllow}, + {0x06, 0, 0, kRetUserNotif}, + }; + struct Prog { unsigned short len; Insn* filter; } p{ + static_cast(sizeof(prog) / sizeof(prog[0])), prog }; + if (::prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) return -1; + const long fd = ::syscall(SYS_seccomp, kSetModeFilter, kFlagNewListener, &p); + return fd < 0 ? -1 : static_cast(fd); +} + +std::optional next_exec(int listener) { + SeccompNotif req{}; + if (::ioctl(listener, kNotifRecv, &req) != 0) return std::nullopt; + const auto addr = req.data.nr == static_cast(kExecveat) ? req.data.args[1] : req.data.args[0]; + auto path = read_remote_string(static_cast(req.pid), addr); + // The read is only trusted while the request is still live. + if (::ioctl(listener, kNotifIdValid, &req.id) != 0) path.clear(); + return ExecNotice{ static_cast(req.pid), std::move(path), req.id }; +} + +bool complete_exec(int listener, std::uint64_t id, bool allow) { + if (::ioctl(listener, kNotifIdValid, &id) != 0) return false; + SeccompNotifResp resp{ .id = id, .val = 0, .error = allow ? 0 : -EACCES, + .flags = allow ? kUserNotifContinue : 0u }; + return ::ioctl(listener, kNotifSend, &resp) == 0; +} + +} // namespace seccomp + +// ── the filesystem, beneath a root ─────────────────────────────────── + +namespace { + +struct Fd { + int fd { -1 }; + Fd() = default; + explicit Fd(int f) : fd(f) {} + Fd(Fd&& o) noexcept : fd(std::exchange(o.fd, -1)) {} + Fd& operator=(Fd&& o) noexcept { if (this != &o) { reset(); fd = std::exchange(o.fd, -1); } return *this; } + ~Fd() { reset(); } + void reset() { if (fd >= 0) ::close(fd); fd = -1; } + explicit operator bool() const { return fd >= 0; } +}; + +std::string err(std::string_view what, const fs::path& p) { + return std::format("{} {}: {}", what, p.string(), std::strerror(errno)); +} + +// openat2(2) RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS; on a kernel without it +// (before 5.6), the same walk by hand, one component at a time. +Fd open_beneath(int root, const fs::path& rel, int flags, unsigned mode = 0) { + struct OpenHow { std::uint64_t flags, mode, resolve; }; + constexpr long kOpenat2 = 437; + constexpr std::uint64_t kNoSymlinks = 0x04, kBeneath = 0x08; + const auto r = rel.relative_path().lexically_normal(); + const std::string path = r.empty() || r == "." ? std::string(".") : r.generic_string(); + OpenHow how{ static_cast(flags | O_CLOEXEC), mode, kNoSymlinks | kBeneath }; + const long fd = ::syscall(kOpenat2, root, path.c_str(), &how, sizeof how); + if (fd >= 0 || errno != ENOSYS) return Fd(static_cast(fd)); + + Fd cur(::fcntl(root, F_DUPFD_CLOEXEC, 0)); + std::vector parts; + for (auto& c : r) if (c != "." && !c.empty()) parts.push_back(c.string()); + if (parts.empty()) return Fd(::openat(cur.fd, ".", flags | O_CLOEXEC | O_NOFOLLOW, mode)); + for (std::size_t i = 0; i + 1 < parts.size(); ++i) { + if (parts[i] == "..") { errno = EXDEV; return {}; } + Fd next(::openat(cur.fd, parts[i].c_str(), O_PATH | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC)); + if (!next) return {}; + cur = std::move(next); + } + if (parts.back() == "..") { errno = EXDEV; return {}; } + return Fd(::openat(cur.fd, parts.back().c_str(), flags | O_CLOEXEC | O_NOFOLLOW, mode)); +} + +// Every directory of `rel` beneath `root`, made where missing. +std::expected mkdirs_beneath(int root, const fs::path& rel) { + fs::path sofar; + for (auto& c : rel.relative_path().lexically_normal()) { + if (c == "." || c.empty()) continue; + Fd parent = open_beneath(root, sofar, O_PATH | O_DIRECTORY); + if (!parent) return std::unexpected(err("cannot open", sofar)); + if (::mkdirat(parent.fd, c.c_str(), 0755) != 0 && errno != EEXIST) + return std::unexpected(err("cannot create", sofar / c)); + sofar /= c; + if (!open_beneath(root, sofar, O_PATH | O_DIRECTORY)) + return std::unexpected(std::format("{} is not a directory of the instance's own (a link?)", + sofar.string())); + } + return {}; +} + +std::expected write_all(int to, int from, const fs::path& what) { + std::array buf; + for (;;) { + const auto n = ::read(from, buf.data(), buf.size()); + if (n == 0) return {}; + if (n < 0) { if (errno == EINTR) continue; return std::unexpected(err("cannot read", what)); } + for (ssize_t off = 0; off < n;) { + const auto w = ::write(to, buf.data() + off, static_cast(n - off)); + if (w < 0) { if (errno == EINTR) continue; return std::unexpected(err("cannot write", what)); } + off += w; + } + } +} + +std::expected file_into(const fs::path& src, int root, const fs::path& rel) { + if (auto m = mkdirs_beneath(root, rel.parent_path()); !m) return m; + std::error_code ec; + const auto st = fs::symlink_status(src, ec); + Fd parent = open_beneath(root, rel.parent_path(), O_PATH | O_DIRECTORY); + if (!parent) return std::unexpected(err("cannot open", rel.parent_path())); + const auto name = rel.filename(); + // What is there now goes, unless it is a directory: a link the instance + // made is replaced, never written through. + struct stat cur{}; + if (::fstatat(parent.fd, name.c_str(), &cur, AT_SYMLINK_NOFOLLOW) == 0 && !S_ISDIR(cur.st_mode)) + (void)::unlinkat(parent.fd, name.c_str(), 0); + if (fs::is_symlink(st)) { + const auto target = fs::read_symlink(src, ec); + if (ec || ::symlinkat(target.c_str(), parent.fd, name.c_str()) != 0) + return std::unexpected(err("cannot link", rel)); + return {}; + } + Fd in(::open(src.c_str(), O_RDONLY | O_CLOEXEC)); + if (!in) return std::unexpected(err("cannot read", src)); + struct stat sst{}; + ::fstat(in.fd, &sst); + Fd out(::openat(parent.fd, name.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, + sst.st_mode & 0777)); + if (!out) return std::unexpected(err("cannot write", rel)); + return write_all(out.fd, in.fd, rel); +} + +std::expected file_out(int dir, const char* name, const fs::path& dst) { + struct stat st{}; + if (::fstatat(dir, name, &st, AT_SYMLINK_NOFOLLOW) != 0) return std::unexpected(err("cannot read", name)); + std::error_code ec; + fs::remove(dst, ec); + if (S_ISLNK(st.st_mode)) { + std::array buf{}; + const auto n = ::readlinkat(dir, name, buf.data(), buf.size() - 1); + if (n < 0) return std::unexpected(err("cannot read", name)); + fs::create_symlink(std::string(buf.data(), static_cast(n)), dst, ec); + if (ec) return std::unexpected(std::format("cannot link {}: {}", dst.string(), ec.message())); + return {}; + } + if (!S_ISREG(st.st_mode)) return {}; // devices, fifos, sockets: not copied + Fd in(::openat(dir, name, O_RDONLY | O_NOFOLLOW | O_CLOEXEC)); + if (!in) return std::unexpected(err("cannot read", name)); + Fd out(::open(dst.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, st.st_mode & 0777)); + if (!out) return std::unexpected(err("cannot write", dst)); + return write_all(out.fd, in.fd, dst); +} + +std::expected dir_out(int dir, const fs::path& dst) { + std::error_code ec; + fs::create_directories(dst, ec); + if (ec) return std::unexpected(std::format("cannot create {}: {}", dst.string(), ec.message())); + Fd copy(::fcntl(dir, F_DUPFD_CLOEXEC, 0)); + Fd listed(::openat(dir, ".", O_RDONLY | O_DIRECTORY | O_CLOEXEC)); + DIR* d = listed ? ::fdopendir(listed.fd) : nullptr; + if (!d) return std::unexpected(err("cannot list", dst)); + listed.fd = -1; // owned by d now + std::vector names; + while (auto* e = ::readdir(d)) + if (std::string_view n = e->d_name; n != "." && n != "..") names.emplace_back(n); + ::closedir(d); + for (auto& n : names) { + struct stat st{}; + if (::fstatat(copy.fd, n.c_str(), &st, AT_SYMLINK_NOFOLLOW) != 0) continue; + if (S_ISDIR(st.st_mode)) { + Fd sub(::openat(copy.fd, n.c_str(), O_PATH | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC)); + if (!sub) return std::unexpected(err("cannot open", n)); + if (auto r = dir_out(sub.fd, dst / n); !r) return r; + } else if (auto r = file_out(copy.fd, n.c_str(), dst / n); !r) { + return r; + } + } + return {}; +} + +} // namespace + +std::expected copy_into_beneath(const fs::path& src, const fs::path& root, const fs::path& rel) { + Fd rootfd(::open(root.c_str(), O_PATH | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC)); + if (!rootfd) return std::unexpected(err("cannot open", root)); + std::error_code ec; + if (!fs::is_directory(fs::symlink_status(src, ec))) { + // Onto a directory of the instance's own, or a path that ends in + // `/`: into it, as cp does. + if (!rel.has_filename()) { + if (auto m = mkdirs_beneath(rootfd.fd, rel); !m) return m; + return file_into(src, rootfd.fd, rel.parent_path() / src.filename()); + } + if (open_beneath(rootfd.fd, rel, O_PATH | O_DIRECTORY)) + return file_into(src, rootfd.fd, rel / src.filename()); + return file_into(src, rootfd.fd, rel); + } + if (auto m = mkdirs_beneath(rootfd.fd, rel); !m) return m; + for (auto it = fs::recursive_directory_iterator(src, ec); !ec && it != std::default_sentinel; + it.increment(ec)) { + const auto sub = rel / fs::relative(it->path(), src, ec); + if (it->is_directory(ec) && !it->is_symlink(ec)) { + if (auto m = mkdirs_beneath(rootfd.fd, sub); !m) return m; + } else if (auto r = file_into(it->path(), rootfd.fd, sub); !r) { + return r; + } + } + if (ec) return std::unexpected(std::format("cannot read {}: {}", src.string(), ec.message())); + return {}; +} + +std::expected copy_out_of_beneath(const fs::path& root, const fs::path& rel, const fs::path& dst) { + Fd rootfd(::open(root.c_str(), O_PATH | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC)); + if (!rootfd) return std::unexpected(err("cannot open", root)); + Fd parent = open_beneath(rootfd.fd, rel.parent_path(), O_PATH | O_DIRECTORY); + if (!parent) return std::unexpected(err("cannot open", rel.parent_path())); + const auto name = rel.filename(); + struct stat st{}; + if (::fstatat(parent.fd, name.c_str(), &st, AT_SYMLINK_NOFOLLOW) != 0) + return std::unexpected(err("not found:", rel)); + if (S_ISDIR(st.st_mode)) { + Fd dir(::openat(parent.fd, name.c_str(), O_PATH | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC)); + if (!dir) return std::unexpected(err("cannot open", rel)); + return dir_out(dir.fd, dst); + } + return file_out(parent.fd, name.c_str(), dst); +} + +// ── facts ──────────────────────────────────────────────────────────── + +std::optional file_ownership(const fs::path& path) { + struct stat st{}; + if (::stat(path.c_str(), &st) != 0) return std::nullopt; + return FileOwnership{ static_cast(st.st_uid), static_cast(st.st_mode) }; +} + +std::string kernel_release() { + struct utsname u{}; + return ::uname(&u) == 0 ? std::string(u.release) : std::string{}; +} + +bool mount_kernel_fs(std::string_view type, const fs::path& target) { + std::error_code ec; + fs::create_directories(target, ec); + const std::string t(type); + unsigned long flags = MS_NOSUID; + if (t != "devtmpfs") flags |= MS_NODEV; + if (t == "proc" || t == "sysfs") flags |= MS_NOEXEC; + if (::mount(t.c_str(), target.c_str(), t.c_str(), flags, t == "tmpfs" ? "mode=0755" : nullptr) == 0) + return true; + return errno == EBUSY; +} + +bool remount_root_rw() { + return ::mount("", "/", "", MS_REMOUNT, nullptr) == 0; +} + +bool is_pid1() { return ::getpid() == 1; } + +#else + +bool enter_private_network(unsigned, unsigned) { return false; } + +namespace landlock { +int abi() { return 0; } +std::expected restrict_writes(std::span) { + return std::unexpected("Landlock is Linux-only"); +} +} // namespace landlock + +namespace seccomp { +std::vector block_terminal_injection() { return {}; } +std::size_t instruction_count(std::span program) { return program.size() / 8; } +int exec_listener() { return -1; } +std::optional next_exec(int) { return std::nullopt; } +bool complete_exec(int, std::uint64_t, bool) { return false; } +} // namespace seccomp + +std::expected copy_into_beneath(const fs::path& src, const fs::path& root, const fs::path& rel) { + std::error_code ec; + auto to = root / rel; + if (fs::is_directory(to, ec) && !fs::is_directory(src, ec)) to /= src.filename(); + fs::create_directories(to.parent_path(), ec); + fs::copy(src, to, fs::copy_options::recursive | fs::copy_options::overwrite_existing, ec); + if (ec) return std::unexpected("copy failed: " + ec.message()); + return {}; +} + +std::expected copy_out_of_beneath(const fs::path& root, const fs::path& rel, const fs::path& dst) { + std::error_code ec; + fs::copy(root / rel, dst, fs::copy_options::recursive | fs::copy_options::overwrite_existing + | fs::copy_options::copy_symlinks, ec); + if (ec) return std::unexpected("copy failed: " + ec.message()); + return {}; +} + +std::optional file_ownership(const fs::path& path) { +#if defined(__APPLE__) + struct stat st{}; + if (::stat(path.c_str(), &st) != 0) return std::nullopt; + return FileOwnership{ static_cast(st.st_uid), static_cast(st.st_mode) }; +#else + (void)path; + return std::nullopt; +#endif +} + +bool mount_kernel_fs(std::string_view, const fs::path&) { return false; } +bool remount_root_rw() { return false; } +bool is_pid1() { return false; } + +std::string kernel_release() { +#if defined(__APPLE__) + struct utsname u{}; + return ::uname(&u) == 0 ? std::string(u.release) : std::string{}; +#else + return {}; +#endif +} + +#endif + +} // namespace xlings::platform diff --git a/modules/platform/src/isolation/isolation.cppm b/modules/platform/src/isolation/isolation.cppm new file mode 100644 index 000000000..1c3d129a8 --- /dev/null +++ b/modules/platform/src/isolation/isolation.cppm @@ -0,0 +1,96 @@ +// The kernel's isolation mechanisms, as calls (Linux; the SubOS design §16- +// §20 decides when each is used). Every one is declared everywhere and +// reports "unavailable" where the kernel does not have it, so a caller +// branches with `if constexpr (platform::is_linux)` or on the result -- never +// with an #if or a header of its own. +// +// The kernel ABIs (Landlock, seccomp, openat2) are written out in the +// implementation rather than taken from : the static musl +// toolchains' kernel headers can predate them, and the numbers are the +// kernel's stable interface. +export module xlings.platform:isolation; + +import std; + +export namespace xlings::platform { + +// ── namespaces ─────────────────────────────────────────────────────── + +// In this process: a user namespace mapping this user to itself (not to +// root) and a network namespace it owns -- one an unprivileged pasta can +// join, which it cannot do for one bwrap made. +bool enter_private_network(unsigned uid, unsigned gid); + +// ── Landlock: a write fence without namespaces ─────────────────────── + +namespace landlock { +// 0 when the kernel has no Landlock, or it is not in the LSM list. +int abi(); +// From here on this process and its children may write only beneath `rw` +// (paths that do not exist grant nothing). Sets no_new_privs. +std::expected restrict_writes(std::span rw); +} // namespace landlock + +// ── seccomp ────────────────────────────────────────────────────────── + +namespace seccomp { +// `ioctl(TIOCSTI | TIOCLINUX)` refused with EPERM, as the bytes bwrap reads +// from `--seccomp `. Empty on an architecture this does not know. +std::vector block_terminal_injection(); +std::size_t instruction_count(std::span program); + +// execve / execveat turned into user notifications, installed in this +// process (inherited by everything it starts); the listener, or -1. +int exec_listener(); +struct ExecNotice { + int pid { 0 }; + std::string path; // empty when it could not be read safely + std::uint64_t id { 0 }; +}; +// The next exec the listener holds, still blocked until complete_exec(). +// nullopt when there was nothing to read. +std::optional next_exec(int listener); +// Audit must be stored before allowing the exec; false denies it with EACCES. +bool complete_exec(int listener, std::uint64_t id, bool allow); +} // namespace seccomp + +// ── the filesystem, beneath a root ─────────────────────────────────── +// +// Paths under `root` are resolved without following a link at any level +// (openat2 RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS, a per-component O_NOFOLLOW +// walk before Linux 5.6). A link at the destination is replaced, a linked +// directory on the way refused, and a link copied out is copied as a link. +// Elsewhere: std::filesystem's copy. + +// host `src` -> `root` / `rel` (a file, a directory's contents; `rel` ending +// in `/` or naming a directory of its own receives `src` inside it). +std::expected copy_into_beneath(const std::filesystem::path& src, + const std::filesystem::path& root, + const std::filesystem::path& rel); +// `root` / `rel` -> host `dst`. +std::expected copy_out_of_beneath(const std::filesystem::path& root, + const std::filesystem::path& rel, + const std::filesystem::path& dst); + +// ── facts ──────────────────────────────────────────────────────────── + +struct FileOwnership { + unsigned uid { 0 }; + unsigned mode { 0 }; // st_mode, type and permission bits +}; +std::optional file_ownership(const std::filesystem::path& path); + +// `uname -r`; empty where there is none. +std::string kernel_release(); + +// ── a machine's first process (SubOS design part 2 §8.2) ───────────── +// +// For stage-0, PID 1 of a machine whose root is a SubOS: the kernel's file +// systems (proc, sysfs, devtmpfs, tmpfs) at `target`, created when missing; +// already mounted is success. And `/` read-write, which the kernel mounts +// read-only unless told otherwise. +bool mount_kernel_fs(std::string_view type, const std::filesystem::path& target); +bool remount_root_rw(); +bool is_pid1(); + +} // namespace xlings::platform diff --git a/modules/platform/src/isolation/net_notify.cpp b/modules/platform/src/isolation/net_notify.cpp new file mode 100644 index 000000000..74a160a7d --- /dev/null +++ b/modules/platform/src/isolation/net_notify.cpp @@ -0,0 +1,148 @@ +module; + +#if defined(__linux__) +#include +#include +#include +#include +#include +#include "seccomp_abi.hpp" +#include +#include +#include +#include +#include +#include +#include +#endif + +module xlings.platform; + +import :net_notify; + +import std; + +namespace xlings::platform::net_notify { +#if defined(__linux__) +namespace { +bool remote_(int pid, std::uint64_t address, void* result, std::size_t size) { + iovec local{result, size}, distant{reinterpret_cast(address), size}; + return ::process_vm_readv(pid, &local, 1, &distant, 1, 0) == static_cast(size); +} +#if defined(__x86_64__) +constexpr std::uint32_t ARCH { AUDIT_ARCH_X86_64 }; +#elif defined(__aarch64__) +constexpr std::uint32_t ARCH { AUDIT_ARCH_AARCH64 }; +#else +constexpr std::uint32_t ARCH { 0 }; +#endif +} +int listener(Selection selection) { + if constexpr (ARCH == 0) return -1; + // Unsupported ABIs are denied: a compat binary cannot bypass a native + // syscall-number monitor and create unaudited egress. + std::vector filter{ + {static_cast(BPF_LD | BPF_W | BPF_ABS), 0, 0, offsetof(seccomp_abi::Data, arch)}, + {static_cast(BPF_JMP | BPF_JEQ | BPF_K), 1, 0, ARCH}, + {static_cast(BPF_RET | BPF_K), 0, 0, seccomp_abi::kKill}, + {static_cast(BPF_LD | BPF_W | BPF_ABS), 0, 0, offsetof(seccomp_abi::Data, nr)}}; + const auto notify = [&](int syscall) { + filter.push_back({static_cast(BPF_JMP | BPF_JEQ | BPF_K), 0, 1, + static_cast(syscall)}); + filter.push_back({static_cast(BPF_RET | BPF_K), 0, 0, seccomp_abi::kNotify}); + }; + if (selection.network) + for (const auto syscall : {SYS_connect, SYS_sendto, SYS_sendmsg, SYS_sendmmsg}) notify(syscall); + if (selection.exec) + for (const auto syscall : {SYS_execve, SYS_execveat}) notify(syscall); +#if defined(__x86_64__) + // x32 shares AUDIT_ARCH_X86_64 and encodes a different syscall table. + filter.push_back({static_cast(BPF_JMP | BPF_JSET | BPF_K), 0, 1, 0x40000000U}); + filter.push_back({static_cast(BPF_RET | BPF_K), 0, 0, seccomp_abi::kKill}); +#endif + filter.push_back({static_cast(BPF_RET | BPF_K), 0, 0, seccomp_abi::kAllow}); + sock_fprog program{static_cast(filter.size()), filter.data()}; + if (::prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) return -1; + return static_cast(::syscall(SYS_seccomp, seccomp_abi::kFilter, seccomp_abi::kNewListener, &program)); +} +std::optional next(int listenerFd) { + pollfd ready{listenerFd, POLLIN, 0}; + if (::poll(&ready, 1, 0) <= 0) return std::nullopt; + seccomp_abi::Request request{}; + if (::ioctl(listenerFd, seccomp_abi::kRecv, &request) != 0) return std::nullopt; + Notice result{.id = request.id, .pid = static_cast(request.pid)}; + std::uint64_t pointer { 0 }, size { 0 }; + if (request.data.nr == SYS_execve || request.data.nr == SYS_execveat) { + result.kind = Kind::Exec; + result.syscall = request.data.nr == SYS_execve ? "execve" : "execveat"; + const auto address = request.data.args[request.data.nr == SYS_execve ? 0 : 1]; + std::array text{}; + for (unsigned offset = 0; address && offset < 4096; offset += text.size()) { + iovec local{text.data(), text.size()}, distant{reinterpret_cast(address + offset), text.size()}; + const auto bytes = ::process_vm_readv(request.pid, &local, 1, &distant, 1, 0); + if (bytes <= 0) break; + const auto end = std::find(text.begin(), text.begin() + bytes, '\0'); + result.execPath.append(text.begin(), end); + if (end != text.begin() + bytes) break; + } + } else if (request.data.nr == SYS_connect) { + result.syscall = "connect"; + pointer = request.data.args[1]; + size = request.data.args[2]; + } else if (request.data.nr == SYS_sendto) { + result.syscall = "sendto"; + pointer = request.data.args[4]; + size = request.data.args[5]; + } else if (request.data.nr == SYS_sendmmsg) { + // A batch may name distinct destinations. Report it as an unknown + // batch, never as if the first message proved every recipient. + result.syscall = "sendmmsg"; + } else { + result.syscall = "sendmsg"; + msghdr header{}; + if (remote_(result.pid, request.data.args[1], &header, sizeof(header))) { + pointer = reinterpret_cast(header.msg_name); + size = header.msg_namelen; + } + } + sockaddr_storage address{}; + if (pointer != 0 && size >= sizeof(sa_family_t) && size <= sizeof(address) + && remote_(result.pid, pointer, &address, static_cast(size))) { + std::array text{}; + if (address.ss_family == AF_INET && size >= sizeof(sockaddr_in)) { + const auto* ipv4 = reinterpret_cast(&address); + if (::inet_ntop(AF_INET, &ipv4->sin_addr, text.data(), text.size())) { + result.address = text.data(); + result.port = ntohs(ipv4->sin_port); + result.address_readable = true; + } + } else if (address.ss_family == AF_INET6 && size >= sizeof(sockaddr_in6)) { + const auto* ipv6 = reinterpret_cast(&address); + if (::inet_ntop(AF_INET6, &ipv6->sin6_addr, text.data(), text.size())) { + result.address = text.data(); + result.port = ntohs(ipv6->sin6_port); + result.address_readable = true; + } + } else if (address.ss_family == AF_UNIX) { + result.address = "local-unix"; + result.address_readable = true; + } + } + // Validate again after memory access: a killed task's reused id is never + // completed on behalf of an unrelated notification. + if (::ioctl(listenerFd, seccomp_abi::kIdValid, &request.id) != 0) return std::nullopt; + return result; +} +bool complete(int listenerFd, std::uint64_t id, bool allow) { + seccomp_abi::Response response{}; + response.id = id; + response.error = allow ? 0 : -EACCES; + response.flags = allow ? seccomp_abi::kContinue : 0; + return ::ioctl(listenerFd, seccomp_abi::kSend, &response) == 0; +} +#else +int listener(Selection) { return -1; } +std::optional next(int) { return std::nullopt; } +bool complete(int, std::uint64_t, bool) { return false; } +#endif +} // namespace xlings::platform::net_notify diff --git a/modules/platform/src/isolation/net_notify.cppm b/modules/platform/src/isolation/net_notify.cppm new file mode 100644 index 000000000..b76f4166e --- /dev/null +++ b/modules/platform/src/isolation/net_notify.cppm @@ -0,0 +1,29 @@ +export module xlings.platform:net_notify; + +import std; + +export namespace xlings::platform::net_notify { +// Linux seccomp notification of outbound requests, including UDP sendto/sendmsg/sendmmsg. +// This is attempted I/O: CONTINUE cannot observe the syscall's final result. +struct Selection { + bool network { true }; + bool exec { false }; +}; +// Linux permits one NEW_LISTENER in a filter chain. Select both audit classes +// here when they are needed, and hand this one FD to the trusted observer. +int listener(Selection selection = {}); +enum class Kind { Network, Exec }; +struct Notice { + Kind kind { Kind::Network }; + std::uint64_t id { 0 }; + int pid { 0 }; + std::string syscall; + std::string execPath; + std::string address; + unsigned port { 0 }; + bool address_readable { false }; +}; +std::optional next(int listener); +// Audit must commit before CONTINUE. False returns EACCES to the blocked call. +bool complete(int listener, std::uint64_t id, bool allow); +} // namespace xlings::platform::net_notify diff --git a/modules/platform/src/isolation/root_mount.cpp b/modules/platform/src/isolation/root_mount.cpp new file mode 100644 index 000000000..1dd9e890a --- /dev/null +++ b/modules/platform/src/isolation/root_mount.cpp @@ -0,0 +1,454 @@ +module; + +#if defined(__linux__) +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#endif + +module xlings.platform.root_mount; + +import std; + +namespace xlings::platform::root_mount { +#if defined(__linux__) +namespace { +// Stable kernel ABIs; the static toolchain does not ship recent UAPI headers. +struct OpenHow { + std::uint64_t flags, mode, resolve; +}; +struct MountAttributes { + std::uint64_t attr_set, attr_clr, propagation, userns_fd; +}; +constexpr long kOpenat2 = 437, kOpenTree = 428, kMoveMount = 429, kMountSetattr = 442; +constexpr std::uint64_t kInRoot = 0x10, kNoSymlinks = 0x04, kNoMagicLinks = 0x02; +constexpr unsigned kOpenTreeClone = 1, kMoveMountEmpty = 0x04, kRecursive = 0x8000; +constexpr std::uint64_t kReadOnly = 0x01, kNoSuid = 0x02, kNoDev = 0x04; +struct Descriptors { + std::vector values; + ~Descriptors() { + for (const int fd : values) + if (fd >= 0) + ::close(fd); + } + int own(int fd) { + if (fd >= 0) + values.push_back(fd); + return fd; + } +}; +std::string error(std::string_view step) { + return std::string(step) + ": " + std::strerror(errno); +} +bool same(int a, int b) { + struct stat first{}, second{}; + return ::fstat(a, &first) == 0 && ::fstat(b, &second) == 0 && first.st_dev == second.st_dev && + first.st_ino == second.st_ino; +} +bool absolute_normal(const std::filesystem::path& path) { + return path.is_absolute() && path.lexically_normal() == path && path != "/" && + path.native().find('\0') == std::string::npos; +} +int open_beneath(int root, const std::string& path) { + OpenHow how{}; + how.flags = O_PATH | O_CLOEXEC; + how.resolve = kInRoot | kNoSymlinks | kNoMagicLinks; + return static_cast(::syscall(kOpenat2, root, path.c_str(), &how, sizeof(how))); +} +struct Mount { + int source; + int old; + std::string sourcePath; + std::string destination; + bool replace{false}; +}; +enum class Stage { + None, + UserNamespace, + SourceNamespace, + PrivatePropagation, + SourceRoot, + OpenSource, + SourceIdentity, + CloneSource, + ReadOnlySource, + TargetNamespace, + TargetRoot, + CloneRollback, + ReadOnlyRollback, + Publish, + Remove, + Restore, + UndoPublication, +}; +std::string_view stage_name(Stage stage) { + switch (stage) { + case Stage::UserNamespace: + return "setns-user"; + case Stage::SourceNamespace: + return "unshare-source-mountns"; + case Stage::PrivatePropagation: + return "private-source-propagation"; + case Stage::SourceRoot: + return "open-source-root"; + case Stage::OpenSource: + return "open-source"; + case Stage::SourceIdentity: + return "source-identity"; + case Stage::CloneSource: + return "open_tree-source"; + case Stage::ReadOnlySource: + return "mount_setattr-source"; + case Stage::TargetNamespace: + return "setns-target-mountns"; + case Stage::TargetRoot: + return "chroot-target"; + case Stage::CloneRollback: + return "open_tree-rollback"; + case Stage::ReadOnlyRollback: + return "mount_setattr-rollback"; + case Stage::Publish: + return "move_mount"; + case Stage::Remove: + return "unmount"; + case Stage::Restore: + return "move_mount-rollback"; + case Stage::UndoPublication: + return "unmount-publication"; + case Stage::None: + return "none"; + } + return "unknown"; +} +struct ChildError { + Stage step{}; + int code{}; + std::size_t index{}; + Stage rollbackStep{}; + int rollbackCode{}; + std::size_t rollbackIndex{}; +}; +// A source must belong to the current mount namespace. A detached, recursively +// read-only copy can then be moved to the captured namespace without exposing RW. +int clone_readonly(int source, Stage cloneStage, Stage attributeStage, std::size_t index, + ChildError& failure) { + const int tree = static_cast( + ::syscall(kOpenTree, source, "", AT_EMPTY_PATH | kRecursive | kOpenTreeClone | O_CLOEXEC)); + if (tree < 0) { + failure = {cloneStage, errno, index}; + return -1; + } + MountAttributes attributes{}; + attributes.attr_set = kReadOnly | kNoSuid | kNoDev; + if (::syscall(kMountSetattr, tree, "", AT_EMPTY_PATH | kRecursive, &attributes, + sizeof(attributes)) < 0) { + failure = {attributeStage, errno, index}; + ::close(tree); + return -1; + } + return tree; +} +int publish(int tree, const std::string& destination) { + return static_cast( + ::syscall(kMoveMount, tree, "", AT_FDCWD, destination.c_str(), kMoveMountEmpty)); +} +} // namespace +#endif + +std::expected, std::string> capture() { +#if defined(__linux__) + std::array result{-1, -1, -1}; + constexpr const char* paths[]{"/proc/self/ns/user", "/proc/self/ns/mnt", "/"}; + for (std::size_t i = 0; i < result.size(); ++i) { + result[i] = ::open(paths[i], O_CLOEXEC | (i == 2 ? O_PATH | O_DIRECTORY : O_RDONLY)); + if (result[i] < 0) { + const auto reason = error("capture root mount namespace"); + for (const int fd : result) + if (fd >= 0) + ::close(fd); + return std::unexpected(reason); + } + } + return result; +#else + return std::unexpected("root mount updates require Linux mount namespaces"); +#endif +} + +std::expected normalize(std::array& target) { +#if defined(__linux__) + if (::ioctl(target[0], NS_GET_NSTYPE) != CLONE_NEWUSER || + ::ioctl(target[1], NS_GET_NSTYPE) != CLONE_NEWNS) + return std::unexpected("invalid captured root namespace descriptors"); + struct stat root{}; + if (::fstat(target[2], &root) < 0 || !S_ISDIR(root.st_mode)) + return std::unexpected("invalid captured root directory"); + Descriptors descriptors; + const int owner = descriptors.own(::ioctl(target[1], NS_GET_USERNS)); + if (owner < 0) + return std::unexpected(error("derive captured mount namespace owner")); + int current = target[0]; + bool proved = false; + for (unsigned depth = 0; depth <= 32; ++depth) { + if (same(current, owner)) { + proved = true; + break; + } + current = descriptors.own(::ioctl(current, NS_GET_PARENT)); + if (current < 0) + return std::unexpected(error("prove captured user namespace ancestry")); + } + if (!proved) + return std::unexpected("captured user namespace is outside the mount owner's ancestry"); + ::close(target[0]); + target[0] = owner; + for (auto& descriptor : descriptors.values) + if (descriptor == owner) + descriptor = -1; + return {}; +#else + return std::unexpected("root mount namespace authority requires Linux"); +#endif +} + +std::expected update(std::span target, + std::span add, + std::span remove) { +#if defined(__linux__) + if (::ioctl(target[0], NS_GET_NSTYPE) != CLONE_NEWUSER || + ::ioctl(target[1], NS_GET_NSTYPE) != CLONE_NEWNS) + return std::unexpected("invalid root namespace descriptors"); + Descriptors descriptors; + const int owner = descriptors.own(::ioctl(target[1], NS_GET_USERNS)); + if (owner < 0 || !same(owner, target[0])) + return std::unexpected("mount namespace does not belong to the captured user namespace"); + struct stat root{}; + if (::fstat(target[2], &root) < 0 || !S_ISDIR(root.st_mode)) + return std::unexpected("invalid captured root directory"); + std::vector additions, removals; + std::set destinations; + auto prepare = [&](const std::filesystem::path& source, + const std::filesystem::path& destination, bool removing, + bool replacing = false) -> std::expected { + if (!absolute_normal(destination) || (!removing && !absolute_normal(source))) + return std::unexpected("root mount paths must be normalized absolute paths"); + const auto dst = destination.native(); + if (!destinations.insert(dst).second) + return std::unexpected("duplicate root mount destination: " + dst); + const int old = descriptors.own(open_beneath(target[2], dst)); + if (old < 0) + return std::unexpected(error("open private root mountpoint " + dst)); + struct stat prior{}; + if (::fstat(old, &prior) < 0 || (!S_ISDIR(prior.st_mode) && !S_ISREG(prior.st_mode))) + return std::unexpected("root mountpoint is not a regular file or directory: " + dst); + const int src = + removing ? -1 + : descriptors.own(::open(source.c_str(), O_PATH | O_CLOEXEC | O_NOFOLLOW)); + if (!removing) { + if (src < 0) + return std::unexpected(error("open checked root mount source " + source.string())); + struct stat next{}; + if (::fstat(src, &next) < 0 || (next.st_mode & S_IFMT) != (prior.st_mode & S_IFMT)) + return std::unexpected("root mount source and destination types differ: " + dst); + } + return Mount{src, old, source.native(), dst, replacing}; + }; + for (const auto& binding : add) { + const auto mount = prepare(binding.source, binding.destination, false, binding.replace); + if (!mount) + return std::unexpected(mount.error()); + additions.push_back(*mount); + } + for (const auto& destination : remove) { + const auto mount = prepare({}, destination, true); + if (!mount) + return std::unexpected(mount.error()); + removals.push_back(*mount); + } + if (additions.empty() && removals.empty()) + return {}; + std::vector retirements = removals; + for (const auto& mount : additions) + if (mount.replace) + retirements.push_back(mount); + std::ranges::sort(retirements, [](const Mount& a, const Mount& b) { + return a.destination.size() > b.destination.size(); + }); + std::vector sourceTrees(additions.size(), -1), rollbackTrees(retirements.size(), -1); + std::vector restoredTrees(retirements.size(), false); + int channel[2]; + if (::pipe2(channel, O_CLOEXEC) < 0) + return std::unexpected(error("root mount update channel")); + descriptors.own(channel[0]); + descriptors.own(channel[1]); + const int pid = ::fork(); + if (pid < 0) + return std::unexpected(error("start root mount update helper")); + if (pid == 0) { + ::close(channel[0]); + ChildError failure{}; + const int ownUser = ::open("/proc/self/ns/user", O_RDONLY | O_CLOEXEC); + if (ownUser < 0 || (!same(ownUser, target[0]) && ::setns(target[0], CLONE_NEWUSER) < 0)) + failure = {Stage::UserNamespace, errno}; + if (ownUser >= 0) + ::close(ownUser); + // Copy the supervisor's source view into a namespace owned by the proven + // USER. Original O_PATH descriptors still point into the supervisor's + // namespace, so reopen and compare their exact file identities here. + if (failure.step == Stage::None && !additions.empty()) { + if (::unshare(CLONE_NEWNS) < 0) + failure = {Stage::SourceNamespace, errno}; + if (failure.step == Stage::None && + ::mount(nullptr, "/", nullptr, MS_REC | MS_PRIVATE, nullptr) < 0) + failure = {Stage::PrivatePropagation, errno}; + const int sourceRoot = + failure.step == Stage::None ? ::open("/", O_PATH | O_DIRECTORY | O_CLOEXEC) : -1; + if (failure.step == Stage::None && sourceRoot < 0) + failure = {Stage::SourceRoot, errno}; + for (std::size_t i = 0; failure.step == Stage::None && i < additions.size(); ++i) { + const auto& mount = additions[i]; + OpenHow how{}; + how.flags = O_PATH | O_CLOEXEC | O_NOFOLLOW; + how.resolve = kInRoot | kNoMagicLinks; + const int source = static_cast( + ::syscall(kOpenat2, sourceRoot, mount.sourcePath.c_str(), &how, sizeof(how))); + if (source < 0) { + failure = {Stage::OpenSource, errno, i}; + break; + } + if (!same(source, mount.source)) + failure = {Stage::SourceIdentity, ESTALE, i}; + else + sourceTrees[i] = clone_readonly(source, Stage::CloneSource, + Stage::ReadOnlySource, i, failure); + ::close(source); + ::close(mount.source); + if (!mount.replace) + ::close(mount.old); + } + if (sourceRoot >= 0) + ::close(sourceRoot); + } + if (failure.step == Stage::None && ::setns(target[1], CLONE_NEWNS) < 0) + failure = {Stage::TargetNamespace, errno}; + if (failure.step == Stage::None && + (::fchdir(target[2]) < 0 || ::chroot(".") < 0 || ::chdir("/") < 0)) + failure = {Stage::TargetRoot, errno}; + // Preserve exact mounted trees before any removal. Reopening the old + // pathname after a reinstall could instead bind the replacement inode. + for (std::size_t i = 0; failure.step == Stage::None && i < retirements.size(); ++i) { + rollbackTrees[i] = clone_readonly(retirements[i].old, Stage::CloneRollback, + Stage::ReadOnlyRollback, i, failure); + ::close(retirements[i].old); + } + std::size_t mounted = 0, removed = 0; + // Retire children before parents. Replacing a parent removes its entire + // old subtree, so publishing the new leaves cannot accumulate old layers. + for (std::size_t i = 0; failure.step == Stage::None && i < retirements.size(); ++i) { + if (::umount2(retirements[i].destination.c_str(), MNT_DETACH) < 0) + failure = {Stage::Remove, errno, i}; + else + ++removed; + } + for (std::size_t i = 0; failure.step == Stage::None && i < additions.size(); ++i) { + if (publish(sourceTrees[i], additions[i].destination) < 0) + failure = {Stage::Publish, errno, i}; + else + ++mounted; + } + if (failure.step != Stage::None) { + while (mounted > 0) { + const auto i = --mounted; + if (::umount2(additions[i].destination.c_str(), MNT_DETACH) < 0) { + failure.rollbackStep = Stage::UndoPublication; + failure.rollbackCode = errno; + failure.rollbackIndex = i; + } + } + while (removed > 0) { + const auto i = --removed; + const auto& destination = retirements[i].destination; + bool covered = false; + for (std::size_t j = i + 1; j < retirements.size(); ++j) { + const auto& parent = retirements[j].destination; + if (restoredTrees[j] && destination.starts_with(parent + "/")) { + covered = true; + break; + } + } + // A recursively restored parent already contains its original + // child mounts. Publishing their snapshots again would stack them. + if (covered) + continue; + if (publish(rollbackTrees[i], destination) < 0) { + failure.rollbackStep = Stage::Restore; + failure.rollbackCode = errno; + failure.rollbackIndex = i; + } else { + restoredTrees[i] = true; + } + } + } + for (const int tree : sourceTrees) + if (tree >= 0) + ::close(tree); + for (const int tree : rollbackTrees) + if (tree >= 0) + ::close(tree); + (void)::write(channel[1], &failure, sizeof(failure)); + ::_exit(failure.step != Stage::None ? 125 : 0); + } + ::close(channel[1]); + // The descriptor is already closed; prevent a later destructor from + // closing an unrelated descriptor that reused its number. + descriptors.values.back() = -1; + pollfd reply{channel[0], POLLIN, 0}; + int ready; + do { + ready = ::poll(&reply, 1, 5000); + } while (ready < 0 && errno == EINTR); + ChildError failure{}; + const auto received = ready > 0 ? ::read(channel[0], &failure, sizeof(failure)) : -1; + if (received != sizeof(failure)) + (void)::kill(pid, SIGKILL); + int status{}; + while (::waitpid(pid, &status, 0) < 0 && errno == EINTR) { + } + if (received != sizeof(failure)) + return std::unexpected("root mount update helper failed or timed out; end the session"); + if (failure.step != Stage::None) { + const bool removal = failure.step == Stage::Remove || + failure.step == Stage::CloneRollback || + failure.step == Stage::ReadOnlyRollback; + const auto& mounts = removal ? retirements : additions; + const auto destination = failure.index < mounts.size() + ? " at " + mounts[failure.index].destination + : std::string{}; + const auto rollback = + failure.rollbackStep == Stage::None + ? std::string{} + : std::format("; rollback {}[{}]: {} (end the session)", + stage_name(failure.rollbackStep), failure.rollbackIndex, + std::strerror(failure.rollbackCode)); + return std::unexpected(std::format("root mount update {}[{}]{}: {}{}", + stage_name(failure.step), failure.index, destination, + std::strerror(failure.code), rollback)); + } + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) + return std::unexpected("root mount update helper did not complete; end the session"); + return {}; +#else + return std::unexpected("root mount updates require Linux mount namespaces"); +#endif +} +} // namespace xlings::platform::root_mount diff --git a/modules/platform/src/isolation/root_mount.cppm b/modules/platform/src/isolation/root_mount.cppm new file mode 100644 index 000000000..a8c441cbd --- /dev/null +++ b/modules/platform/src/isolation/root_mount.cppm @@ -0,0 +1,30 @@ +export module xlings.platform.root_mount; + +import std; + +export namespace xlings::platform::root_mount { + +struct Binding { + std::filesystem::path source; + std::filesystem::path destination; + // Only an already tracked leaf mount may be retired before replacement. + bool replace{false}; +}; + +// Captured by the trusted init before commands start. The caller owns all +// three descriptors (user namespace, mount namespace, root directory). +std::expected, std::string> capture(); + +// Called by the outer supervisor after the trusted init handoff. Derive the +// mount owner from its kernel FD and prove that the captured current user +// namespace is that owner or its descendant. Replace only on success. +std::expected normalize(std::array& target); + +// The owner has already checked the package closure and prepared mountpoints +// in its private read-only skeleton. No PID, path prefix, or environment +// variable selects a namespace. A failed update rolls back its mounts. +std::expected update(std::span target, + std::span add, + std::span remove); + +} // namespace xlings::platform::root_mount diff --git a/modules/platform/src/isolation/seccomp_abi.hpp b/modules/platform/src/isolation/seccomp_abi.hpp new file mode 100644 index 000000000..ad12ddfae --- /dev/null +++ b/modules/platform/src/isolation/seccomp_abi.hpp @@ -0,0 +1,16 @@ +#pragma once +#include + +namespace xlings::platform::seccomp_abi { +// Stable notification ABI, including SDKs whose Linux headers predate it. +struct Data { std::int32_t nr; std::uint32_t arch; std::uint64_t ip; std::uint64_t args[6]; }; +struct Request { std::uint64_t id; std::uint32_t pid; std::uint32_t flags; Data data; }; +struct Response { std::uint64_t id; std::int64_t val; std::int32_t error; std::uint32_t flags; }; +static_assert(sizeof(Data) == 64 && sizeof(Request) == 80 && sizeof(Response) == 24); +inline constexpr unsigned long kRecv = 0xC0502100UL; +inline constexpr unsigned long kSend = 0xC0182101UL; +inline constexpr unsigned long kIdValid = 0x40082102UL; +inline constexpr std::uint32_t kNotify = 0x7fc00000U, kAllow = 0x7fff0000U, kKill = 0x80000000U; +inline constexpr unsigned kFilter = 1, kNewListener = 1U << 3; +inline constexpr std::uint32_t kContinue = 1; +} diff --git a/modules/platform/src/net/loopback_http.cpp b/modules/platform/src/net/loopback_http.cpp new file mode 100644 index 000000000..c33b48190 --- /dev/null +++ b/modules/platform/src/net/loopback_http.cpp @@ -0,0 +1,117 @@ +module xlings.platform.loopback_http; +import std; +import xlings.platform; + +namespace xlings::platform::loopback_http { +Result download(std::string_view url, const std::filesystem::path& destination, + std::chrono::seconds timeout, std::function progress, + std::function cancelled) { + Result result; + auto fail = [&](Failure kind, std::string message) { + result.failure = kind; + result.error = std::move(message); + return result; + }; + constexpr std::string_view prefix = "http://127.0.0.1:"; + if (!url.starts_with(prefix)) return fail(Failure::Source, "plain HTTP requires the numeric loopback address"); + auto rest = url.substr(prefix.size()); + const auto slash = rest.find('/'); + if (slash == rest.npos) return fail(Failure::Source, "loopback HTTP URL needs a port and path"); + unsigned port = 0; + const auto number = rest.substr(0, slash); + const auto [end, ec] = std::from_chars(number.data(), number.data() + number.size(), port); + if (ec != std::errc{} || end != number.data() + number.size() || port == 0 || port > 65535) + return fail(Failure::Source, "invalid loopback HTTP port"); + const auto path = rest.substr(slash); + if (std::ranges::any_of(path, [](unsigned char c) { return c <= 32 || c == 127; }) || path.contains('#')) + return fail(Failure::Source, "invalid loopback HTTP path"); + if (cancelled && cancelled()) return fail(Failure::Cancelled, "cancelled"); + timeout = std::clamp(timeout, std::chrono::seconds(1), std::chrono::seconds(30)); + const auto deadline = std::chrono::steady_clock::now() + timeout; + auto connection = TcpStream::connect_loopback(static_cast(port), std::chrono::seconds(2)); + if (!connection) return fail(Failure::Transfer, connection.error()); + auto sent = connection->send_all(std::format("GET {} HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nConnection: close\r\n\r\n", path, port), std::chrono::seconds(2)); + if (!sent) return fail(Failure::Transfer, sent.error()); + std::string bytes; + std::array buffer{}; + auto receive = [&]() -> std::expected { + while (std::chrono::steady_clock::now() < deadline) { + if (cancelled && cancelled()) return std::unexpected("cancelled"); + auto got = connection->poll_receive(buffer, std::chrono::milliseconds(100)); + if (!got) return std::unexpected(got.error()); + if (*got) return **got; + } + return std::unexpected("loopback HTTP deadline exceeded"); + }; + auto transfer_failure = [&](std::string error) { + return fail(cancelled && cancelled() ? Failure::Cancelled : Failure::Transfer, std::move(error)); + }; + std::size_t boundary = std::string::npos; + while ((boundary = bytes.find("\r\n\r\n")) == bytes.npos) { + if (bytes.size() > 16384) return fail(Failure::Source, "HTTP headers exceed 16 KiB"); + auto got = receive(); + if (!got) return transfer_failure(got.error()); + if (*got == 0) return fail(Failure::Transfer, "HTTP headers ended early"); + bytes.append(buffer.data(), *got); + } + if (boundary > 16384) return fail(Failure::Source, "HTTP headers exceed 16 KiB"); + std::istringstream headers(bytes.substr(0, boundary)); + std::string line; + if (!std::getline(headers, line)) return fail(Failure::Source, "missing HTTP status"); + std::istringstream status(line); + std::string version; + int code = 0; + if (!(status >> version >> code) || (version != "HTTP/1.1" && version != "HTTP/1.0")) + return fail(Failure::Source, "invalid HTTP status"); + if (code != 200) return fail(Failure::Source, std::format("HTTP {}: loopback source refused the payload", code)); + while (std::getline(headers, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + const auto colon = line.find(':'); + if (colon == line.npos) return fail(Failure::Source, "malformed HTTP header"); + auto name = line.substr(0, colon); + for (char& c : name) if (c >= 'A' && c <= 'Z') c += 'a' - 'A'; + auto value = std::string_view(line).substr(colon + 1); + while (!value.empty() && (value.front() == ' ' || value.front() == '\t')) value.remove_prefix(1); + while (!value.empty() && (value.back() == ' ' || value.back() == '\t')) value.remove_suffix(1); + if (name == "transfer-encoding") return fail(Failure::Source, "chunked loopback HTTP is unsupported"); + if (name == "content-length") { + std::int64_t length = 0; + const auto [last, error] = std::from_chars(value.data(), value.data() + value.size(), length); + if (result.expected || error != std::errc{} || last != value.data() + value.size() + || length < 0 || length > 64 * 1024 * 1024) + return fail(Failure::Source, "invalid or duplicate HTTP Content-Length"); + result.expected = length; + } + } + if (!result.expected) return fail(Failure::Source, "loopback HTTP requires Content-Length"); + std::error_code spaceError; + const auto space = std::filesystem::space(destination.parent_path(), spaceError); + if (!spaceError && space.available < static_cast(*result.expected)) + return fail(Failure::Local, "not enough space for the loopback HTTP payload"); + std::ofstream file(destination, std::ios::binary | std::ios::trunc); + if (!file) return fail(Failure::Local, "cannot open loopback HTTP destination"); + auto write = [&](std::string_view body) -> std::expected { + if (body.size() > static_cast(*result.expected - result.written)) + return std::unexpected("HTTP body exceeds Content-Length"); + file.write(body.data(), static_cast(body.size())); + if (!file) return std::unexpected("cannot write loopback HTTP destination"); + result.written += static_cast(body.size()); + if (progress) progress(static_cast(*result.expected), static_cast(result.written)); + return {}; + }; + auto kept = write(std::string_view(bytes).substr(boundary + 4)); + if (!kept) return fail(file ? Failure::Source : Failure::Local, kept.error()); + for (;;) { + auto got = receive(); + if (!got) return transfer_failure(got.error()); + if (*got == 0) break; + kept = write(std::string_view(buffer.data(), *got)); + if (!kept) return fail(file ? Failure::Source : Failure::Local, kept.error()); + } + if (result.written != *result.expected) return fail(Failure::Transfer, "HTTP body ended before Content-Length"); + file.flush(); + if (!file) return fail(Failure::Local, "cannot flush loopback HTTP destination"); + result.success = true; + return result; +} +} diff --git a/modules/platform/src/net/loopback_http.cppm b/modules/platform/src/net/loopback_http.cppm new file mode 100644 index 000000000..71dc92781 --- /dev/null +++ b/modules/platform/src/net/loopback_http.cppm @@ -0,0 +1,17 @@ +export module xlings.platform.loopback_http; +import std; +export namespace xlings::platform::loopback_http { +enum class Failure { None, Source, Transfer, Local, Cancelled }; +struct Result { + bool success { false }; + std::string error; + std::int64_t written { 0 }; + std::optional expected; + Failure failure { Failure::None }; +}; +// Plain HTTP is restricted to the numeric IPv4 loopback literal. No DNS, +// proxy, redirects or chunked body; HTTPS remains the external transport. +Result download(std::string_view url, const std::filesystem::path& destination, + std::chrono::seconds timeout, std::function progress = {}, + std::function cancelled = {}); +} diff --git a/modules/platform/src/net/network.cpp b/modules/platform/src/net/network.cpp new file mode 100644 index 000000000..29d3971c7 --- /dev/null +++ b/modules/platform/src/net/network.cpp @@ -0,0 +1,139 @@ +module; + +#if defined(__linux__) +#include +#include +#include +#include +#include +#include +#include +#include +#include +#endif + +module xlings.platform; + +import :network; + +import std; + +namespace xlings::platform::network { +#if defined(__linux__) +namespace { +std::string error_text_() { return std::strerror(errno); } +bool retry_() { return errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR; } +} +std::expected, std::string> resolve_proxy(std::string_view host, std::uint16_t port) { + addrinfo hints{}; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + hints.ai_protocol = IPPROTO_TCP; + addrinfo* result { nullptr }; + const auto status = ::getaddrinfo(std::string(host).c_str(), std::to_string(port).c_str(), &hints, &result); + if (status != 0) return std::unexpected("cannot resolve declared proxy: " + std::string(::gai_strerror(status))); + std::unique_ptr owner{result, &::freeaddrinfo}; + std::vector
addresses; + for (auto* item = result; item; item = item->ai_next) { + if (item->ai_family != AF_INET && item->ai_family != AF_INET6) continue; + Address address{.family = item->ai_family}; + const auto* bytes = reinterpret_cast(item->ai_addr); + address.bytes.assign(bytes, bytes + item->ai_addrlen); + std::array numeric{}; + if (::getnameinfo(item->ai_addr, item->ai_addrlen, numeric.data(), numeric.size(), nullptr, 0, + NI_NUMERICHOST) == 0) address.numeric = numeric.data(); + addresses.push_back(std::move(address)); + } + if (addresses.empty()) return std::unexpected("declared proxy has no IPv4 or IPv6 address"); + return addresses; +} +std::expected enable_loopback() { + const int fd = ::socket(AF_INET, SOCK_DGRAM | SOCK_CLOEXEC, 0); + if (fd < 0) return std::unexpected(error_text_()); + ifreq request{}; + std::memcpy(request.ifr_name, "lo", 3); + bool ok = ::ioctl(fd, SIOCGIFFLAGS, &request) == 0; + request.ifr_flags |= IFF_UP; + if (ok) ok = ::ioctl(fd, SIOCSIFFLAGS, &request) == 0; + const auto error = ok ? std::string{} : error_text_(); + ::close(fd); + if (!ok) return std::unexpected("cannot enable private loopback: " + error); + return {}; +} +std::expected listen_loopback(std::uint16_t port) { + const int fd = ::socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, IPPROTO_TCP); + if (fd < 0) return std::unexpected(error_text_()); + sockaddr_in address{}; + address.sin_family = AF_INET; + address.sin_port = htons(port); + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (::bind(fd, reinterpret_cast(&address), sizeof(address)) != 0 || ::listen(fd, 64) != 0) { + const auto error = error_text_(); + ::close(fd); + return std::unexpected("cannot listen on private loopback: " + error); + } + return fd; +} +std::expected bound_port(int listener) { + sockaddr_in address{}; + socklen_t size = sizeof(address); + if (::getsockname(listener, reinterpret_cast(&address), &size) != 0) + return std::unexpected(error_text_()); + return ntohs(address.sin_port); +} +std::expected, std::string> accept(int listener) { + const int fd = ::accept4(listener, nullptr, nullptr, SOCK_NONBLOCK | SOCK_CLOEXEC); + if (fd >= 0) return std::optional{fd}; + if (retry_()) return std::nullopt; + return std::unexpected(error_text_()); +} +std::expected connect_to(const Address& proxy) { + if ((proxy.family != AF_INET && proxy.family != AF_INET6) + || proxy.bytes.size() != (proxy.family == AF_INET ? sizeof(sockaddr_in) : sizeof(sockaddr_in6))) + return std::unexpected("invalid resolved proxy endpoint"); + const int fd = ::socket(proxy.family, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, IPPROTO_TCP); + if (fd < 0) return std::unexpected(error_text_()); + if (::connect(fd, reinterpret_cast(proxy.bytes.data()), proxy.bytes.size()) == 0) + return Connecting{fd, true}; + if (errno == EINPROGRESS) return Connecting{fd, false}; + const auto error = error_text_(); + ::close(fd); + return std::unexpected(error); +} +std::expected, std::string> connected(int fd) { + pollfd ready{fd, POLLOUT, 0}; + const int status = ::poll(&ready, 1, 0); + if (status == 0 || (status < 0 && retry_())) return std::nullopt; + if (status < 0) return std::unexpected(error_text_()); + int failure { 0 }; + socklen_t size = sizeof(failure); + if (::getsockopt(fd, SOL_SOCKET, SO_ERROR, &failure, &size) != 0) return std::unexpected(error_text_()); + if (failure != 0) return std::unexpected(std::string(::strerror(failure))); + return std::optional{true}; +} +std::expected, std::string> receive(int fd, std::span buffer) { + const auto count = ::recv(fd, buffer.data(), buffer.size(), MSG_DONTWAIT); + if (count >= 0) return std::optional{static_cast(count)}; + if (retry_()) return std::nullopt; + return std::unexpected(error_text_()); +} +std::expected, std::string> send(int fd, std::string_view bytes) { + const auto count = ::send(fd, bytes.data(), bytes.size(), MSG_DONTWAIT | MSG_NOSIGNAL); + if (count >= 0) return std::optional{static_cast(count)}; + if (retry_()) return std::nullopt; + return std::unexpected(error_text_()); +} +bool shutdown_write(int fd) { return ::shutdown(fd, SHUT_WR) == 0; } +#else +std::expected, std::string> resolve_proxy(std::string_view, std::uint16_t) { return std::unexpected("proxy network sessions need Linux"); } +std::expected enable_loopback() { return std::unexpected("proxy network sessions need Linux"); } +std::expected listen_loopback(std::uint16_t) { return std::unexpected("proxy network sessions need Linux"); } +std::expected bound_port(int) { return std::unexpected("proxy network sessions need Linux"); } +std::expected, std::string> accept(int) { return std::unexpected("proxy network sessions need Linux"); } +std::expected connect_to(const Address&) { return std::unexpected("proxy network sessions need Linux"); } +std::expected, std::string> connected(int) { return std::unexpected("proxy network sessions need Linux"); } +std::expected, std::string> receive(int, std::span) { return std::unexpected("proxy network sessions need Linux"); } +std::expected, std::string> send(int, std::string_view) { return std::unexpected("proxy network sessions need Linux"); } +bool shutdown_write(int) { return false; } +#endif +} // namespace xlings::platform::network diff --git a/modules/platform/src/net/network.cppm b/modules/platform/src/net/network.cppm new file mode 100644 index 000000000..1b96cdcd2 --- /dev/null +++ b/modules/platform/src/net/network.cppm @@ -0,0 +1,28 @@ +export module xlings.platform:network; + +import std; + +export namespace xlings::platform::network { + +// Linux session sockets. Unlike :tcp's fixture API, only the supervisor may +// use connect_to(): it takes the already resolved, declared proxy endpoint. +struct Address { + int family { 0 }; + std::vector bytes; + std::string numeric; +}; +std::expected, std::string> resolve_proxy(std::string_view host, std::uint16_t port); +std::expected enable_loopback(); +std::expected listen_loopback(std::uint16_t port); +std::expected bound_port(int listener); +std::expected, std::string> accept(int listener); +struct Connecting { int fd { -1 }; bool ready { false }; }; +std::expected connect_to(const Address& proxy); +// nullopt until writable; false/error is never successful establishment. +std::expected, std::string> connected(int fd); +// nullopt is EAGAIN; zero is EOF. Operations never block. +std::expected, std::string> receive(int fd, std::span buffer); +std::expected, std::string> send(int fd, std::string_view bytes); +bool shutdown_write(int fd); + +} // namespace xlings::platform::network diff --git a/modules/platform/src/net/tcp.cpp b/modules/platform/src/net/tcp.cpp new file mode 100644 index 000000000..a239e611b --- /dev/null +++ b/modules/platform/src/net/tcp.cpp @@ -0,0 +1,265 @@ +module; + +#if defined(_WIN32) +#define NOMINMAX +#include +#include +#define WIN32_LEAN_AND_MEAN +#include +#pragma comment(lib, "ws2_32.lib") +#else +#include +#include +#include +#include +#include +#include +#include +#endif + +module xlings.platform; + +import :tcp; + +import std; + +namespace xlings::platform { + +namespace { + +#if defined(_WIN32) +using NativeSocket = SOCKET; +constexpr auto INVALID = INVALID_SOCKET; +int socket_error() { return ::WSAGetLastError(); } +std::string socket_error_text(int error) { return "Winsock error " + std::to_string(error); } +bool retryable(int error) { return error == WSAEWOULDBLOCK || error == WSAEINTR; } +void close_socket(NativeSocket socket) { ::closesocket(socket); } +bool initialize() { + struct Winsock { + bool ready; + Winsock() { WSADATA data{}; ready = ::WSAStartup(MAKEWORD(2, 2), &data) == 0; } + ~Winsock() { if (ready) ::WSACleanup(); } + }; + static Winsock instance; + return instance.ready; +} +bool configure(NativeSocket socket) { + u_long mode { 1 }; + return ::ioctlsocket(socket, FIONBIO, &mode) == 0 + && ::SetHandleInformation(reinterpret_cast(socket), HANDLE_FLAG_INHERIT, 0); +} +#else +using NativeSocket = int; +constexpr auto INVALID = -1; +int socket_error() { return errno; } +std::string socket_error_text(int error) { return std::strerror(error); } +bool retryable(int error) { return error == EAGAIN || error == EWOULDBLOCK || error == EINTR; } +void close_socket(NativeSocket socket) { ::close(socket); } +bool initialize() { return true; } +bool configure(NativeSocket socket) { + if (::fcntl(socket, F_SETFD, FD_CLOEXEC) < 0) return false; + const auto flags = ::fcntl(socket, F_GETFL, 0); + if (flags < 0 || ::fcntl(socket, F_SETFL, flags | O_NONBLOCK) < 0) return false; +#if defined(SO_NOSIGPIPE) + const int enabled { 1 }; + if (::setsockopt(socket, SOL_SOCKET, SO_NOSIGPIPE, &enabled, sizeof(enabled)) != 0) return false; +#endif + return true; +} +#endif + +using Clock = std::chrono::steady_clock; + +std::expected wait_socket(NativeSocket socket, bool writing, Clock::time_point deadline) { + for (;;) { + const auto remaining = std::chrono::duration_cast(deadline - Clock::now()); + if (remaining.count() < 0) return false; + const auto timeout = static_cast(std::min(remaining.count(), std::numeric_limits::max())); +#if defined(_WIN32) + WSAPOLLFD fd { socket, static_cast(writing ? POLLWRNORM : POLLRDNORM), 0 }; + const int ready = ::WSAPoll(&fd, 1, timeout); +#else + pollfd fd { socket, static_cast(writing ? POLLOUT : POLLIN), 0 }; + const int ready = ::poll(&fd, 1, timeout); +#endif + if (ready > 0) return true; // recv/accept/SO_ERROR attributes hang-up and error. + if (ready == 0) return false; + const int error = socket_error(); + if (retryable(error)) continue; + return std::unexpected(socket_error_text(error)); + } +} + +sockaddr_in loopback_address(std::uint16_t port) { + sockaddr_in address{}; + address.sin_family = AF_INET; + address.sin_port = htons(port); + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + return address; +} + +std::expected new_socket() { + if (!initialize()) return std::unexpected("cannot initialize loopback sockets"); + const auto socket = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (socket == INVALID) return std::unexpected(socket_error_text(socket_error())); + if (!configure(socket)) { + const auto error = socket_error_text(socket_error()); + close_socket(socket); + return std::unexpected("cannot configure loopback socket: " + error); + } + return socket; +} + +} // namespace + +TcpStream::TcpStream(std::intptr_t handle) : handle_{handle} {} +TcpStream::TcpStream(TcpStream&& other) noexcept : handle_{std::exchange(other.handle_, -1)} {} +TcpStream& TcpStream::operator=(TcpStream&& other) noexcept { + if (this != &other) { close(); handle_ = std::exchange(other.handle_, -1); } + return *this; +} +TcpStream::~TcpStream() { close(); } +void TcpStream::close() noexcept { + if (handle_ != -1) close_socket(static_cast(std::exchange(handle_, -1))); +} + +std::expected TcpStream::connect_loopback(std::uint16_t port, + std::chrono::milliseconds timeout) { + const auto socket = new_socket(); + if (!socket) return std::unexpected(socket.error()); + TcpStream stream{static_cast(*socket)}; + const auto address = loopback_address(port); + if (::connect(*socket, reinterpret_cast(&address), sizeof(address)) != 0) { + const auto error = socket_error(); +#if defined(_WIN32) + if (error != WSAEWOULDBLOCK && error != WSAEINPROGRESS) +#else + if (error != EINPROGRESS && !retryable(error)) +#endif + return std::unexpected("loopback connect failed: " + socket_error_text(error)); + const auto ready = wait_socket(*socket, true, Clock::now() + timeout); + if (!ready) return std::unexpected(ready.error()); + if (!*ready) return std::unexpected("loopback connect timed out"); + int failure { 0 }; +#if defined(_WIN32) + int length = sizeof(failure); + const int checked = ::getsockopt(*socket, SOL_SOCKET, SO_ERROR, reinterpret_cast(&failure), &length); +#else + socklen_t length = sizeof(failure); + const int checked = ::getsockopt(*socket, SOL_SOCKET, SO_ERROR, &failure, &length); +#endif + if (checked != 0 || failure != 0) + return std::unexpected("loopback connect failed: " + socket_error_text(failure ? failure : socket_error())); + } + return stream; +} + +std::expected TcpStream::receive(std::span bytes, + std::chrono::milliseconds timeout) { + auto read = poll_receive(bytes, timeout); + if (!read) return std::unexpected(read.error()); + if (!*read) return std::unexpected("loopback receive timed out"); + return **read; +} + +std::expected, std::string> TcpStream::poll_receive(std::span bytes, + std::chrono::milliseconds timeout) { + if (handle_ == -1) return std::unexpected("socket is closed"); + if (bytes.empty()) return 0; + const auto socket = static_cast(handle_); + const auto deadline = Clock::now() + timeout; + for (;;) { + const auto ready = wait_socket(socket, false, deadline); + if (!ready) return std::unexpected(ready.error()); + if (!*ready) return std::nullopt; + const auto length = static_cast(std::min(bytes.size(), std::numeric_limits::max())); + const auto read = ::recv(socket, bytes.data(), length, 0); + if (read >= 0) return static_cast(read); + const auto error = socket_error(); + if (!retryable(error)) return std::unexpected(socket_error_text(error)); + } +} + +std::expected TcpStream::send_all(std::string_view bytes, + std::chrono::milliseconds timeout) { + if (handle_ == -1) return std::unexpected("socket is closed"); + const auto socket = static_cast(handle_); + const auto deadline = Clock::now() + timeout; + while (!bytes.empty()) { + const auto ready = wait_socket(socket, true, deadline); + if (!ready) return std::unexpected(ready.error()); + if (!*ready) return std::unexpected("loopback send timed out"); +#if defined(MSG_NOSIGNAL) + constexpr int flags = MSG_NOSIGNAL; +#else + constexpr int flags = 0; +#endif + const auto length = static_cast(std::min(bytes.size(), std::numeric_limits::max())); + const auto sent = ::send(socket, bytes.data(), length, flags); + if (sent > 0) { bytes.remove_prefix(static_cast(sent)); continue; } + const auto error = socket_error(); + if (sent == 0 || !retryable(error)) return std::unexpected(socket_error_text(error)); + } + return {}; +} + +LoopbackListener::LoopbackListener(std::intptr_t handle, std::uint16_t port) : handle_{handle}, port_{port} {} +LoopbackListener::LoopbackListener(LoopbackListener&& other) noexcept + : handle_{std::exchange(other.handle_, -1)}, port_{std::exchange(other.port_, 0)} {} +LoopbackListener& LoopbackListener::operator=(LoopbackListener&& other) noexcept { + if (this != &other) { close(); handle_ = std::exchange(other.handle_, -1); port_ = std::exchange(other.port_, 0); } + return *this; +} +LoopbackListener::~LoopbackListener() { close(); } +void LoopbackListener::close() noexcept { + if (handle_ != -1) close_socket(static_cast(std::exchange(handle_, -1))); + port_ = 0; +} +std::uint16_t LoopbackListener::port() const { return port_; } + +std::expected LoopbackListener::open(std::uint16_t port) { + const auto socket = new_socket(); + if (!socket) return std::unexpected(socket.error()); + LoopbackListener listener{static_cast(*socket), port}; + auto address = loopback_address(port); +#if defined(_WIN32) + const BOOL exclusive { TRUE }; + if (::setsockopt(*socket, SOL_SOCKET, SO_EXCLUSIVEADDRUSE, + reinterpret_cast(&exclusive), sizeof(exclusive)) != 0) + return std::unexpected("cannot reserve exclusive loopback address"); +#endif + if (::bind(*socket, reinterpret_cast(&address), sizeof(address)) != 0 + || ::listen(*socket, 32) != 0) + return std::unexpected("cannot listen on loopback: " + socket_error_text(socket_error())); +#if defined(_WIN32) + int length = sizeof(address); +#else + socklen_t length = sizeof(address); +#endif + if (::getsockname(*socket, reinterpret_cast(&address), &length) != 0) + return std::unexpected(socket_error_text(socket_error())); + listener.port_ = ntohs(address.sin_port); + return listener; +} + +std::expected, std::string> +LoopbackListener::accept(std::chrono::milliseconds timeout) { + if (handle_ == -1) return std::unexpected("listener is closed"); + const auto socket = static_cast(handle_); + const auto deadline = Clock::now() + timeout; + for (;;) { + const auto ready = wait_socket(socket, false, deadline); + if (!ready) return std::unexpected(ready.error()); + if (!*ready) return std::nullopt; + const auto client = ::accept(socket, nullptr, nullptr); + if (client != INVALID) { + TcpStream stream{static_cast(client)}; + if (!configure(client)) return std::unexpected("cannot configure accepted loopback stream"); + return std::optional{std::move(stream)}; + } + const auto error = socket_error(); + if (!retryable(error)) return std::unexpected(socket_error_text(error)); + } +} + +} // namespace xlings::platform diff --git a/modules/platform/src/net/tcp.cppm b/modules/platform/src/net/tcp.cppm new file mode 100644 index 000000000..d3dfa25db --- /dev/null +++ b/modules/platform/src/net/tcp.cppm @@ -0,0 +1,58 @@ +export module xlings.platform:tcp; + +import std; + +// The bounded loopback TCP boundary, used by development fixtures. There is +// deliberately no arbitrary host/address API: this cannot access an external +// network. Every socket is noninheritable and released by its owning object. +export namespace xlings::platform { + +class TcpStream { +private: + std::intptr_t handle_ { -1 }; + explicit TcpStream(std::intptr_t handle); + friend class LoopbackListener; + +public: + TcpStream() = default; + TcpStream(TcpStream&& other) noexcept; + TcpStream& operator=(TcpStream&& other) noexcept; + TcpStream(const TcpStream&) = delete; + ~TcpStream(); + + static std::expected + connect_loopback(std::uint16_t port, std::chrono::milliseconds timeout = std::chrono::seconds(2)); + // 0 means orderly EOF; a timeout is an error, never EOF. + std::expected + receive(std::span bytes, std::chrono::milliseconds timeout); + // nullopt means the bounded wait expired; a value of zero means EOF. + std::expected, std::string> + poll_receive(std::span bytes, std::chrono::milliseconds timeout); + std::expected + send_all(std::string_view bytes, std::chrono::milliseconds timeout); + void close() noexcept; +}; + +class LoopbackListener { +private: + std::intptr_t handle_ { -1 }; + std::uint16_t port_ { 0 }; + LoopbackListener(std::intptr_t handle, std::uint16_t port); + +public: + LoopbackListener() = default; + LoopbackListener(LoopbackListener&& other) noexcept; + LoopbackListener& operator=(LoopbackListener&& other) noexcept; + LoopbackListener(const LoopbackListener&) = delete; + ~LoopbackListener(); + + // Port 0 reserves an ephemeral port in the kernel, without a bind race. + static std::expected open(std::uint16_t port = 0); + std::uint16_t port() const; + // nullopt is the timeout; other failures report their cause. + std::expected, std::string> + accept(std::chrono::milliseconds timeout); + void close() noexcept; +}; + +} // namespace xlings::platform diff --git a/modules/platform/src/platform/linux.cpp b/modules/platform/src/os/linux.cpp similarity index 100% rename from modules/platform/src/platform/linux.cpp rename to modules/platform/src/os/linux.cpp diff --git a/modules/platform/src/platform/linux.cppm b/modules/platform/src/os/linux.cppm similarity index 93% rename from modules/platform/src/platform/linux.cppm rename to modules/platform/src/os/linux.cppm index 181798974..1bc433f4a 100644 --- a/modules/platform/src/platform/linux.cppm +++ b/modules/platform/src/os/linux.cppm @@ -1,14 +1,6 @@ module; -#include -#include -#if defined(__linux__) -#include -#include -#include -#include -#include -#endif +#include // stdout, for println below export module xlings.platform:linux; diff --git a/modules/platform/src/platform/macos.cpp b/modules/platform/src/os/macos.cpp similarity index 100% rename from modules/platform/src/platform/macos.cpp rename to modules/platform/src/os/macos.cpp diff --git a/modules/platform/src/platform/macos.cppm b/modules/platform/src/os/macos.cppm similarity index 91% rename from modules/platform/src/platform/macos.cppm rename to modules/platform/src/os/macos.cppm index 0207b44e3..954acd60d 100644 --- a/modules/platform/src/platform/macos.cppm +++ b/modules/platform/src/os/macos.cppm @@ -1,16 +1,6 @@ module; -#include -#include -#if defined(__APPLE__) -#include -#include -#include -#include -#include -#include -#include -#endif +#include // stdout, for println below export module xlings.platform:macos; diff --git a/modules/platform/src/platform/unix.cpp b/modules/platform/src/os/unix.cpp similarity index 100% rename from modules/platform/src/platform/unix.cpp rename to modules/platform/src/os/unix.cpp diff --git a/modules/platform/src/platform/unix.cppm b/modules/platform/src/os/unix.cppm similarity index 94% rename from modules/platform/src/platform/unix.cppm rename to modules/platform/src/os/unix.cppm index 553063527..a941207d2 100644 --- a/modules/platform/src/platform/unix.cppm +++ b/modules/platform/src/os/unix.cppm @@ -1,24 +1,5 @@ module; -#include -#include -#include -#if defined(__linux__) || defined(__APPLE__) -#include -#include -#include -#include -#include -#include -#include -#endif -#if defined(__linux__) -#include -#ifndef RENAME_EXCHANGE -#define RENAME_EXCHANGE (1u << 1) // ; defined here to avoid header clashes -#endif -#endif - export module xlings.platform:unix; import std; diff --git a/modules/platform/src/platform/windows.cpp b/modules/platform/src/os/windows.cpp similarity index 96% rename from modules/platform/src/platform/windows.cpp rename to modules/platform/src/os/windows.cpp index 2ca81a0da..f0d0f1c14 100644 --- a/modules/platform/src/platform/windows.cpp +++ b/modules/platform/src/os/windows.cpp @@ -4,6 +4,7 @@ module; #include #if defined(_WIN32) #define NOMINMAX +#define WIN32_LEAN_AND_MEAN #include #endif @@ -18,6 +19,20 @@ namespace xlings { namespace platform_impl { +std::string user_ui_language() { + wchar_t buf[LOCALE_NAME_MAX_LENGTH] {}; + const int n = ::GetUserDefaultLocaleName(buf, LOCALE_NAME_MAX_LENGTH); + if (n <= 0) return {}; + // The tag is ASCII by definition, so a narrowing copy is exact and + // avoids dragging a codepage conversion in for four characters. + std::string out; + out.reserve(static_cast(n)); + for (int i = 0; i < n && buf[i] != L'\0'; ++i) { + out += static_cast(buf[i] & 0x7F); + } + return out; + } + std::filesystem::path get_executable_path() { wchar_t buf[MAX_PATH]; DWORD n = ::GetModuleFileNameW(nullptr, buf, MAX_PATH); diff --git a/modules/platform/src/platform/windows.cppm b/modules/platform/src/os/windows.cppm similarity index 91% rename from modules/platform/src/platform/windows.cppm rename to modules/platform/src/os/windows.cppm index de7c21272..da6fda188 100644 --- a/modules/platform/src/platform/windows.cppm +++ b/modules/platform/src/os/windows.cppm @@ -1,11 +1,6 @@ module; -#include -#include -#if defined(_WIN32) -#define NOMINMAX -#include -#endif +#include // stdout, for println below export module xlings.platform:windows; @@ -22,19 +17,7 @@ namespace platform_impl { // Windows has no LANG/LC_* to read, and `std::locale("")` is the wrong // tool everywhere (see get_system_language). GetUserDefaultLocaleName is // the documented way to ask, available since Vista. - export inline std::string user_ui_language() { - wchar_t buf[LOCALE_NAME_MAX_LENGTH] {}; - const int n = ::GetUserDefaultLocaleName(buf, LOCALE_NAME_MAX_LENGTH); - if (n <= 0) return {}; - // The tag is ASCII by definition, so a narrowing copy is exact and - // avoids dragging a codepage conversion in for four characters. - std::string out; - out.reserve(static_cast(n)); - for (int i = 0; i < n && buf[i] != L'\0'; ++i) { - out += static_cast(buf[i] & 0x7F); - } - return out; - } + export std::string user_ui_language(); export class FileLock { public: @@ -57,7 +40,9 @@ namespace platform_impl { void release(); private: - HANDLE handle_ { INVALID_HANDLE_VALUE }; + // A HANDLE, stored opaque so this interface needs no ; + // the sentinel is INVALID_HANDLE_VALUE's value, (HANDLE)-1. + void* handle_ { reinterpret_cast(static_cast(-1)) }; }; export constexpr char PATH_SEPARATOR = ';'; diff --git a/modules/platform/src/os/windows_syslibs.cpp b/modules/platform/src/os/windows_syslibs.cpp new file mode 100644 index 000000000..948569425 --- /dev/null +++ b/modules/platform/src/os/windows_syslibs.cpp @@ -0,0 +1,6 @@ +#if defined(_WIN32) && !defined(__CYGWIN__) +#pragma comment(lib, "bcrypt.lib") +// run_elevated (ShellExecuteExW) and is_elevated (OpenProcessToken). +#pragma comment(lib, "shell32.lib") +#pragma comment(lib, "advapi32.lib") +#endif diff --git a/modules/platform/src/platform.cpp b/modules/platform/src/platform.cpp index 4d3529621..d6f4f4f7f 100644 --- a/modules/platform/src/platform.cpp +++ b/modules/platform/src/platform.cpp @@ -2,7 +2,15 @@ module; #include #include +#include +#include +#if defined(__linux__) +#include +#elif defined(__APPLE__) +#include +#endif #if !defined(_WIN32) +#include #include #include #include @@ -10,8 +18,11 @@ module; #else #include #include +#include #define NOMINMAX +#define WIN32_LEAN_AND_MEAN #include +#include #endif module xlings.platform; @@ -24,6 +35,14 @@ namespace platform { std::string gRundir = std::filesystem::current_path().string(); +bool remove_empty_directory(const std::filesystem::path& path) { +#if defined(_WIN32) + return ::RemoveDirectoryW(path.c_str()) != 0; +#else + return ::rmdir(path.c_str()) == 0; +#endif +} + #if !defined(_WIN32) #endif @@ -44,10 +63,6 @@ std::string gRundir = std::filesystem::current_path().string(); user ? std::string{user} : std::string{} }; } -[[nodiscard]] std::string priv_prefix() { - return platform_impl::is_root() ? std::string{} : std::string{"sudo "}; - } - [[nodiscard]] std::optional sudo_invoker() { if (!platform_impl::is_root()) return std::nullopt; return parse_sudo_env(); @@ -437,6 +452,95 @@ int run_shell_command(std::string_view command, bool interactive) { #endif } +bool is_elevated() { +#if defined(_WIN32) + HANDLE token = nullptr; + if (!::OpenProcessToken(::GetCurrentProcess(), TOKEN_QUERY, &token)) return false; + TOKEN_ELEVATION elevation{}; + DWORD size = 0; + const bool ok = ::GetTokenInformation(token, TokenElevation, &elevation, sizeof(elevation), &size); + ::CloseHandle(token); + return ok && elevation.TokenIsElevated != 0; +#else + return platform_impl::is_root(); +#endif +} + +int run_elevated(const std::vector& argv) { + if (argv.empty()) return 127; + if (is_elevated()) return run_argv(argv); +#if defined(_WIN32) + std::wstring parameters; + for (std::size_t i = 1; i < argv.size(); ++i) { + if (!parameters.empty()) parameters += L' '; + parameters += std::filesystem::path(shell_quote(argv[i])).wstring(); + } + const auto file = std::filesystem::path(argv[0]).wstring(); + SHELLEXECUTEINFOW info{}; + info.cbSize = sizeof(info); + info.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC; + info.lpVerb = L"runas"; + info.lpFile = file.c_str(); + info.lpParameters = parameters.c_str(); + info.nShow = SW_HIDE; + if (!::ShellExecuteExW(&info) || !info.hProcess) { + auto err = ::GetLastError(); + return (err == ERROR_FILE_NOT_FOUND || err == ERROR_PATH_NOT_FOUND) ? 127 : 126; + } + ::WaitForSingleObject(info.hProcess, INFINITE); + DWORD exitCode = 126; + ::GetExitCodeProcess(info.hProcess, &exitCode); + ::CloseHandle(info.hProcess); + return static_cast(exitCode); +#else + std::vector withSudo{"sudo", "--"}; + withSudo.insert(withSudo.end(), argv.begin(), argv.end()); + return run_argv(withSudo); +#endif +} + +int run_argv(const std::vector& argv) { + if (argv.empty()) return 127; + std::cout.flush(); + std::cerr.flush(); +#if defined(_WIN32) + std::string commandLine; + for (const auto& arg : argv) { + if (!commandLine.empty()) commandLine += ' '; + commandLine += shell_quote(arg); + } + STARTUPINFOA startup{}; + startup.cb = sizeof(startup); + PROCESS_INFORMATION process{}; + if (!::CreateProcessA(nullptr, commandLine.data(), nullptr, nullptr, + TRUE, 0, nullptr, nullptr, &startup, &process)) { + auto err = ::GetLastError(); + return (err == ERROR_FILE_NOT_FOUND || err == ERROR_PATH_NOT_FOUND) ? 127 : 126; + } + ::WaitForSingleObject(process.hProcess, INFINITE); + DWORD exitCode = 127; + ::GetExitCodeProcess(process.hProcess, &exitCode); + ::CloseHandle(process.hThread); + ::CloseHandle(process.hProcess); + return static_cast(exitCode); +#else + std::vector raw; + for (const auto& a : argv) raw.push_back(const_cast(a.c_str())); + raw.push_back(nullptr); + const auto pid = ::fork(); + if (pid < 0) return 126; + if (pid == 0) { + ::execvp(raw[0], raw.data()); + ::_exit(errno == ENOENT ? 127 : 126); + } + int status = 0; + if (::waitpid(pid, &status, 0) < 0) return 126; + if (WIFEXITED(status)) return WEXITSTATUS(status); + if (WIFSIGNALED(status)) return 128 + WTERMSIG(status); + return 126; +#endif + } + int run_shell(std::string_view command, bool interactive) { #if !defined(_WIN32) if (interactive) return exec_replace_interactive_shell(); @@ -497,6 +601,36 @@ int run_shell(std::string_view command, bool interactive) { return content; } +std::expected rename_no_replace(const std::filesystem::path& from, + const std::filesystem::path& to) { + std::error_code error; +#if defined(__linux__) + constexpr unsigned NO_REPLACE = 1; // renameat2 RENAME_NOREPLACE + if (::syscall(SYS_renameat2, AT_FDCWD, from.c_str(), AT_FDCWD, to.c_str(), NO_REPLACE) != 0) + error = std::error_code(errno, std::generic_category()); +#elif defined(__APPLE__) + if (::renamex_np(from.c_str(), to.c_str(), RENAME_EXCL) != 0) + error = std::error_code(errno, std::generic_category()); +#elif defined(_WIN32) + if (!::MoveFileExW(from.wstring().c_str(), to.wstring().c_str(), MOVEFILE_WRITE_THROUGH)) + error = std::error_code(static_cast(::GetLastError()), std::system_category()); +#else + return std::unexpected("atomic publication without replacement is unavailable on this platform"); +#endif + if (error) return std::unexpected("cannot publish " + to.string() + ": " + error.message()); + return {}; + } + +std::filesystem::path read_symlink(const std::filesystem::path& path, std::error_code& error) { + namespace fs = std::filesystem; + for (int attempt = 0; attempt < 64; ++attempt) { + auto target = fs::read_symlink(path, error); + if (!error || !is_macos || error != std::errc::invalid_argument) return target; + std::this_thread::yield(); + } + return {}; +} + void write_file_atomic(const std::string& filepath, const std::string& content) { namespace fs = std::filesystem; std::error_code ec; @@ -517,43 +651,133 @@ void write_file_atomic(const std::string& filepath, const std::string& content) auto dir = target.parent_path(); if (dir.empty()) dir = fs::path("."); - auto staging = dir / ("." + target.filename().string() + ".xlings-tmp." + - std::to_string(platform_impl::get_pid())); - - std::FILE* fp = std::fopen(staging.string().c_str(), "wb"); - if (!fp) { - throw std::runtime_error("Failed to write file: " + filepath); + fs::path staging; + std::FILE* fp = nullptr; + std::random_device random; + for (int attempt = 0; attempt < 16; ++attempt) { + staging = dir / ("." + target.filename().string() + ".xlings-tmp." + + std::to_string(platform_impl::get_pid()) + "." + + std::format("{:08x}{:08x}", random(), random())); +#if defined(_WIN32) + const int fd = ::_wopen(staging.wstring().c_str(), + _O_WRONLY | _O_CREAT | _O_EXCL | _O_BINARY | _O_NOINHERIT, _S_IREAD | _S_IWRITE); +#else + const int fd = ::open(staging.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0666); +#endif + if (fd < 0) { + if (errno == EEXIST) continue; + throw std::runtime_error("Failed to create staging file: " + filepath); + } +#if defined(_WIN32) + fp = ::_fdopen(fd, "wb"); + if (!fp) ::_close(fd); +#else + fp = ::fdopen(fd, "wb"); + if (!fp) ::close(fd); +#endif + if (!fp) { + fs::remove(staging, ec); + throw std::runtime_error("Failed to open staging file: " + filepath); + } + break; } + if (!fp) throw std::runtime_error("Failed to create unique staging file: " + filepath); bool ok = std::fwrite(content.data(), 1, content.size(), fp) == content.size(); if (ok) ok = std::fflush(fp) == 0; - if (ok) ok = sync_file_handle_(fp); - std::fclose(fp); + if (ok) { +#if defined(_WIN32) + ok = ::_commit(::_fileno(fp)) == 0; +#else + ok = ::fsync(::fileno(fp)) == 0; +#endif + } + if (std::fclose(fp) != 0) ok = false; if (!ok) { ec.clear(); fs::remove(staging, ec); throw std::runtime_error("Failed to write file: " + filepath); } - // Carry over the destination's mode. A fresh staging file is created - // under the current umask, so without this an overwrite would quietly - // drop an executable bit or widen the mode of the file it replaces. ec.clear(); if (auto st = fs::status(target, ec); !ec && fs::exists(st)) { std::error_code permEc; - fs::permissions(staging, st.permissions(), - fs::perm_options::replace, permEc); + fs::permissions(staging, st.permissions(), fs::perm_options::replace, permEc); + if (permEc) { + fs::remove(staging, ec); + throw std::runtime_error("Failed to preserve file permissions: " + filepath); + } } ec.clear(); +#if defined(_WIN32) + if (!::MoveFileExW(staging.wstring().c_str(), target.wstring().c_str(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) + ec = std::error_code(static_cast(::GetLastError()), std::system_category()); +#else fs::rename(staging, target, ec); +#endif if (ec) { std::error_code rmEc; fs::remove(staging, rmEc); throw std::runtime_error("Failed to write file: " + filepath); } - sync_directory_(dir); + sync_directory(dir); } +bool sync_file(const std::filesystem::path& path) { +#if defined(_WIN32) + const int fd = ::_wopen(path.wstring().c_str(), _O_RDWR | _O_BINARY); + if (fd < 0) return false; + const bool ok = ::_commit(fd) == 0; + ::_close(fd); + return ok; +#else + const int fd = ::open(path.c_str(), O_RDONLY | O_CLOEXEC); + if (fd < 0) return false; + const bool ok = ::fsync(fd) == 0; + ::close(fd); + return ok; +#endif +} + +void sync_directory(const std::filesystem::path& dir) { +#if !defined(_WIN32) + const int fd = ::open(dir.c_str(), O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (fd < 0) return; + ::fsync(fd); + ::close(fd); +#else + (void)dir; // no directory-handle fsync equivalent on Windows +#endif +} + +std::optional change_stamp(const std::filesystem::path& path) { +#if defined(_WIN32) + (void)path; + return std::nullopt; +#else + struct stat st {}; + if (::lstat(path.c_str(), &st) != 0) return std::nullopt; +#if defined(__APPLE__) + return ChangeStamp{static_cast(st.st_ino), static_cast(st.st_ctimespec.tv_sec), + static_cast(st.st_ctimespec.tv_nsec)}; +#else + return ChangeStamp{static_cast(st.st_ino), static_cast(st.st_ctim.tv_sec), + static_cast(st.st_ctim.tv_nsec)}; +#endif +#endif +} + +std::tm local_time(std::time_t t) { + std::tm tm{}; +#if defined(_WIN32) + ::localtime_s(&tm, &t); +#else + ::localtime_r(&t, &tm); +#endif + return tm; +} + void write_string_to_file(const std::string& filepath, const std::string& content) { write_file_atomic(filepath, content); } diff --git a/modules/platform/src/platform.cppm b/modules/platform/src/platform.cppm index 54c94e013..7cd2c2d5d 100644 --- a/modules/platform/src/platform.cppm +++ b/modules/platform/src/platform.cppm @@ -1,16 +1,7 @@ module; -#include -#include -#if !defined(_WIN32) -#include -#include -#include -#else -#include -#define NOMINMAX -#include -#endif +// The primary interface declares; it includes nothing. System headers live +// in the implementation unit that calls them (AGENTS.md, "System headers"). export module xlings.platform; @@ -21,10 +12,47 @@ export import :macos; export import :windows; // Shared POSIX implementations (linux + macos). Empty TU on Windows. export import :unix; +// Processes, signals, descriptors and local sockets (a supervisor's needs). +export import :process; +export import :tcp; +export import :network; +export import :net_notify; +export import :asset_paths; +export import :machine_etc; +export import :domain_mount; +// The kernel's isolation mechanisms: namespaces, Landlock, seccomp, beneath. +export import :isolation; namespace xlings { namespace platform { + // The platform this binary was built for, as constants: `if constexpr` + // rather than `#if` wherever both branches compile on every platform. + // `#if` stays for what only exists on one (headers, system calls). +#if defined(_WIN32) + export inline constexpr bool is_windows = true; +#else + export inline constexpr bool is_windows = false; +#endif +#if defined(__APPLE__) + export inline constexpr bool is_macos = true; +#else + export inline constexpr bool is_macos = false; +#endif +#if defined(__linux__) + export inline constexpr bool is_linux = true; +#else + export inline constexpr bool is_linux = false; +#endif + export inline constexpr bool is_posix = !is_windows; + // What a file name needs to be an executable here: ".exe" on Windows. + export inline constexpr std::string_view exe_suffix = is_windows ? ".exe" : ""; + // The null device, as a shell redirection target. + export inline constexpr std::string_view null_device = is_windows ? "NUL" : "/dev/null"; + // The calendar fields of `t` in the local time zone (localtime_r / _s). + export std::tm local_time(std::time_t t); + export bool remove_empty_directory(const std::filesystem::path& path); + export using platform_impl::PATH_SEPARATOR; export using platform_impl::OS_NAME; export using platform_impl::clear_console; @@ -85,12 +113,6 @@ namespace platform { // unless both numeric ids are present and well-formed. export [[nodiscard]] std::optional parse_sudo_env(); - // Shell-command prefix for privileged ops (mount/umount/chown): - // "" when already root (sudo is redundant and often absent in minimal - // root containers), "sudo " otherwise — identical to the historical - // hardcoded string for the non-root case. - export [[nodiscard]] std::string priv_prefix(); - // The invoking user iff launched via sudo (root EUID + SUDO_* set). // nullopt for pure root (no demotion target) and unprivileged runs. export [[nodiscard]] std::optional sudo_invoker(); @@ -234,6 +256,24 @@ namespace platform { // POSIX, everything else spawns and waits. export int run_shell(std::string_view command, bool interactive); + // Run an argv -- no shell, nothing to quote for one -- with this process's + // stdio and environment, and return its exit code: 127 when the program + // is not found, 126 when it cannot be executed, 128+n for signal n (the + // `subos exec` exit-code table, design §12.2). + export int run_argv(const std::vector& argv); + + // Run `argv` with administrator rights, waiting for it (SubOS design + // part 3 §6.4, "Elevation"): directly when this process already has + // them; otherwise `sudo` on Linux and macOS (the terminal asks), and the + // UAC prompt on Windows (ShellExecuteEx "runas"). No shell: argv is + // passed as given. 126 when elevation could not be started, 127 when the + // program does not exist. Callers record what they elevated + // (xlings.subos.elevation); this function only runs it. + export int run_elevated(const std::vector& argv); + + // Whether this process already runs with administrator rights. + export bool is_elevated(); + // Escape a single argument for safe embedding in a shell command string. export [[nodiscard]] std::string shell_quote(const std::string& arg); @@ -254,32 +294,39 @@ namespace platform { // the whole new one, and an interruption before the rename leaves the // old content untouched. - // fsync the data we just wrote. Without it the rename can reach the disk - // before the staging file's contents do, which on a power loss yields an - // atomically-renamed *empty* file — the exact failure we are removing. - inline bool sync_file_handle_(std::FILE* fp) { -#if defined(_WIN32) - return ::_commit(::_fileno(fp)) == 0; -#else - return ::fsync(::fileno(fp)) == 0; -#endif - } + // Flush a file's data to stable storage (fsync; _commit on Windows). + // Without it a rename can reach the disk before the staged contents do, + // which on a power loss yields an atomically-renamed *empty* file. + export bool sync_file(const std::filesystem::path& path); + + // Flush a directory so a rename or link created in it is durable. + // Best-effort: Windows has no directory-handle fsync, and some file + // systems refuse it; neither makes the result less correct than the + // non-durable write it hardens. + export void sync_directory(const std::filesystem::path& dir); + + // What changes whenever an entry's own metadata or a directory's entries + // change: inode and ctime, not following a final symlink (lstat). ctime + // cannot be set back by a user, so an equal stamp means "not touched + // since". nullopt where it cannot be read, and always on Windows (no + // root projection exists there). + export struct ChangeStamp { + std::uint64_t inode { 0 }; + std::int64_t seconds { 0 }; + std::int64_t nanoseconds { 0 }; + bool operator==(const ChangeStamp&) const = default; + }; + export std::optional change_stamp(const std::filesystem::path& path); - // fsync the directory so the rename itself is durable. Best-effort: not - // all filesystems support it, and failing here does not make the result - // any less correct than the non-atomic write it replaces. - inline void sync_directory_(const std::filesystem::path& dir) { -#if !defined(_WIN32) - int fd = ::open(dir.c_str(), O_RDONLY | O_DIRECTORY); - if (fd < 0) return; - ::fsync(fd); - ::close(fd); -#else - (void)dir; // no directory-handle fsync equivalent on Windows -#endif - } + // Publish a same-filesystem scratch atomically, refusing every existing destination. + // No copy or check-then-rename fallback when the OS cannot enforce this. + export std::expected rename_no_replace( + const std::filesystem::path& from, const std::filesystem::path& to); export void write_file_atomic(const std::string& filepath, const std::string& content); + // Acquire a link target once; macOS may return EINVAL while its symlink + // vnode is being replaced. Persistent errors remain errors. + export std::filesystem::path read_symlink(const std::filesystem::path& path, std::error_code& error); export void write_string_to_file(const std::string& filepath, const std::string& content); diff --git a/modules/platform/src/platform/windows_syslibs.cpp b/modules/platform/src/platform/windows_syslibs.cpp deleted file mode 100644 index e7de29e30..000000000 --- a/modules/platform/src/platform/windows_syslibs.cpp +++ /dev/null @@ -1,3 +0,0 @@ -#if defined(_WIN32) && !defined(__CYGWIN__) -#pragma comment(lib, "bcrypt.lib") -#endif diff --git a/modules/platform/src/process/process.cpp b/modules/platform/src/process/process.cpp new file mode 100644 index 000000000..0aacf6aed --- /dev/null +++ b/modules/platform/src/process/process.cpp @@ -0,0 +1,641 @@ +module; + +#include +#include +#include +#include +#include +#include +#if defined(__linux__) || defined(__APPLE__) +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#if defined(__APPLE__) +#include +#define XLINGS_ENVIRON (*_NSGetEnviron()) +#else +// glibc and musl declare environ in under _GNU_SOURCE +#define XLINGS_ENVIRON environ +#endif +#elif defined(_WIN32) +#include +#define NOMINMAX +#define WIN32_LEAN_AND_MEAN +#include +#endif + +module xlings.platform; + +import std; + +namespace xlings::platform { + +namespace sig { +#if defined(_WIN32) +extern const int interrupt = 2, quit = 3, terminate = 15, hangup = 1, pipe = 13, kill = 9, child = 17; +#else +extern const int interrupt = SIGINT, quit = SIGQUIT, terminate = SIGTERM, hangup = SIGHUP, + pipe = SIGPIPE, kill = SIGKILL, child = SIGCHLD; +#endif +} // namespace sig + +int exit_code(const ExitStatus& s) { + return s.exited ? s.code : 128 + s.signal; +} + +int last_error() { return errno; } +std::string error_text(int error) { return std::strerror(error); } +bool is_not_found(int error) { return error == ENOENT; } +bool is_not_executable(int error) { return error == EACCES || error == ENOEXEC || error == EISDIR; } + +void close_fds(std::vector& fds) { + for (int fd : fds) close_fd(fd); + fds.clear(); +} + +#if defined(__linux__) || defined(__APPLE__) + +namespace { + +// The descriptor flags Linux sets atomically; macOS sets them after the fact +// (nothing here is threaded between the two calls). +void cloexec(int fd) { if (fd >= 0) ::fcntl(fd, F_SETFD, FD_CLOEXEC); } + +std::vector env_strings(const std::map& env) { + std::vector out; + out.reserve(env.size()); + for (const auto& [k, v] : env) out.push_back(k + "=" + v); + return out; +} + +std::vector c_array(std::vector& v) { + std::vector out; + for (auto& s : v) out.push_back(s.data()); + out.push_back(nullptr); + return out; +} + +ExitStatus decode(int status) { + if (WIFEXITED(status)) return {true, WEXITSTATUS(status), 0}; + if (WIFSIGNALED(status)) return {false, 0, WTERMSIG(status)}; + return {false, 0, 0}; +} + +int g_signal_pipe[2] = {-1, -1}; + +extern "C" void on_routed_signal(int s) { + const int saved = errno; + const unsigned char b = static_cast(s); + (void)::write(g_signal_pipe[1], &b, 1); + errno = saved; +} + +// sockaddr_un holds ~108 bytes; a longer path goes through its directory +// (/proc/self/fd//name on Linux). +struct UnixAddr { + sockaddr_un addr{}; + socklen_t len{}; + int dirfd { -1 }; + ~UnixAddr() { if (dirfd >= 0) ::close(dirfd); } +}; + +bool make_addr(const std::filesystem::path& path, UnixAddr& a) { + a.addr.sun_family = AF_UNIX; + auto s = path.string(); + if (s.size() >= sizeof(a.addr.sun_path)) { +#if defined(__linux__) + a.dirfd = ::open(path.parent_path().c_str(), O_PATH | O_DIRECTORY | O_CLOEXEC); + if (a.dirfd < 0) return false; + s = std::format("/proc/self/fd/{}/{}", a.dirfd, path.filename().string()); + if (s.size() >= sizeof(a.addr.sun_path)) return false; +#elif defined(__APPLE__) + // No /proc/self/fd here: a short link in /tmp to the socket's + // directory, named for it (FNV-1a, the same in every xlings build) and + // for this user. One that is someone else's, or points elsewhere and + // cannot be replaced, is not used. + const auto dir = path.parent_path().lexically_normal().string(); + std::uint64_t h = 1469598103934665603ull; + for (unsigned char c : dir) { h ^= c; h *= 1099511628211ull; } + const auto link = std::format("/tmp/.xlings-{}-{:016x}", ::getuid(), h); + char target[1024]; + const auto n = ::readlink(link.c_str(), target, sizeof(target) - 1); + if (n < 0 || std::string_view(target, static_cast(n)) != dir) { + struct stat st {}; + if (::lstat(link.c_str(), &st) == 0) { + if (st.st_uid != ::getuid() || ::unlink(link.c_str()) != 0) return false; + } + if (::symlink(dir.c_str(), link.c_str()) != 0) return false; + } + s = link + "/" + path.filename().string(); + if (s.size() >= sizeof(a.addr.sun_path)) return false; +#else + return false; +#endif + } + std::memcpy(a.addr.sun_path, s.c_str(), s.size() + 1); + a.len = static_cast(offsetof(sockaddr_un, sun_path) + s.size() + 1); + return true; +} + +// One message per datagram. Linux: SOCK_SEQPACKET keeps the boundary. macOS +// has no SEQPACKET for AF_UNIX: a stream, each message framed by a 4-byte +// big-endian length, its descriptors riding on the frame's header. +#if defined(__APPLE__) +constexpr int kMessageSocket = SOCK_STREAM; +#else +constexpr int kMessageSocket = SOCK_SEQPACKET; +#endif + +int seqpacket_socket() { + const int fd = ::socket(AF_UNIX, kMessageSocket, 0); + cloexec(fd); + return fd; +} + +} // namespace + +bool send_signal(int pid, int signal) { return pid > 0 && ::kill(pid, signal) == 0; } +bool send_signal_group(int pgid, int signal) { return pgid > 0 && ::kill(-pgid, signal) == 0; } + +int route_signals(std::initializer_list forward, std::initializer_list ignore) { + if (g_signal_pipe[0] < 0) { + if (::pipe(g_signal_pipe) != 0) return -1; + for (int fd : g_signal_pipe) { + cloexec(fd); + ::fcntl(fd, F_SETFL, ::fcntl(fd, F_GETFL) | O_NONBLOCK); + } + } + struct sigaction sa{}; + sa.sa_handler = on_routed_signal; + sigemptyset(&sa.sa_mask); + for (int s : forward) ::sigaction(s, &sa, nullptr); + ignore_signals(ignore); + return g_signal_pipe[0]; +} + +std::vector pending_signals() { + std::vector out; + unsigned char b; + while (g_signal_pipe[0] >= 0 && ::read(g_signal_pipe[0], &b, 1) == 1) out.push_back(b); + return out; +} + +void ignore_signals(std::initializer_list signals) { + struct sigaction ign{}; + ign.sa_handler = SIG_IGN; + for (int s : signals) ::sigaction(s, &ign, nullptr); +} + +void reset_signals() { + for (int s : {SIGINT, SIGQUIT, SIGTERM, SIGHUP, SIGPIPE, SIGCHLD}) ::signal(s, SIG_DFL); + sigset_t none; + sigemptyset(&none); + ::sigprocmask(SIG_SETMASK, &none, nullptr); +} + +int fork_process() { + std::fflush(nullptr); + return static_cast(::fork()); +} + +void exit_now(int code) { ::_exit(code); } + +int exec_path(const std::vector& argv, const std::map& env) { + if (argv.empty()) return EINVAL; + auto args = argv; + auto envs = env_strings(env); + auto av = c_array(args); + auto ev = c_array(envs); + ::execve(av[0], av.data(), ev.data()); + return errno; +} + +int exec_program(const std::vector& argv, const std::map& env) { + if (argv.empty()) return EINVAL; + if (argv[0].find('/') != std::string::npos) return exec_path(argv, env); + auto args = argv; + auto envs = env_strings(env); + auto av = c_array(args); + auto ev = c_array(envs); + std::string path = "/usr/local/bin:/usr/bin:/bin"; + if (auto it = env.find("PATH"); it != env.end()) path = it->second; + int saved = ENOENT; + std::size_t start = 0; + while (start <= path.size()) { + auto end = path.find(':', start); + if (end == std::string::npos) end = path.size(); + auto dir = path.substr(start, end - start); + if (dir.empty()) dir = "."; + const auto candidate = dir + "/" + argv[0]; + ::execve(candidate.c_str(), av.data(), ev.data()); + if (errno == EACCES) saved = EACCES; + else if (errno != ENOENT && errno != ENOTDIR) saved = errno; + start = end + 1; + } + return saved; +} + +std::optional wait_process(int pid, bool block) { + int status = 0; + pid_t r; + do { r = ::waitpid(pid, &status, block ? 0 : WNOHANG); } while (r < 0 && errno == EINTR); + if (r != pid) return std::nullopt; + return decode(status); +} + +std::optional> reap_child() { + int status = 0; + const pid_t r = ::waitpid(-1, &status, WNOHANG); + if (r <= 0) return std::nullopt; + return std::pair{static_cast(r), decode(status)}; +} + +bool new_session() { return ::setsid() >= 0; } +bool take_controlling_terminal() { return ::ioctl(0, TIOCSCTTY, 0) == 0; } + +void redirect_stdio(std::span fds) { + for (std::size_t i = 0; i < 3 && i < fds.size(); ++i) ::dup2(fds[i], static_cast(i)); + for (int fd : fds) if (fd > 2) ::close(fd); +} + +int run_argv_with_timeout(const std::vector& argv, std::chrono::milliseconds limit) { + if (argv.empty()) return 127; + const int pid = fork_process(); + if (pid < 0) return 126; + if (pid == 0) { + ::setpgid(0, 0); + const int e = exec_program(argv, environment()); + ::_exit(e == ENOENT ? 127 : 126); + } + const auto deadline = std::chrono::steady_clock::now() + limit; + while (true) { + if (auto s = wait_process(pid, false)) return exit_code(*s); + if (std::chrono::steady_clock::now() >= deadline) { + send_signal_group(pid, SIGTERM); + std::this_thread::sleep_for(std::chrono::seconds(2)); + send_signal_group(pid, SIGKILL); + (void)wait_process(pid, true); + return 124; + } + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } +} + +UserIds user_ids() { + return {static_cast(::getuid()), static_cast(::getgid()), + static_cast(::geteuid())}; +} + +bool stdout_is_terminal() { return ::isatty(1) == 1; } + +std::map environment() { + std::map env; + for (char** e = XLINGS_ENVIRON; e && *e; ++e) { + std::string_view kv(*e); + if (auto eq = kv.find('='); eq != std::string_view::npos) + env[std::string(kv.substr(0, eq))] = std::string(kv.substr(eq + 1)); + } + return env; +} + +void unset_env_variable(const std::string& name) { ::unsetenv(name.c_str()); } + +bool set_inheritable(int fd, bool inheritable) { + return fd >= 0 && ::fcntl(fd, F_SETFD, inheritable ? 0 : FD_CLOEXEC) == 0; +} + +std::optional> make_pipe(bool close_on_exec) { + int p[2]; + if (::pipe(p) != 0) return std::nullopt; + if (close_on_exec) { cloexec(p[0]); cloexec(p[1]); } + return std::array{p[0], p[1]}; +} + +bool read_exact(int fd, void* buf, std::size_t size) { + auto* p = static_cast(buf); + while (size > 0) { + const auto n = ::read(fd, p, size); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) return false; + p += n; + size -= static_cast(n); + } + return true; +} + +int open_null() { + const int fd = ::open("/dev/null", O_RDWR); + cloexec(fd); + return fd; +} + +int open_for_append(const std::filesystem::path& path) { + return ::open(path.c_str(), O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0600); +} + +int poll_fds(std::span fds, int timeout_ms) { + std::vector p; + p.reserve(fds.size()); + for (auto& f : fds) p.push_back({f.fd, POLLIN, 0}); + int n; + do { n = ::poll(p.data(), p.size(), timeout_ms); } while (n < 0 && errno == EINTR); + for (std::size_t i = 0; i < fds.size(); ++i) { + fds[i].readable = (p[i].revents & POLLIN) != 0; + fds[i].closed = (p[i].revents & (POLLHUP | POLLERR | POLLNVAL)) != 0; + } + return n < 0 ? 0 : n; +} + +int unix_listen(const std::filesystem::path& path) { + const int fd = seqpacket_socket(); + if (fd < 0) return -1; + UnixAddr a; + if (!make_addr(path, a)) { ::close(fd); return -1; } + ::unlink(path.c_str()); + const auto old = ::umask(0077); + const int rc = ::bind(fd, reinterpret_cast(&a.addr), a.len); + ::umask(old); + if (rc != 0 || ::listen(fd, 16) != 0) { ::close(fd); return -1; } + return fd; +} + +int unix_connect(const std::filesystem::path& path) { + const int fd = seqpacket_socket(); + if (fd < 0) return -1; + UnixAddr a; + if (!make_addr(path, a) || ::connect(fd, reinterpret_cast(&a.addr), a.len) != 0) { + ::close(fd); + return -1; + } + return fd; +} + +int unix_accept(int listen_fd) { + const int fd = ::accept(listen_fd, nullptr, nullptr); + cloexec(fd); + return fd; +} + +std::optional> unix_pair() { + int p[2]; + if (::socketpair(AF_UNIX, kMessageSocket, 0, p) != 0) return std::nullopt; + cloexec(p[0]); + cloexec(p[1]); + return std::array{p[0], p[1]}; +} + +bool send_message(int sock, std::string_view data, std::span fds) { +#if defined(__APPLE__) + const auto size = static_cast(data.size()); + unsigned char header[4] = {static_cast(size >> 24), static_cast(size >> 16), + static_cast(size >> 8), static_cast(size)}; + iovec iov{ header, sizeof(header) }; +#else + iovec iov{ const_cast(data.data()), data.size() }; +#endif + msghdr msg{}; + msg.msg_iov = &iov; + msg.msg_iovlen = 1; + std::vector control; + if (!fds.empty()) { + control.resize(CMSG_SPACE(sizeof(int) * fds.size())); + msg.msg_control = control.data(); + msg.msg_controllen = static_cast(control.size()); + cmsghdr* cm = CMSG_FIRSTHDR(&msg); + cm->cmsg_level = SOL_SOCKET; + cm->cmsg_type = SCM_RIGHTS; + cm->cmsg_len = CMSG_LEN(sizeof(int) * fds.size()); + std::memcpy(CMSG_DATA(cm), fds.data(), sizeof(int) * fds.size()); + } +#if defined(MSG_NOSIGNAL) + constexpr int flags = MSG_NOSIGNAL; +#else + constexpr int flags = 0; // macOS: SIGPIPE is ignored by the callers +#endif +#if defined(__APPLE__) + // The header carries the descriptors; the rest follows on the stream. + std::size_t sent = 0; + while (sent < sizeof(header)) { + const auto n = sent == 0 ? ::sendmsg(sock, &msg, flags) : ::send(sock, header + sent, sizeof(header) - sent, flags); + if (n < 0) { if (errno == EINTR) continue; return false; } + sent += static_cast(n); + } + for (std::size_t at = 0; at < data.size();) { + const auto n = ::send(sock, data.data() + at, data.size() - at, flags); + if (n < 0) { if (errno == EINTR) continue; return false; } + at += static_cast(n); + } + return true; +#else + while (true) { + const auto n = ::sendmsg(sock, &msg, flags); + if (n >= 0) return static_cast(n) == data.size(); + if (errno != EINTR) return false; + } +#endif +} + +std::optional receive_message(int sock, std::size_t max) { + constexpr int kMaxFds = 8; +#if defined(__APPLE__) + // The frame's header, with whatever descriptors ride on it. + unsigned char header[4]; + std::size_t got = 0; + Message framed; + while (got < sizeof(header)) { + iovec hiov{ header + got, sizeof(header) - got }; + alignas(cmsghdr) char hcontrol[CMSG_SPACE(sizeof(int) * kMaxFds)]; + msghdr hmsg{}; + hmsg.msg_iov = &hiov; + hmsg.msg_iovlen = 1; + hmsg.msg_control = hcontrol; + hmsg.msg_controllen = sizeof(hcontrol); + const auto n = ::recvmsg(sock, &hmsg, 0); + if (n < 0 && errno == EINTR) continue; + if (n <= 0 || (hmsg.msg_flags & MSG_CTRUNC)) { close_fds(framed.fds); return std::nullopt; } + for (cmsghdr* cm = CMSG_FIRSTHDR(&hmsg); cm; cm = CMSG_NXTHDR(&hmsg, cm)) { + if (cm->cmsg_level != SOL_SOCKET || cm->cmsg_type != SCM_RIGHTS) continue; + const auto count = (cm->cmsg_len - CMSG_LEN(0)) / sizeof(int); + for (std::size_t i = 0; i < count; ++i) { + int fd; + std::memcpy(&fd, CMSG_DATA(cm) + i * sizeof(int), sizeof(int)); + cloexec(fd); + framed.fds.push_back(fd); + } + } + got += static_cast(n); + } + const std::size_t size = (std::size_t{header[0]} << 24) | (std::size_t{header[1]} << 16) + | (std::size_t{header[2]} << 8) | std::size_t{header[3]}; + framed.data.resize(size); + for (std::size_t at = 0; at < size;) { + const auto n = ::recv(sock, framed.data.data() + at, size - at, 0); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) { close_fds(framed.fds); return std::nullopt; } + at += static_cast(n); + } + if (size > max) { close_fds(framed.fds); return std::nullopt; } // as a truncated datagram + return framed; +#endif + std::size_t capacity = max; +#if defined(__linux__) + ssize_t pending; + do { pending = ::recv(sock, nullptr, 0, MSG_PEEK | MSG_TRUNC); } + while (pending < 0 && errno == EINTR); + if (pending < 0) return std::nullopt; + capacity = std::min(max, static_cast(pending)); +#endif + std::vector buf(capacity); + iovec iov{ buf.data(), buf.size() }; + alignas(cmsghdr) char control[CMSG_SPACE(sizeof(int) * kMaxFds)]; + msghdr msg{}; + msg.msg_iov = &iov; + msg.msg_iovlen = 1; + msg.msg_control = control; + msg.msg_controllen = sizeof(control); +#if defined(MSG_CMSG_CLOEXEC) + constexpr int flags = MSG_CMSG_CLOEXEC; +#else + constexpr int flags = 0; +#endif + ssize_t n; + do { n = ::recvmsg(sock, &msg, flags); } while (n < 0 && errno == EINTR); + if (n < 0) return std::nullopt; + Message out; + for (cmsghdr* cm = CMSG_FIRSTHDR(&msg); cm; cm = CMSG_NXTHDR(&msg, cm)) { + if (cm->cmsg_level != SOL_SOCKET || cm->cmsg_type != SCM_RIGHTS) continue; + const auto count = (cm->cmsg_len - CMSG_LEN(0)) / sizeof(int); + for (std::size_t i = 0; i < count; ++i) { + int fd; + std::memcpy(&fd, CMSG_DATA(cm) + i * sizeof(int), sizeof(int)); + if constexpr (flags == 0) cloexec(fd); + out.fds.push_back(fd); + } + } + if (n == 0 || (msg.msg_flags & (MSG_TRUNC | MSG_CTRUNC))) { + close_fds(out.fds); + return std::nullopt; + } + out.data.assign(buf.data(), static_cast(n)); + return out; +} + +#else // no POSIX process model here + +bool send_signal(int, int) { return false; } +bool send_signal_group(int, int) { return false; } +int route_signals(std::initializer_list, std::initializer_list) { return -1; } +std::vector pending_signals() { return {}; } +void ignore_signals(std::initializer_list) {} +void reset_signals() {} +int fork_process() { return -1; } +void exit_now(int code) { std::_Exit(code); } +int exec_program(const std::vector&, const std::map&) { return ENOSYS; } +int exec_path(const std::vector&, const std::map&) { return ENOSYS; } +std::optional wait_process(int, bool) { return std::nullopt; } +std::optional> reap_child() { return std::nullopt; } +bool new_session() { return false; } +bool take_controlling_terminal() { return false; } +void redirect_stdio(std::span) {} +// The Windows process scope (design part 3 §6.3): the program and everything +// it starts in one Job Object that dies with this process (kill-on-close) and +// is ended whole when it runs past `limit` (124). +int run_argv_with_timeout(const std::vector& argv, std::chrono::milliseconds limit) { + if (argv.empty()) return 127; + std::wstring line; + for (const auto& a : argv) { + if (!line.empty()) line += L' '; + const auto w = std::filesystem::path(a).wstring(); + const bool quote = w.empty() || w.find_first_of(L" \t\"") != std::wstring::npos; + if (!quote) { line += w; continue; } + line += L'"'; + std::size_t slashes = 0; + for (wchar_t c : w) { + if (c == L'\\') { ++slashes; continue; } + if (c == L'"') line.append(slashes * 2 + 1, L'\\'); + else line.append(slashes, L'\\'); + slashes = 0; + line += c; + } + line.append(slashes * 2, L'\\'); + line += L'"'; + } + HANDLE job = ::CreateJobObjectW(nullptr, nullptr); + if (!job) return 126; + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{}; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + ::SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits)); + STARTUPINFOW startup{}; + startup.cb = sizeof(startup); + PROCESS_INFORMATION process{}; + if (!::CreateProcessW(nullptr, line.data(), nullptr, nullptr, TRUE, CREATE_SUSPENDED, nullptr, nullptr, + &startup, &process)) { + const auto err = ::GetLastError(); + ::CloseHandle(job); + return (err == ERROR_FILE_NOT_FOUND || err == ERROR_PATH_NOT_FOUND) ? 127 : 126; + } + ::AssignProcessToJobObject(job, process.hProcess); + ::ResumeThread(process.hThread); + ::CloseHandle(process.hThread); + const auto ms = limit.count(); + const DWORD wait = ms <= 0 || ms >= static_cast(INFINITE) ? INFINITE : static_cast(ms); + int rc = 124; + if (::WaitForSingleObject(process.hProcess, wait) == WAIT_OBJECT_0) { + DWORD code = 126; + ::GetExitCodeProcess(process.hProcess, &code); + rc = static_cast(code); + } else { + ::TerminateJobObject(job, 124); + ::WaitForSingleObject(process.hProcess, 5000); + } + ::CloseHandle(process.hProcess); + ::CloseHandle(job); // kill-on-close: nothing it started outlives it + return rc; +} +UserIds user_ids() { return {}; } +bool stdout_is_terminal() { return ::_isatty(1) != 0; } +std::map environment() { + std::map env; + wchar_t* block = ::GetEnvironmentStringsW(); + auto utf8 = [](std::wstring_view w) { + if (w.empty()) return std::string{}; + const int n = ::WideCharToMultiByte(CP_UTF8, 0, w.data(), static_cast(w.size()), + nullptr, 0, nullptr, nullptr); + std::string out(static_cast(n), '\0'); + ::WideCharToMultiByte(CP_UTF8, 0, w.data(), static_cast(w.size()), out.data(), n, + nullptr, nullptr); + return out; + }; + for (const wchar_t* p = block; p && *p; p += std::wcslen(p) + 1) { + std::wstring_view kv(p); + const auto eq = kv.find(L'=', 1); // "=C:=C:\" entries start with '=' + if (eq == std::wstring_view::npos) continue; + env[utf8(kv.substr(0, eq))] = utf8(kv.substr(eq + 1)); + } + if (block) ::FreeEnvironmentStringsW(block); + return env; +} +void unset_env_variable(const std::string& name) { set_env_variable(name, ""); } +bool set_inheritable(int, bool) { return false; } +std::optional> make_pipe(bool) { return std::nullopt; } +bool read_exact(int, void*, std::size_t) { return false; } +int open_null() { return -1; } +int open_for_append(const std::filesystem::path&) { return -1; } +int poll_fds(std::span, int) { return 0; } +int unix_listen(const std::filesystem::path&) { return -1; } +int unix_connect(const std::filesystem::path&) { return -1; } +int unix_accept(int) { return -1; } +std::optional> unix_pair() { return std::nullopt; } +bool send_message(int, std::string_view, std::span) { return false; } +std::optional receive_message(int, std::size_t) { return std::nullopt; } + +#endif + +} // namespace xlings::platform diff --git a/modules/platform/src/process/process.cppm b/modules/platform/src/process/process.cppm new file mode 100644 index 000000000..667bf23ae --- /dev/null +++ b/modules/platform/src/process/process.cppm @@ -0,0 +1,128 @@ +// Processes, signals and the descriptors between them -- the POSIX surface a +// supervisor needs (the SubOS session, its broker), so that nothing outside +// this module includes a system header for it. +// +// Declared on every platform; implemented on POSIX. Elsewhere each call +// reports failure (-1, false, nullopt) and the caller -- which already knows +// sessions are Linux/POSIX-only -- never gets that far. +// +// Signal numbers and errno values cross process boundaries as plain ints +// (in a JSON message, a wait status); the names below are this platform's. +export module xlings.platform:process; + +import std; + +export namespace xlings::platform { + +// ── signals ────────────────────────────────────────────────────────── + +namespace sig { +extern const int interrupt; // SIGINT +extern const int quit; // SIGQUIT +extern const int terminate; // SIGTERM +extern const int hangup; // SIGHUP +extern const int pipe; // SIGPIPE +extern const int kill; // SIGKILL +extern const int child; // SIGCHLD +} // namespace sig + +bool send_signal(int pid, int signal); +// To every process of the group `pgid` leads. +bool send_signal_group(int pgid, int signal); + +// Signals delivered as bytes on a pipe, so a poll loop sees them: `forward` +// are caught, `ignore` ignored. One pipe per process; the descriptor to poll +// is returned (-1 when the pipe could not be made). +int route_signals(std::initializer_list forward, std::initializer_list ignore); +std::vector pending_signals(); +void ignore_signals(std::initializer_list signals); +// In a child before exec: default dispositions, nothing blocked. +void reset_signals(); + +// ── errors ─────────────────────────────────────────────────────────── + +int last_error(); // errno +std::string error_text(int error); +bool is_not_found(int error); // ENOENT +bool is_not_executable(int error); // EACCES, ENOEXEC, EISDIR + +// ── processes ──────────────────────────────────────────────────────── + +struct ExitStatus { + bool exited { false }; + int code { 0 }; // when exited + int signal { 0 }; // when killed by one +}; +// The shell's convention: the code, or 128 + the signal. +int exit_code(const ExitStatus& s); + +// stdio is flushed first, so nothing buffered is written twice. +int fork_process(); +[[noreturn]] void exit_now(int code); +// execvp's search against `env`'s PATH (a name with a `/` is run as is), +// with execvp's errno rule: a permission problem anywhere beats "not found". +// Returns only on failure, with the error. +int exec_program(const std::vector& argv, + const std::map& env); +// Exactly `argv[0]`, no search. +int exec_path(const std::vector& argv, const std::map& env); + +// `block`: wait for it; otherwise nullopt while it runs. +std::optional wait_process(int pid, bool block); +// Any child that has ended, without blocking. +std::optional> reap_child(); + +bool new_session(); // setsid +bool take_controlling_terminal(); // stdin's terminal, TIOCSCTTY +// fds[0..2] become this process's stdin, stdout, stderr. +void redirect_stdio(std::span fds); +// argv with a deadline, its own process group, killed whole when late (124). +int run_argv_with_timeout(const std::vector& argv, std::chrono::milliseconds limit); + +struct UserIds { unsigned uid { 0 }; unsigned gid { 0 }; unsigned euid { 0 }; }; +UserIds user_ids(); + +std::map environment(); +bool stdout_is_terminal(); +void unset_env_variable(const std::string& name); + +// ── descriptors ────────────────────────────────────────────────────── + +void close_fds(std::vector& fds); +bool set_inheritable(int fd, bool inheritable); +// Close-on-exec unless asked otherwise. +std::optional> make_pipe(bool cloexec = true); +bool read_exact(int fd, void* buf, std::size_t size); +int open_null(); +// Owner-only (0600), created when missing, appended to. +int open_for_append(const std::filesystem::path& path); + +struct PollFd { + int fd { -1 }; + bool readable { false }; // set by poll_fds + bool closed { false }; // hang-up or error +}; +// -1 waits forever. Returns how many are ready (0 on timeout). +int poll_fds(std::span fds, int timeout_ms); + +// ── local sockets with descriptors ─────────────────────────────────── +// +// Sequenced packets: one message is one datagram, carried whole, with up to +// eight descriptors. A path longer than sockaddr_un holds is reached through +// its directory's descriptor. + +// Owner-only (umask 077): whoever connects can act on what listens. +int unix_listen(const std::filesystem::path& path); +int unix_connect(const std::filesystem::path& path); +int unix_accept(int listen_fd); +std::optional> unix_pair(); + +struct Message { + std::string data; + std::vector fds; // received descriptors, close-on-exec +}; +bool send_message(int sock, std::string_view data, std::span fds = {}); +// nullopt on end of stream or error. +std::optional receive_message(int sock, std::size_t max = std::size_t{1} << 20); + +} // namespace xlings::platform diff --git a/modules/platform/src/process/stream.cpp b/modules/platform/src/process/stream.cpp new file mode 100644 index 000000000..58043498f --- /dev/null +++ b/modules/platform/src/process/stream.cpp @@ -0,0 +1,242 @@ +module; +#if defined(_WIN32) +#define NOMINMAX +#define WIN32_LEAN_AND_MEAN +#include +#else +#include +#include +#include +#include +#include +#include +#if defined(__linux__) +#include +#endif +#endif + +module xlings.platform.stream; +import std; + +namespace xlings::platform::stream { +namespace { +#if defined(_WIN32) +struct Handle { + HANDLE value{nullptr}; + ~Handle() { if (value && value != INVALID_HANDLE_VALUE) CloseHandle(value); } + void close() { if (value && value != INVALID_HANDLE_VALUE) CloseHandle(value); value = nullptr; } +}; +std::wstring wide(std::string_view text) { + const auto size = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text.data(), + static_cast(text.size()), nullptr, 0); + if (!size && !text.empty()) throw std::runtime_error("invalid UTF-8 process argument"); + std::wstring result(size, L'\0'); + MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text.data(), static_cast(text.size()), + result.data(), size); + return result; +} +std::wstring quote(std::wstring_view value) { + std::wstring result{L"\""}; + std::size_t slashes{}; + for (const auto ch : value) { + if (ch == L'\\') { ++slashes; continue; } + result.append(ch == L'"' ? slashes * 2 + 1 : slashes, L'\\'); + slashes = 0; + result += ch; + } + result.append(slashes * 2, L'\\'); + return result + L'"'; +} +#else +struct Descriptor { + int value{-1}; + ~Descriptor() { close(); } + void close() { if (value >= 0) ::close(value); value = -1; } +}; +#endif +} + +int run(const std::vector& argv, + const std::function& output, + const std::function& cancelled) { + if (argv.empty() || argv.front().empty() || !output) return 125; + for (const auto& word : argv) if (word.find('\0') != std::string::npos) return 125; + std::array buffer{}; + bool was_cancelled{}; +#if defined(_WIN32) + SECURITY_ATTRIBUTES attributes{sizeof(attributes), nullptr, TRUE}; + Handle out, out_write, err, err_write, input, process, thread, job; + if (!CreatePipe(&out.value, &out_write.value, &attributes, 0) + || !CreatePipe(&err.value, &err_write.value, &attributes, 0)) return 125; + if (!SetHandleInformation(out.value, HANDLE_FLAG_INHERIT, 0) + || !SetHandleInformation(err.value, HANDLE_FLAG_INHERIT, 0)) return 125; + input.value = CreateFileW(L"NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, + &attributes, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (input.value == INVALID_HANDLE_VALUE) return 125; + std::wstring command; + for (const auto& word : argv) { + if (!command.empty()) command += L' '; + command += quote(wide(word)); + } + SIZE_T size{}; + InitializeProcThreadAttributeList(nullptr, 1, 0, &size); + std::vector storage(size); + auto* list = reinterpret_cast(storage.data()); + if (!InitializeProcThreadAttributeList(list, 1, 0, &size)) return 125; + struct Attributes { + LPPROC_THREAD_ATTRIBUTE_LIST list; + ~Attributes() { DeleteProcThreadAttributeList(list); } + } attribute_owner{list}; + std::array inherited{input.value, out_write.value, err_write.value}; + if (!UpdateProcThreadAttribute(list, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + inherited.data(), sizeof(inherited), nullptr, nullptr)) return 125; + STARTUPINFOEXW startup{}; + startup.StartupInfo.cb = sizeof(startup); + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = input.value; + startup.StartupInfo.hStdOutput = out_write.value; + startup.StartupInfo.hStdError = err_write.value; + startup.lpAttributeList = list; + job.value = CreateJobObjectW(nullptr, nullptr); + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{}; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if (!job.value || !SetInformationJobObject(job.value, JobObjectExtendedLimitInformation, + &limits, sizeof(limits))) return 125; + PROCESS_INFORMATION info{}; + if (!CreateProcessW(nullptr, command.data(), nullptr, nullptr, TRUE, + EXTENDED_STARTUPINFO_PRESENT | CREATE_SUSPENDED | CREATE_NO_WINDOW, + nullptr, nullptr, &startup.StartupInfo, &info)) + return GetLastError() == ERROR_FILE_NOT_FOUND || GetLastError() == ERROR_PATH_NOT_FOUND ? 127 : 126; + process.value = info.hProcess; + thread.value = info.hThread; + if (!AssignProcessToJobObject(job.value, process.value)) { + TerminateProcess(process.value, 125); + WaitForSingleObject(process.value, INFINITE); + return 125; + } + if (ResumeThread(thread.value) == static_cast(-1)) { + TerminateJobObject(job.value, 125); + WaitForSingleObject(process.value, INFINITE); + return 125; + } + out_write.close(); err_write.close(); input.close(); thread.close(); + try { + for (;;) { + for (auto [handle, name] : {std::pair{out.value, "stdout"}, std::pair{err.value, "stderr"}}) { + for (int round = 0; round < 8; ++round) { + DWORD available{}, count{}; + if (!PeekNamedPipe(handle, nullptr, 0, nullptr, &available, nullptr) || !available) break; + if (!ReadFile(handle, buffer.data(), std::min(available, buffer.size()), &count, nullptr) + || !count) break; + output(name, {buffer.data(), count}); + } + } + if (cancelled && cancelled()) { + was_cancelled = true; + TerminateJobObject(job.value, 130); + } + const auto waited = WaitForSingleObject(process.value, 10); + if (waited == WAIT_FAILED) return 125; + if (waited == WAIT_OBJECT_0) { + // Descendants cannot keep an output pipe open after this invocation. + TerminateJobObject(job.value, was_cancelled ? 130 : 0); + for (auto [handle, name] : {std::pair{out.value, "stdout"}, std::pair{err.value, "stderr"}}) { + for (;;) { + DWORD available{}, count{}; + if (!PeekNamedPipe(handle, nullptr, 0, nullptr, &available, nullptr) || !available) break; + if (!ReadFile(handle, buffer.data(), std::min(available, buffer.size()), &count, nullptr) + || !count) break; + output(name, {buffer.data(), count}); + } + } + DWORD code{}; + if (!GetExitCodeProcess(process.value, &code)) return 125; + return was_cancelled ? 130 : static_cast(code); + } + } + } catch (...) { + TerminateJobObject(job.value, 125); + WaitForSingleObject(process.value, INFINITE); + throw; + } +#else + int out_pipe[2], err_pipe[2]; + if (::pipe(out_pipe) != 0) return 125; + Descriptor out{out_pipe[0]}, out_write{out_pipe[1]}; + if (::pipe(err_pipe) != 0) return 125; + Descriptor err{err_pipe[0]}, err_write{err_pipe[1]}; + Descriptor input{::open("/dev/null", O_RDONLY | O_CLOEXEC)}; + if (input.value < 0) return 125; + for (const auto fd : {out.value, out_write.value, err.value, err_write.value}) + if (::fcntl(fd, F_SETFD, FD_CLOEXEC) < 0) return 125; + for (const auto fd : {out.value, err.value}) + if (::fcntl(fd, F_SETFL, ::fcntl(fd, F_GETFL) | O_NONBLOCK) < 0) return 125; + std::vector arguments; + for (const auto& word : argv) arguments.push_back(const_cast(word.c_str())); + arguments.push_back(nullptr); + const auto limit = ::sysconf(_SC_OPEN_MAX); + if (limit < 0) return 125; + const auto pid = ::fork(); + if (pid < 0) return 125; + if (pid == 0) { + ::setpgid(0, 0); + if (::dup2(input.value, 0) < 0 || ::dup2(out_write.value, 1) < 0 || ::dup2(err_write.value, 2) < 0) + ::_exit(125); +#if defined(__linux__) && defined(SYS_close_range) + if (::syscall(SYS_close_range, 3u, ~0u, 0) != 0) +#endif + for (int fd = 3; fd < limit; ++fd) ::close(fd); + ::execvp(arguments.front(), arguments.data()); + ::_exit(errno == ENOENT ? 127 : 126); + } + ::setpgid(pid, pid); + out_write.close(); err_write.close(); input.close(); + struct Child { + int pid; + bool reaped{}; + ~Child() { + if (!reaped) { + ::kill(-pid, SIGKILL); + int status{}; + while (::waitpid(pid, &status, 0) < 0 && errno == EINTR) {} + } + } + } child{pid}; + int status{}; + std::optional drain_deadline; + for (;;) { + for (auto [descriptor, name] : {std::pair{&out, "stdout"}, std::pair{&err, "stderr"}}) { + for (int round = 0; descriptor->value >= 0 && round < 8; ++round) { + const auto count = ::read(descriptor->value, buffer.data(), buffer.size()); + if (count > 0) output(name, {buffer.data(), static_cast(count)}); + else if (count == 0) { descriptor->close(); break; } + else if (errno == EINTR) continue; + else if (errno == EAGAIN || errno == EWOULDBLOCK) break; + else return 125; + } + } + if (!child.reaped && cancelled && cancelled()) { + was_cancelled = true; + ::kill(-pid, SIGKILL); + } + if (!child.reaped) { + const auto waited = ::waitpid(pid, &status, WNOHANG); + if (waited < 0 && errno != EINTR) return 125; + if (waited == pid) { + child.reaped = true; + ::kill(-pid, SIGKILL); + drain_deadline = std::chrono::steady_clock::now() + std::chrono::seconds(1); + } + } + if (child.reaped && out.value < 0 && err.value < 0) break; + if (drain_deadline && std::chrono::steady_clock::now() >= *drain_deadline) break; + std::array watched{{{out.value, POLLIN, 0}, {err.value, POLLIN, 0}}}; + if (::poll(watched.data(), watched.size(), 20) < 0 && errno != EINTR) return 125; + } + if (was_cancelled) return 130; + if (WIFEXITED(status)) return WEXITSTATUS(status); + if (WIFSIGNALED(status)) return 128 + WTERMSIG(status); + return 125; +#endif +} +} diff --git a/modules/platform/src/process/stream.cppm b/modules/platform/src/process/stream.cppm new file mode 100644 index 000000000..2509ea015 --- /dev/null +++ b/modules/platform/src/process/stream.cppm @@ -0,0 +1,11 @@ +export module xlings.platform.stream; + +import std; + +export namespace xlings::platform::stream { +// Output callbacks receive bounded byte chunks as they arrive, separately +// for stdout and stderr. stdin is closed; the argv never passes through a shell. +int run(const std::vector& argv, + const std::function& output, + const std::function& cancelled = {}); +} diff --git a/modules/platform/src/process/worker.cpp b/modules/platform/src/process/worker.cpp new file mode 100644 index 000000000..e1d4e6270 --- /dev/null +++ b/modules/platform/src/process/worker.cpp @@ -0,0 +1,746 @@ +module; +#include +#if defined(_WIN32) +#define NOMINMAX +#include +#include +#include +#define WIN32_LEAN_AND_MEAN +#include +#else +#include +#include +#include +#if defined(__linux__) +#include +#endif +#include +#include +#include +#include +#endif +module xlings.platform.worker; +import std; +import xlings.platform; +namespace xlings::platform::worker { +namespace { +constexpr std::size_t kMessageLimit = std::size_t{64} << 20; +std::optional number(std::string_view s) { + std::uintptr_t n{}; + auto [end, ec] = std::from_chars(s.data(), s.data() + s.size(), n); + if (ec != std::errc{} || end != s.data() + s.size()) + return std::nullopt; + return n; +} +bool read_all(int fd, void* data, std::size_t size) { + auto* p = static_cast(data); + while (size) { +#if defined(_WIN32) + auto n = ::_read(fd, p, static_cast(std::min(size, std::size_t{1} << 20))); +#else + auto n = ::read(fd, p, size); + if (n < 0 && errno == EINTR) + continue; +#endif + if (n <= 0) + return false; + p += n; + size -= static_cast(n); + } + return true; +} +bool write_all(int fd, const void* data, std::size_t size) { +#if !defined(_WIN32) + struct PipeSignalGuard { + sigset_t blocked{}, previous{}; + bool was_pending{false}; + PipeSignalGuard() { + sigemptyset(&blocked); + sigaddset(&blocked, SIGPIPE); + pthread_sigmask(SIG_BLOCK, &blocked, &previous); + sigset_t pending{}; + sigpending(&pending); + was_pending = sigismember(&pending, SIGPIPE) == 1; + } + ~PipeSignalGuard() { + sigset_t pending{}; + sigpending(&pending); + if (!was_pending && sigismember(&pending, SIGPIPE) == 1) { + int signal{}; + sigwait(&blocked, &signal); + } + pthread_sigmask(SIG_SETMASK, &previous, nullptr); + } + } guard; +#endif + + const auto* p = static_cast(data); + while (size) { +#if defined(_WIN32) + auto n = ::_write(fd, p, static_cast(std::min(size, std::size_t{1} << 20))); +#else + auto n = ::write(fd, p, size); + if (n < 0 && errno == EINTR) + continue; +#endif + if (n <= 0) + return false; + p += n; + size -= static_cast(n); + } + return true; +} +#if !defined(_WIN32) +void close_except(std::initializer_list descriptors) { + std::vector keep{0, 1, 2}; + keep.insert(keep.end(), descriptors.begin(), descriptors.end()); + std::ranges::sort(keep); + keep.erase(std::unique(keep.begin(), keep.end()), keep.end()); +#if defined(__linux__) + unsigned next = 3; + bool supported = true; + for (int fd : keep) { + if (fd < 3) + continue; + if (next < static_cast(fd) && + ::syscall(436, next, static_cast(fd) - 1, 0) != 0) + supported = false; + next = static_cast(fd) + 1; + } + if (::syscall(436, next, std::numeric_limits::max(), 0) != 0) + supported = false; + if (supported) + return; +#endif + const auto limit = ::sysconf(_SC_OPEN_MAX); + for (int fd = 3; fd < limit; ++fd) + if (!std::binary_search(keep.begin(), keep.end(), fd)) + ::close(fd); +} +#endif +#if defined(_WIN32) +std::wstring wide(std::string_view s) { + int count = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, s.data(), + static_cast(s.size()), nullptr, 0); + if (!count && !s.empty()) + throw std::runtime_error("invalid UTF-8 worker argument"); + std::wstring v(count, L'\0'); + MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, s.data(), static_cast(s.size()), + v.data(), count); + return v; +} +std::wstring quote(const std::wstring& s) { + std::wstring r = L"\""; + unsigned slashes = 0; + for (wchar_t c : s) { + if (c == L'\\') { + ++slashes; + continue; + } + r.append(c == L'"' ? 2 * slashes + 1 : slashes, L'\\'); + slashes = 0; + r += c; + } + r.append(2 * slashes, L'\\'); + r += L'"'; + return r; +} +#endif +} // namespace +std::optional attach(std::string_view r, std::string_view w) { + auto a = number(r), b = number(w); + if (!a || !b || *a == *b) + return std::nullopt; +#if defined(_WIN32) + auto rh = reinterpret_cast(*a), wh = reinterpret_cast(*b); + SetHandleInformation(rh, HANDLE_FLAG_INHERIT, 0); + SetHandleInformation(wh, HANDLE_FLAG_INHERIT, 0); + int rd = ::_open_osfhandle(static_cast(*a), _O_RDONLY | _O_BINARY); + int wr = ::_open_osfhandle(static_cast(*b), _O_WRONLY | _O_BINARY); + if (rd < 0 || wr < 0) { + if (rd >= 0) + ::_close(rd); + if (wr >= 0) + ::_close(wr); + return std::nullopt; + } + return Channel{rd, wr}; +#else + if (*a > static_cast(std::numeric_limits::max()) || + *b > static_cast(std::numeric_limits::max())) + return std::nullopt; + Channel c{static_cast(*a), static_cast(*b)}; + if (!platform::set_inheritable(c.read, false) || !platform::set_inheritable(c.write, false)) + return std::nullopt; + return c; +#endif +} +void close(Channel& c) { + for (auto fd : {c.read, c.write}) + if (fd >= 0) + platform::close_fd(fd); + c = {}; +} +std::expected receive(Channel c) { + std::array header{}; + if (!read_all(c.read, header.data(), header.size())) + return std::unexpected("Lua worker control channel closed"); + std::size_t size = 0; + for (int i = 0; i < 4; ++i) + size |= static_cast(header[i]) << (i * 8); + if (size > kMessageLimit) + return std::unexpected("Lua worker message exceeds limit"); + std::string message(size, '\0'); + if (!read_all(c.read, message.data(), size)) + return std::unexpected("Lua worker response truncated"); + return message; +} +bool send(Channel c, std::string_view message) { + if (message.size() > kMessageLimit) + return false; + std::array header{}; + for (int i = 0; i < 4; ++i) + header[i] = static_cast(message.size() >> (i * 8)); + return write_all(c.write, header.data(), header.size()) && + write_all(c.write, message.data(), message.size()); +} +bool install_trace(std::string_view channel) { + auto socket = number(channel); + if (!socket || *socket > static_cast(std::numeric_limits::max())) + return false; + const auto fd = static_cast(*socket); + platform::set_inheritable(fd, false); + std::promise listeners; + auto ready = listeners.get_future(); + bool transferred = false; + // This helper must exist before either filter: its one SCM_RIGHTS handoff + // must never wait for the network listener it is still handing to the host. + std::jthread handoff([&] { + const int listener = ready.get(); + if (listener >= 0) { + const std::array descriptors{listener}; + transferred = platform::send_message(fd, "trace-listeners", descriptors); + platform::close_fd(listener); + } + platform::close_fd(fd); + }); + const auto listener = platform::net_notify::listener({.network = true, .exec = true}); + listeners.set_value(listener); + handoff.join(); + return transferred; +} +std::expected read_log(const std::filesystem::path& root, + std::string_view name) { + if (name.empty() || name.find('/') != std::string_view::npos || + name.find('\\') != std::string_view::npos || name == "." || name == "..") + return std::unexpected("invalid recipe log name"); +#if defined(_WIN32) + const auto path = root / std::filesystem::path(name); + HANDLE file = CreateFileW(path.c_str(), GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, + OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, nullptr); + if (file == INVALID_HANDLE_VALUE) + return std::unexpected("cannot read recipe log"); + BY_HANDLE_FILE_INFORMATION info{}; + if (!GetFileInformationByHandle(file, &info) || + (info.dwFileAttributes & (FILE_ATTRIBUTE_REPARSE_POINT | FILE_ATTRIBUTE_DIRECTORY))) { + CloseHandle(file); + return std::unexpected("recipe log must be a regular file"); + } + int fd = ::_open_osfhandle(reinterpret_cast(file), _O_RDONLY | _O_BINARY); + if (fd < 0) { + CloseHandle(file); + return std::unexpected("cannot attach recipe log"); + } +#else + int directory = ::open(root.c_str(), O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (directory < 0) + return std::unexpected("cannot open owned recipe directory"); + int fd = ::openat(directory, std::string(name).c_str(), + O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC); + ::close(directory); + if (fd < 0) + return std::unexpected("cannot read owned recipe log"); + struct stat info{}; + if (::fstat(fd, &info) != 0 || !S_ISREG(info.st_mode)) { + ::close(fd); + return std::unexpected("recipe log must be a regular file"); + } +#endif + struct Cleanup { + int fd; + ~Cleanup() { + platform::close_fd(fd); + } + } cleanup{fd}; + std::string contents; + std::array buffer{}; + while (true) { +#if defined(_WIN32) + auto count = ::_read(fd, buffer.data(), static_cast(buffer.size())); +#else + auto count = ::read(fd, buffer.data(), buffer.size()); + if (count < 0 && errno == EINTR) + continue; +#endif + if (count == 0) + break; + if (count < 0) + return std::unexpected("cannot read recipe output"); + if (contents.size() + static_cast(count) > (std::size_t{32} << 20)) + return std::unexpected("recipe output exceeds 32 MiB log limit"); + contents.append(buffer.data(), static_cast(count)); + } + return contents; +} +bool prepare_log(const std::filesystem::path& path) { +#if defined(_WIN32) + HANDLE file = CreateFileW(path.c_str(), GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, + OPEN_ALWAYS, FILE_FLAG_OPEN_REPARSE_POINT, nullptr); + if (file == INVALID_HANDLE_VALUE) + return false; + BY_HANDLE_FILE_INFORMATION info{}; + bool ok = GetFileInformationByHandle(file, &info) && + !(info.dwFileAttributes & (FILE_ATTRIBUTE_REPARSE_POINT | FILE_ATTRIBUTE_DIRECTORY)); + CloseHandle(file); + return ok; +#else + int fd = ::open(path.c_str(), O_WRONLY | O_CREAT | O_APPEND | O_NOFOLLOW | O_CLOEXEC, 0600); + if (fd < 0) + return false; + struct stat info{}; + bool ok = ::fstat(fd, &info) == 0 && S_ISREG(info.st_mode); + ::close(fd); + return ok; +#endif +} +struct OutputCapture::State { + int saved_out{-1}; + int saved_error{-1}; + int destination{-1}; + ~State() { + std::cout.flush(); + std::cerr.flush(); + std::fflush(nullptr); +#if defined(_WIN32) + if (saved_out >= 0) + ::_dup2(saved_out, 1); + if (saved_error >= 0) + ::_dup2(saved_error, 2); + SetStdHandle(STD_OUTPUT_HANDLE, reinterpret_cast(::_get_osfhandle(1))); + SetStdHandle(STD_ERROR_HANDLE, reinterpret_cast(::_get_osfhandle(2))); +#else + if (saved_out >= 0) + ::dup2(saved_out, 1); + if (saved_error >= 0) + ::dup2(saved_error, 2); +#endif + for (int fd : {saved_out, saved_error, destination}) + platform::close_fd(fd); + } +}; +OutputCapture::OutputCapture(const std::filesystem::path& path) + : state_(std::make_unique()) { + std::cout.flush(); + std::cerr.flush(); + std::fflush(nullptr); +#if defined(_WIN32) + state_->saved_out = ::_dup(1); + state_->saved_error = ::_dup(2); + state_->destination = + ::_wopen(path.c_str(), _O_WRONLY | _O_CREAT | _O_TRUNC | _O_BINARY, _S_IREAD | _S_IWRITE); + if (state_->saved_out < 0 || state_->saved_error < 0 || state_->destination < 0 || + ::_dup2(state_->destination, 1) != 0 || ::_dup2(state_->destination, 2) != 0) + throw std::runtime_error("cannot capture recipe output"); + SetStdHandle(STD_OUTPUT_HANDLE, reinterpret_cast(::_get_osfhandle(1))); + SetStdHandle(STD_ERROR_HANDLE, reinterpret_cast(::_get_osfhandle(2))); +#else + state_->saved_out = ::fcntl(1, F_DUPFD_CLOEXEC, 6); + state_->saved_error = ::fcntl(2, F_DUPFD_CLOEXEC, 6); + state_->destination = + ::open(path.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW | O_CLOEXEC, 0600); + if (state_->saved_out < 0 || state_->saved_error < 0 || state_->destination < 0 || + ::dup2(state_->destination, 1) < 0 || ::dup2(state_->destination, 2) < 0) + throw std::runtime_error("cannot capture recipe output"); +#endif +} +OutputCapture::~OutputCapture() = default; +struct Process::State { + Channel channel; +#if defined(_WIN32) + HANDLE process{}; +#else + int pid{-1}; + int pasta_pidfd{-1}; + int net_listener{-1}; + int proxy_bridge{-1}; + std::atomic stopping{false}; + std::thread observer; +#endif + ~State() { + close(channel); +#if defined(_WIN32) + if (process) { + if (WaitForSingleObject(process, 1000) == WAIT_TIMEOUT) { + TerminateProcess(process, 125); + WaitForSingleObject(process, INFINITE); + } + CloseHandle(process); + } +#else + stopping.store(true); + if (observer.joinable()) + observer.join(); + platform::close_fd(net_listener); + platform::close_fd(proxy_bridge); +#if defined(__linux__) + if (pasta_pidfd >= 0) { + (void)::syscall(424, pasta_pidfd, SIGTERM, nullptr, 0); + platform::close_fd(pasta_pidfd); + } +#endif + if (pid > 0) { + int status{}; + if (::waitpid(pid, &status, WNOHANG) == 0) { + ::kill(-pid, SIGTERM); + ::kill(pid, SIGTERM); + while (::waitpid(pid, &status, 0) < 0 && errno == EINTR) { + } + } + } +#endif + } +}; +Process::Process(std::unique_ptr s) : state_(std::move(s)) { +} +Process::Process(Process&&) noexcept = default; +Process& Process::operator=(Process&&) noexcept = default; +Process::~Process() = default; +std::expected Process::launch(const std::vector& input, + const std::map& env, + const Network& network, const Trace& trace) { + if (input.empty()) + return std::unexpected("empty Lua worker command"); + auto state = std::make_unique(); + auto argv = input; +#if defined(_WIN32) + if (!network.pasta.empty() || network.proxy || trace.enabled) + return std::unexpected("worker kernel isolation mechanisms are unavailable"); + SECURITY_ATTRIBUTES sa{sizeof(sa), nullptr, TRUE}; + HANDLE cr{}, pw{}, pr{}, cw{}; + if (!CreatePipe(&cr, &pw, &sa, 0)) + return std::unexpected("cannot create Lua worker request pipe"); + if (!CreatePipe(&pr, &cw, &sa, 0)) { + CloseHandle(cr); + CloseHandle(pw); + return std::unexpected("cannot create Lua worker response pipe"); + } + SetHandleInformation(pw, HANDLE_FLAG_INHERIT, 0); + SetHandleInformation(pr, HANDLE_FLAG_INHERIT, 0); + for (auto& a : argv) { + if (a == kReadToken) + a = std::to_string(reinterpret_cast(cr)); + if (a == kWriteToken) + a = std::to_string(reinterpret_cast(cw)); + } + std::wstring command, environment; + for (const auto& a : argv) { + if (!command.empty()) + command += L' '; + command += quote(wide(a)); + } + for (const auto& [k, v] : env) { + environment += wide(k + "=" + v); + environment += L'\0'; + } + environment += L'\0'; + if (env.empty()) + environment += L'\0'; + STARTUPINFOW si{}; + si.cb = sizeof(si); + PROCESS_INFORMATION pi{}; + const auto exe = wide(argv.front()); + bool ok = CreateProcessW(exe.c_str(), command.data(), nullptr, nullptr, TRUE, + CREATE_UNICODE_ENVIRONMENT, environment.data(), nullptr, &si, &pi); + CloseHandle(cr); + CloseHandle(cw); + if (!ok) { + CloseHandle(pw); + CloseHandle(pr); + return std::unexpected("cannot launch Lua worker: " + std::to_string(GetLastError())); + } + CloseHandle(pi.hThread); + state->process = pi.hProcess; + state->channel = { + ::_open_osfhandle(reinterpret_cast(pr), _O_RDONLY | _O_BINARY), + ::_open_osfhandle(reinterpret_cast(pw), _O_WRONLY | _O_BINARY)}; + if (state->channel.read < 0 || state->channel.write < 0) + return std::unexpected("cannot attach Lua worker pipes"); +#else + auto request = platform::make_pipe(), response = platform::make_pipe(); + if (!request) + return std::unexpected("cannot create Lua worker request pipe"); + if (!response) { + platform::close_fd((*request)[0]); + platform::close_fd((*request)[1]); + return std::unexpected("cannot create Lua worker response pipe"); + } + std::optional> trace_channel; + if (trace.enabled) { + trace_channel = platform::unix_pair(); + if (!trace_channel) { + for (int fd : {(*request)[0], (*request)[1], (*response)[0], (*response)[1]}) + platform::close_fd(fd); + return std::unexpected("cannot create worker exec audit channel"); + } + } + std::optional> proxy_bridge; + if (network.proxy) { + proxy_bridge = platform::unix_pair(); + if (!proxy_bridge || !network.gateway || !network.accept_client || !network.relay_tick || + !network.relay_fds) { + for (int fd : {(*request)[0], (*request)[1], (*response)[0], (*response)[1]}) + platform::close_fd(fd); + if (trace_channel) + for (int fd : *trace_channel) + platform::close_fd(fd); + if (proxy_bridge) + for (int fd : *proxy_bridge) + platform::close_fd(fd); + return std::unexpected("proxy worker relay is unavailable"); + } + } + std::optional> ready, go; + if (!network.pasta.empty() || network.proxy) { + ready = platform::make_pipe(); + go = platform::make_pipe(); + } + if ((!network.pasta.empty() || network.proxy) && (!ready || !go)) { + for (int fd : {(*request)[0], (*request)[1], (*response)[0], (*response)[1]}) + platform::close_fd(fd); + if (ready) + for (int fd : *ready) + platform::close_fd(fd); + if (go) + for (int fd : *go) + platform::close_fd(fd); + if (trace_channel) + for (int fd : *trace_channel) + platform::close_fd(fd); + if (proxy_bridge) + for (int fd : *proxy_bridge) + platform::close_fd(fd); + return std::unexpected("cannot create worker network handshake pipes"); + } + const auto ids = platform::user_ids(); + int pid = platform::fork_process(); + if (pid == 0) { + if (ready) { + platform::close_fd((*ready)[0]); + platform::close_fd((*go)[1]); + if (!platform::enter_private_network(ids.uid, ids.gid)) + platform::exit_now(125); + if (proxy_bridge) { + platform::close_fd((*proxy_bridge)[0]); + if (!platform::network::enable_loopback()) + platform::exit_now(125); + auto listener = platform::network::listen_loopback(network.gateway_port); + if (!listener) + platform::exit_now(125); + int gateway = platform::fork_process(); + if (gateway == 0) { + for (int fd : {(*request)[0], (*request)[1], (*response)[0], (*response)[1], + (*ready)[1], (*go)[0]}) + platform::close_fd(fd); + if (trace_channel) + for (int fd : *trace_channel) + platform::close_fd(fd); + close_except({(*proxy_bridge)[1], *listener}); + platform::new_session(); + platform::exit_now(network.gateway((*proxy_bridge)[1], *listener)); + } + platform::close_fd(*listener); + platform::close_fd((*proxy_bridge)[1]); + if (gateway < 0) + platform::exit_now(125); + } + const char byte = 1; + if (!write_all((*ready)[1], &byte, 1)) + platform::exit_now(125); + char proceed{}; + if (!read_all((*go)[0], &proceed, 1) || proceed != 1) + platform::exit_now(125); + platform::close_fd((*ready)[1]); + platform::close_fd((*go)[0]); + } + int rd = ::fcntl((*request)[0], F_DUPFD_CLOEXEC, 6), + wr = ::fcntl((*response)[1], F_DUPFD_CLOEXEC, 6); + int notify = trace_channel ? ::fcntl((*trace_channel)[1], F_DUPFD_CLOEXEC, 6) : -1; + if (trace_channel) + for (int fd : *trace_channel) + platform::close_fd(fd); + for (int fd : {(*request)[0], (*request)[1], (*response)[0], (*response)[1]}) + platform::close_fd(fd); + if (rd < 0 || wr < 0 || ::dup2(rd, 3) < 0 || ::dup2(wr, 4) < 0) + platform::exit_now(125); + if (trace_channel && (notify < 0 || ::dup2(notify, 5) < 0)) + platform::exit_now(125); + platform::close_fd(rd); + platform::close_fd(wr); + platform::close_fd(notify); + platform::new_session(); + platform::reset_signals(); + for (auto& a : argv) { + if (a == kReadToken) + a = "3"; + if (a == kWriteToken) + a = "4"; + if (a == kTraceToken) + a = "5"; + } + (void)platform::exec_path(argv, env); + platform::exit_now(125); + } + platform::close_fd((*request)[0]); + platform::close_fd((*response)[1]); + if (proxy_bridge) + platform::close_fd((*proxy_bridge)[1]); + if (pid < 0) { + platform::close_fd((*request)[1]); + platform::close_fd((*response)[0]); + if (ready) + for (int fd : *ready) + platform::close_fd(fd); + if (go) + for (int fd : *go) + platform::close_fd(fd); + if (trace_channel) + for (int fd : *trace_channel) + platform::close_fd(fd); + if (proxy_bridge) + platform::close_fd((*proxy_bridge)[0]); + return std::unexpected("cannot fork Lua worker"); + } + state->pid = pid; + state->channel = {(*response)[0], (*request)[1]}; + if (proxy_bridge) + state->proxy_bridge = (*proxy_bridge)[0]; + if (ready) { + platform::close_fd((*ready)[1]); + platform::close_fd((*go)[0]); + platform::PollFd pending{(*ready)[0]}; + char byte{}; + bool established = platform::poll_fds(std::span(&pending, 1), 10000) > 0 && + read_all((*ready)[0], &byte, 1) && byte == 1; + if (established && !network.proxy) { + auto pasta = network.pasta; + pasta.insert(pasta.end(), {"--pid", network.pid_file.string(), std::to_string(pid)}); + established = platform::run_argv_with_timeout(pasta, std::chrono::seconds(10)) == 0; + if (established) { + std::ifstream pid_file(network.pid_file); + int pasta_pid{}; + established = static_cast(pid_file >> pasta_pid) && pasta_pid > 1 && + pasta_pid != platform::get_pid(); +#if defined(__linux__) + if (established) { + state->pasta_pidfd = static_cast(::syscall(434, pasta_pid, 0)); + established = state->pasta_pidfd >= 0; + } +#else + established = false; +#endif + } + } + const char proceed = established ? 1 : 0; + write_all((*go)[1], &proceed, 1); + platform::close_fd((*ready)[0]); + platform::close_fd((*go)[1]); + std::error_code ec; + std::filesystem::remove(network.pid_file, ec); + if (!established) { + if (trace_channel) + for (int fd : *trace_channel) + platform::close_fd(fd); + return std::unexpected("pasta could not establish the Lua worker network"); + } + } + if (trace_channel) { + platform::close_fd((*trace_channel)[1]); + platform::PollFd ready_listener{(*trace_channel)[0]}; + const bool ready = platform::poll_fds(std::span(&ready_listener, 1), 10000) > 0; + auto listener = ready ? platform::receive_message((*trace_channel)[0]) : std::nullopt; + platform::close_fd((*trace_channel)[0]); + if (!listener || listener->fds.size() != 1 || listener->data != "trace-listeners") { + if (listener) + for (int fd : listener->fds) + platform::close_fd(fd); + return std::unexpected("Lua worker could not install its required exec audit filter"); + } + state->net_listener = listener->fds[0]; + } + if (trace_channel || proxy_bridge) { + auto* owned = state.get(); + owned->observer = + std::thread([owned, audit = trace.audit, net_audit = trace.net_audit, network] { + while (!owned->stopping.load()) { + std::vector pending; + if (owned->net_listener >= 0) + pending.push_back({owned->net_listener}); + if (owned->proxy_bridge >= 0) + pending.push_back({owned->proxy_bridge}); + if (network.proxy) { + auto relay = network.relay_fds(); + pending.insert(pending.end(), relay.begin(), relay.end()); + } + platform::poll_fds(pending, 100); + for (const auto& event : pending) { + if (!event.readable) + continue; + if (event.fd == owned->net_listener) { + auto notification = platform::net_notify::next(owned->net_listener); + if (!notification) + continue; + bool allowed = false; + try { + if (notification->kind == platform::net_notify::Kind::Exec) + allowed = audit && audit(notification->pid, notification->execPath); + else + allowed = net_audit && net_audit(*notification); + } catch (...) { + } + (void)platform::net_notify::complete(owned->net_listener, + notification->id, allowed); + if (!allowed) { + platform::send_signal_group(owned->pid, platform::sig::terminate); + platform::send_signal(owned->pid, platform::sig::terminate); + owned->stopping.store(true); + break; + } + } else if (event.fd == owned->proxy_bridge) { + auto client = platform::receive_message(owned->proxy_bridge); + if (!client) + continue; + if (client->data == "proxy-client" && client->fds.size() == 1) { + (void)network.accept_client(client->fds.front()); + } else + for (int fd : client->fds) + platform::close_fd(fd); + } + } + if (network.proxy) + network.relay_tick(); + } + }); + } +#endif + return Process(std::move(state)); +} +std::expected Process::exchange(std::string_view message) { + if (!state_) + return std::unexpected("Lua worker process unavailable"); + if (!send(state_->channel, message)) + return std::unexpected("cannot write Lua worker request"); + return receive(state_->channel); +} +} // namespace xlings::platform::worker diff --git a/modules/platform/src/process/worker.cppm b/modules/platform/src/process/worker.cppm new file mode 100644 index 000000000..0bc8f640a --- /dev/null +++ b/modules/platform/src/process/worker.cppm @@ -0,0 +1,58 @@ +export module xlings.platform.worker; +import std; +import xlings.platform; +export namespace xlings::platform::worker { +inline constexpr std::string_view kReadToken = "@XLINGS_WORKER_READ@"; +inline constexpr std::string_view kWriteToken = "@XLINGS_WORKER_WRITE@"; +inline constexpr std::string_view kTraceToken = "@XLINGS_WORKER_TRACE@"; +struct Network { + std::vector pasta; + std::filesystem::path pid_file; + bool proxy{false}; + std::uint16_t gateway_port{1080}; + std::function gateway; + std::function accept_client; + std::function()> relay_fds; + std::function relay_tick; +}; +struct Trace { + bool enabled{false}; + std::function audit; + std::function net_audit; +}; +bool install_trace(std::string_view channel); +class OutputCapture { + struct State; + std::unique_ptr state_; + + public: + explicit OutputCapture(const std::filesystem::path& path); + ~OutputCapture(); +}; +std::expected read_log(const std::filesystem::path& root, + std::string_view name); +bool prepare_log(const std::filesystem::path& path); +struct Channel { + int read{-1}; + int write{-1}; +}; +std::optional attach(std::string_view read, std::string_view write); +void close(Channel& channel); +std::expected receive(Channel channel); +bool send(Channel channel, std::string_view message); +class Process { + struct State; + std::unique_ptr state_; + explicit Process(std::unique_ptr state); + + public: + Process(Process&&) noexcept; + Process& operator=(Process&&) noexcept; + ~Process(); + static std::expected launch(const std::vector& argv, + const std::map& env, + const Network& network = {}, + const Trace& trace = {}); + std::expected exchange(std::string_view message); +}; +} // namespace xlings::platform::worker diff --git a/modules/platform/src/platform/target.cpp b/modules/platform/src/triple/target.cpp similarity index 98% rename from modules/platform/src/platform/target.cpp rename to modules/platform/src/triple/target.cpp index ea487e252..57b5eccd1 100644 --- a/modules/platform/src/platform/target.cpp +++ b/modules/platform/src/triple/target.cpp @@ -2,6 +2,7 @@ module; #if defined(_WIN32) #define NOMINMAX +#define WIN32_LEAN_AND_MEAN #include #else #include diff --git a/modules/platform/src/platform/target.cppm b/modules/platform/src/triple/target.cppm similarity index 96% rename from modules/platform/src/platform/target.cppm rename to modules/platform/src/triple/target.cppm index abcee7868..98ad03303 100644 --- a/modules/platform/src/platform/target.cppm +++ b/modules/platform/src/triple/target.cppm @@ -1,12 +1,5 @@ module; -#if defined(_WIN32) -#define NOMINMAX -#include -#else -#include -#endif - export module xlings.platform.target; import std; diff --git a/modules/runtime/mcpp.toml b/modules/runtime/mcpp.toml new file mode 100644 index 000000000..0d9c17afe --- /dev/null +++ b/modules/runtime/mcpp.toml @@ -0,0 +1,29 @@ +# Runtime primitives the xlings core and the SubOS core both use, and +# neither may own. +# +# cancellation cooperative cancellation. `std` only: `platform` depends on +# it, and `platform` is imported by most of the product. +# guard `UserConfirmed`, the token only `ask()` produces -- a +# function that deletes user data takes one, so a code path +# that never asked (a repair, an upgrade) cannot call it -- +# and the `Asker` port the interaction surfaces implement. +# observe one event model and one journal (design §22): the +# destructive-operation record, and a SubOS's audit written by +# its supervisor outside the sandbox. Writing never fails the +# operation it describes; lines carry names, never values, for +# anything that can hold a secret; a journal rotates by size. +# +# None of them knows about events on a screen, homes or sandboxes. +[package] +namespace = "xlings" +name = "runtime" +version = "0.1.0" + +[targets.runtime] +kind = "lib" + +[dependencies] +libs = { path = "../libs" } + +[build] +include_dirs = ["../libs/src/json"] diff --git a/modules/cancellation/src/cancellation.cpp b/modules/runtime/src/cancellation.cpp similarity index 100% rename from modules/cancellation/src/cancellation.cpp rename to modules/runtime/src/cancellation.cpp diff --git a/modules/cancellation/src/cancellation.cppm b/modules/runtime/src/cancellation.cppm similarity index 100% rename from modules/cancellation/src/cancellation.cppm rename to modules/runtime/src/cancellation.cppm diff --git a/modules/runtime/src/guard.cpp b/modules/runtime/src/guard.cpp new file mode 100644 index 000000000..23c277bf2 --- /dev/null +++ b/modules/runtime/src/guard.cpp @@ -0,0 +1,38 @@ +module xlings.guard; + +import std; + +namespace xlings::guard { + +Asked ask(Asker& asker, Question q, bool autoYes, std::string_view yesSpelling) { + if (autoYes) { + return { .outcome = Outcome::Confirmed, + .token = UserConfirmed(std::string(yesSpelling)) }; + } + auto reply = asker.ask(q); + switch (reply.kind) { + case Reply::Kind::Chosen: + if (reply.value == "y") { + return { .outcome = Outcome::Confirmed, .token = UserConfirmed("terminal") }; + } + return { .outcome = Outcome::Declined }; + case Reply::Kind::Cancelled: + return { .outcome = Outcome::Declined }; + case Reply::Kind::NobodyToAsk: + default: + return { .outcome = Outcome::NobodyToAsk }; + } +} + +bool is_within(const std::filesystem::path& path, const std::filesystem::path& root) { + auto p = path.lexically_normal(); + auto r = root.lexically_normal(); + auto pi = p.begin(); + for (auto ri = r.begin(); ri != r.end(); ++ri, ++pi) { + if (ri->empty() && std::next(ri) == r.end()) break; // trailing separator + if (pi == p.end() || *pi != *ri) return false; + } + return true; +} + +} // namespace xlings::guard diff --git a/modules/runtime/src/guard.cppm b/modules/runtime/src/guard.cppm new file mode 100644 index 000000000..3454530eb --- /dev/null +++ b/modules/runtime/src/guard.cppm @@ -0,0 +1,73 @@ +export module xlings.guard; + +import std; + +// Asking before doing something that cannot be undone, and carrying the fact +// that the person said yes (design §13.4, §23). +// +// Moved from src/core/confirm.cppm with its contract unchanged: `UserConfirmed` +// can only be produced by `ask()` -- a "y" from whoever the Asker asks, or the +// auto-confirm the caller was explicitly given (`-y`, `"yes": true`). +export namespace xlings::guard { + +// What an interaction surface is asked. `id` is stable (the interface and +// agents key on it); `question` is for a person. +struct Question { + std::string id; + std::string question; + std::vector options { "y", "n" }; + std::string defaultValue { "n" }; +}; + +struct Reply { + enum class Kind { Chosen, Cancelled, NobodyToAsk }; + Kind kind { Kind::NobodyToAsk }; + std::string value; // set when Chosen +}; + +// The port. A terminal asks; an agent surface answers from its declared +// auto-confirm or reports NobodyToAsk; the interface sends a prompt event. +// The core never branches on which one it has. +class Asker { +public: + virtual ~Asker() = default; + virtual Reply ask(const Question& q) = 0; +}; + +struct Asked; + +class UserConfirmed { +public: + // "terminal", "-y", "yes:true" -- what the destructive log records. + [[nodiscard]] std::string_view how() const { return how_; } + +private: + explicit UserConfirmed(std::string how) : how_(std::move(how)) {} + std::string how_; + friend Asked ask(Asker&, Question, bool, std::string_view); +}; + +enum class Outcome { + Confirmed, // answered yes, or auto-confirmed by the caller + Declined, // answered no, or cancelled + NobodyToAsk, // no terminal, no responder, and no auto-confirm given +}; + +struct Asked { + Outcome outcome { Outcome::Declined }; + std::optional token; // set exactly when Confirmed +}; + +// `autoYes` is the caller's explicit auto-confirm; `yesSpelling` is how that +// was spelled ("-y", "yes:true") and is what `how()` reports for it. +[[nodiscard]] Asked ask(Asker& asker, Question q, bool autoYes, std::string_view yesSpelling); + +// ── Path guard ─────────────────────────────────────────────────────── + +// Whether `path` is `root` or lies under it, compared lexically after +// normalisation. Symlinks are not resolved: a guard that follows a link +// answers for the link's target, which is the wrong question for deletion. +[[nodiscard]] bool is_within(const std::filesystem::path& path, + const std::filesystem::path& root); + +} // namespace xlings::guard diff --git a/modules/runtime/src/observe.cpp b/modules/runtime/src/observe.cpp new file mode 100644 index 000000000..ec6d72de2 --- /dev/null +++ b/modules/runtime/src/observe.cpp @@ -0,0 +1,314 @@ +module xlings.observe; + +import std; +import xlings.libs.json; + +namespace xlings::observe { + +namespace fs = std::filesystem; + +std::string_view to_string(Kind k) { + switch (k) { + case Kind::Ops: return "ops"; + case Kind::Lifecycle: return "lifecycle"; + case Kind::Perm: return "perm"; + case Kind::Exec: return "exec"; + case Kind::Net: return "net"; + case Kind::Fs: return "fs"; + case Kind::Destructive: return "destructive"; + case Kind::Trace: return "trace"; + } + return "ops"; +} + +std::optional kind_from_string(std::string_view s) { + for (auto k : {Kind::Ops, Kind::Lifecycle, Kind::Perm, Kind::Exec, Kind::Net, + Kind::Fs, Kind::Destructive, Kind::Trace}) { + if (to_string(k) == s) return k; + } + return std::nullopt; +} + +std::string utc_now() { + const auto now = std::chrono::floor(std::chrono::system_clock::now()); + return std::format("{:%Y-%m-%dT%H:%M:%SZ}", now); +} + +nlohmann::json Event::to_json() const { + nlohmann::json j = fields.is_object() ? fields : nlohmann::json::object(); + j["kind"] = std::string(to_string(kind)); + j["ts"] = ts.empty() ? utc_now() : ts; + return j; +} + +namespace { + +// Never destroyed: a record written during static destruction (an atexit +// path) must not find its mutex already gone. +std::mutex& journal_mutex() { + static auto* m = new std::mutex(); + return *m; +} + +bool rotate(const fs::path& file, int keep) { + std::error_code ec; + if (keep <= 0) { + fs::remove(file, ec); + return !ec; + } + fs::remove(fs::path(file.string() + "." + std::to_string(keep)), ec); + if (ec) return false; + for (int i = keep - 1; i >= 1; --i) { + auto from = fs::path(file.string() + "." + std::to_string(i)); + const bool exists = fs::exists(from, ec); + if (ec) return false; + if (exists) { + fs::rename(from, fs::path(file.string() + "." + std::to_string(i + 1)), ec); + if (ec) return false; + } + } + fs::rename(file, fs::path(file.string() + ".1"), ec); + return !ec; +} + +} // namespace + +bool append_json_checked(const fs::path& file, const nlohmann::json& line, + JournalLimits limits) noexcept { + try { + const auto text = line.dump() + '\n'; + std::lock_guard lock(journal_mutex()); + std::error_code ec; + if (file.has_parent_path()) { + fs::create_directories(file.parent_path(), ec); + if (ec) return false; + } + if (limits.max_bytes > 0) { + const bool exists = fs::exists(file, ec); + if (ec) return false; + if (exists) { + const auto size = fs::file_size(file, ec); + if (ec) return false; + if (size >= limits.max_bytes && !rotate(file, limits.keep)) return false; + } + } + std::ofstream out(file, std::ios::app | std::ios::binary); + if (!out) return false; + out << text; + out.flush(); + if (!out) return false; + out.close(); + return !out.fail(); + } catch (...) { + return false; + } +} + +bool append_checked(const fs::path& file, const Event& event, JournalLimits limits) noexcept { + try { + return append_json_checked(file, event.to_json(), limits); + } catch (...) { + return false; + } +} + +void append_json(const fs::path& file, const nlohmann::json& line, JournalLimits limits) noexcept { + (void)append_json_checked(file, line, limits); +} + +void append(const fs::path& file, const Event& event, JournalLimits limits) noexcept { + (void)append_checked(file, event, limits); +} + +std::vector read(const fs::path& file, bool include_rotated) { + std::vector files; + std::error_code ec; + if (include_rotated) { + for (int i = 9; i >= 1; --i) { + auto p = fs::path(file.string() + "." + std::to_string(i)); + if (fs::exists(p, ec)) files.push_back(p); + } + } + files.push_back(file); + std::vector out; + for (auto& f : files) { + std::ifstream in(f, std::ios::binary); + std::string line; + while (std::getline(in, line)) { + if (line.empty() || line.front() != '{') continue; + auto j = nlohmann::json::parse(line, nullptr, false); + if (!j.is_discarded() && j.is_object()) out.push_back(std::move(j)); + } + } + return out; +} + +std::vector redact_env(const std::map& env) { + std::vector names; + names.reserve(env.size()); + for (auto& [k, v] : env) names.push_back(k); + return names; // std::map: already sorted +} + +bool looks_secret(std::string_view name) { + std::string upper(name); + for (auto& c : upper) c = static_cast(std::toupper(static_cast(c))); + for (std::string_view marker : {"TOKEN", "SECRET", "PASSWORD", "PASSWD", "CREDENTIAL", + "API_KEY", "APIKEY", "PRIVATE_KEY", "ACCESS_KEY", + "SESSION_KEY", "AUTH"}) { + if (upper.find(marker) != std::string::npos) return true; + } + return false; +} + +namespace { + +const std::vector& trace_categories() { + static const std::vector cats = [] { + std::vector out; + const char* v = std::getenv("XLINGS_TRACE"); + if (!v) return out; + std::string_view s(v); + while (!s.empty()) { + auto comma = s.find(','); + auto item = s.substr(0, comma); + if (!item.empty()) out.emplace_back(item); + if (comma == std::string_view::npos) break; + s.remove_prefix(comma + 1); + } + return out; + }(); + return cats; +} + +} // namespace + +bool trace_enabled(std::string_view category) { + const auto& cats = trace_categories(); + return std::ranges::any_of(cats, [&](const std::string& c) { + return c == category || c == "all" || c == "1"; + }); +} + +void trace(std::string_view category, std::string_view message) { + if (!trace_enabled(category)) return; + std::cerr << "[trace:" << category << "] " << message << '\n'; +} + +} // namespace xlings::observe + +namespace xlings::observe::destructive { + +namespace fs = std::filesystem; + +namespace { + +// observe depends on nothing of xlings, xlings.platform included. +#if defined(__linux__) // platform-if-ok: runtime sits below xlings.platform, which owns is_linux +constexpr bool kLinux = true; +#else +constexpr bool kLinux = false; +#endif + +struct Identity { + std::string version; + std::string command; +}; + +Identity& identity() { + static Identity id; + return id; +} + +// Who started us, when the platform can say. A deletion driven by an agent, +// a shell script or a person looks identical in argv; the parent's name is +// the cheapest fact that tells them apart. +std::string parent_name() { + if constexpr (!kLinux) return {}; + // Streamed, not read by size: every /proc file reports a size of 0. + const auto slurp = [](const char* path) { + std::ifstream in(path); + return std::string(std::istreambuf_iterator(in), {}); + }; + const auto stat = slurp("/proc/self/stat"); + // pid (comm) state ppid ... -- comm may contain spaces, so find the + // closing paren from the right. + const auto close = stat.rfind(')'); + if (close == std::string::npos) return {}; + std::istringstream rest(stat.substr(close + 1)); + std::string state, ppid; + rest >> state >> ppid; + if (ppid.empty()) return {}; + auto comm = slurp(("/proc/" + ppid + "/comm").c_str()); + while (!comm.empty() && (comm.back() == '\n' || comm.back() == '\r')) comm.pop_back(); + return comm; +} + +} // namespace + +void set_identity(std::string version, std::string command) { + identity().version = std::move(version); + identity().command = std::move(command); +} + +fs::path log_path(const fs::path& home) { return home / "logs" / "destructive.ndjson"; } + +void record(const fs::path& home, const Entry& entry) noexcept { + try { + nlohmann::json line = { + {"ts", utc_now()}, + {"xlings", identity().version}, + {"op", entry.op}, + {"path", entry.path.string()}, + {"bytes", entry.bytes}, + {"files", entry.files}, + {"confirmedBy", entry.confirmedBy}, + {"command", identity().command}, + {"parent", parent_name()}, + }; + if (!entry.detail.empty()) line["detail"] = entry.detail; + // Never rotated: this record exists to attribute a loss after the + // fact, and a rotation is a deletion of exactly that evidence. + append_json(log_path(home), line, JournalLimits{ .max_bytes = 0 }); + } catch (...) { + } +} + +Size measure(const fs::path& root) { + Size size; + std::error_code ec; + const auto st = fs::symlink_status(root, ec); + if (ec || fs::is_symlink(st)) return size; + if (fs::is_regular_file(st)) { + size.files = 1; + size.bytes = fs::file_size(root, ec); + if (ec) size.bytes = 0; + return size; + } + if (!fs::is_directory(st)) return size; + for (auto it = fs::recursive_directory_iterator( + root, fs::directory_options::skip_permission_denied, ec); + !ec && it != std::default_sentinel; it.increment(ec)) { + std::error_code entryEc; + const auto es = it->symlink_status(entryEc); + if (entryEc || fs::is_symlink(es) || !fs::is_regular_file(es)) continue; + ++size.files; + const auto bytes = it->file_size(entryEc); + if (!entryEc) size.bytes += bytes; + } + return size; +} + +std::string human_bytes(std::uintmax_t bytes) { + static constexpr std::array units{"B", "KB", "MB", "GB", "TB"}; + auto value = static_cast(bytes); + std::size_t unit = 0; + while (value >= 1024.0 && unit + 1 < units.size()) { + value /= 1024.0; + ++unit; + } + if (unit == 0) return std::format("{} B", bytes); + return std::format("{:.1f} {}", value, units[unit]); +} + +} // namespace xlings::observe::destructive diff --git a/modules/runtime/src/observe.cppm b/modules/runtime/src/observe.cppm new file mode 100644 index 000000000..a0cd70c2a --- /dev/null +++ b/modules/runtime/src/observe.cppm @@ -0,0 +1,103 @@ +export module xlings.observe; + +import std; +import xlings.libs.json; + +// The event model and the journal (design §22). +// +// kind ops | lifecycle | perm | exec | net | fs | destructive | trace +// ts UTC, second resolution, ISO 8601 +// fields kind-specific; only ever ADDED to (the protocol rule) +// +// A journal is an NDJSON file. `append()` creates its directory, rotates it +// when it passes `max_bytes` (file -> file.1 -> ... -> file.), and +// reports failures through append_checked(); the compatibility append() ignores them. +export namespace xlings::observe { + +enum class Kind { Ops, Lifecycle, Perm, Exec, Net, Fs, Destructive, Trace }; + +std::string_view to_string(Kind k); +std::optional kind_from_string(std::string_view s); + +// "2026-10-05T12:34:56Z" +std::string utc_now(); + +struct Event { + Kind kind { Kind::Ops }; + std::string ts; // filled by append() when empty + nlohmann::json fields = nlohmann::json::object(); + + nlohmann::json to_json() const; +}; + +struct JournalLimits { + std::uintmax_t max_bytes { 8u * 1024u * 1024u }; + int keep { 3 }; +}; + +// Checked writes include directory creation, rotation, writing, flushing and closing. +// A false result means the caller cannot rely on this record being stored. +bool append_checked(const std::filesystem::path& file, const Event& event, + JournalLimits limits = {}) noexcept; +bool append_json_checked(const std::filesystem::path& file, const nlohmann::json& line, + JournalLimits limits = {}) noexcept; + +// Never throws, never fails the caller. +void append(const std::filesystem::path& file, const Event& event, + JournalLimits limits = {}) noexcept; +void append_json(const std::filesystem::path& file, const nlohmann::json& line, + JournalLimits limits = {}) noexcept; + +// Every parsable line of a journal and its rotated predecessors, oldest +// first. Lines that do not parse are skipped (a torn write is not an error). +std::vector read(const std::filesystem::path& file, bool include_rotated = true); + +// ── Redaction ──────────────────────────────────────────────────────── + +// Names of the variables, sorted; never their values. +std::vector redact_env(const std::map& env); + +// Whether a variable NAME looks like it holds a credential. Used to decide +// what an allow-list may never pass by pattern (it can still be named +// explicitly). +bool looks_secret(std::string_view name); + +// ── Trace ──────────────────────────────────────────────────────────── + +// XLINGS_TRACE=home,caps,spec,provider,broker,session (or "all"). Read once. +bool trace_enabled(std::string_view category); + +// One line on stderr, prefixed `[trace:]`, when enabled. +void trace(std::string_view category, std::string_view message); + +} // namespace xlings::observe + +export namespace xlings::observe::destructive { + +// The destructive-operation record (AGENTS.md "SubOS user data"), moved here +// from src/core/destructive_log with its fields unchanged. +struct Entry { + std::string op; // "subos-remove", "self-install-overwrite", "gc", ... + std::filesystem::path path; + std::uintmax_t bytes { 0 }; + std::uintmax_t files { 0 }; + std::string confirmedBy; // "terminal", "-y", "yes:true", "automatic" + std::string detail; +}; + +// Identity stamped on every entry: the client version and its command line. +void set_identity(std::string version, std::string command); + +// /logs/destructive.ndjson +std::filesystem::path log_path(const std::filesystem::path& home); + +void record(const std::filesystem::path& home, const Entry& entry) noexcept; + +struct Size { std::uintmax_t bytes { 0 }; std::uintmax_t files { 0 }; }; +// Total bytes and regular files under `root`, symlinks not followed. +Size measure(const std::filesystem::path& root); + +// "2.5 GB", "812 KB", "0 B" +std::string human_bytes(std::uintmax_t bytes); + +} // namespace xlings::observe::destructive diff --git a/modules/sha256/mcpp.toml b/modules/sha256/mcpp.toml deleted file mode 100644 index 0542491c3..000000000 --- a/modules/sha256/mcpp.toml +++ /dev/null @@ -1,12 +0,0 @@ -# SHA-256 (FIPS 180-4), in process. -# -# A leaf: `std` only. It exists because the hashes this project checks are -# checked while a download is being verified, and shelling out to `sha256sum` -# would make that depend on what is installed on the machine. -[package] -namespace = "xlings" -name = "sha256" -version = "0.1.0" - -[targets.sha256] -kind = "lib" diff --git a/modules/store/mcpp.toml b/modules/store/mcpp.toml new file mode 100644 index 000000000..5aa6b25e9 --- /dev/null +++ b/modules/store/mcpp.toml @@ -0,0 +1,28 @@ +# The payload store, as its own package (SubOS design part 3 §7.1). +# +# What keeps a payload in /data/xpkgs alive. A workspace that has it +# installed is one holder; a retained root generation that links into it is +# another -- rolling back to that generation must find every link's target. +# This package states the second kind and remembers what was removed from +# every workspace but is still held that way (the retained ledger), so the +# payload is deleted when the last generation holding it is pruned, and not +# before. +# +# It knows paths and records, not workspaces or generations: the caller says +# which roots exist (xlings.subos.rootfs reads generations, xlings core reads +# workspaces), and an unreadable root is reported as a holder, never as +# absent -- "could not read" is not "empty" (AGENTS.md). +[package] +namespace = "xlings" +name = "store" +version = "0.1.0" + +[targets.store] +kind = "lib" + +[dependencies] +libs = { path = "../libs" } # json +platform = { path = "../platform" } # atomic writes + +[build] +include_dirs = ["../libs/src/json"] diff --git a/modules/store/src/store.cpp b/modules/store/src/store.cpp new file mode 100644 index 000000000..437f4191f --- /dev/null +++ b/modules/store/src/store.cpp @@ -0,0 +1,112 @@ +module xlings.store; + +import std; +import xlings.libs.json; +import xlings.platform; + +namespace xlings::store { + +namespace { + +std::string utc_now() { + return std::format("{:%Y-%m-%dT%H:%M:%SZ}", + std::chrono::floor(std::chrono::system_clock::now())); +} + +bool within(const fs::path& path, const fs::path& root) { + auto p = path.lexically_normal(); + auto r = root.lexically_normal(); + auto pi = p.begin(); + for (auto ri = r.begin(); ri != r.end(); ++ri, ++pi) { + if (ri->empty()) continue; // a trailing separator + if (pi == p.end() || *pi != *ri) return false; + } + return true; +} + +std::expected write_ledger(const fs::path& ledger, + const std::vector& entries) { + nlohmann::json doc{{"schema", 1}, {"retained", nlohmann::json::array()}}; + for (const auto& e : entries) + doc["retained"].push_back({{"payload", e.payload.generic_string()}, {"target", e.target}, + {"version", e.version}, {"since", e.since}}); + try { + std::error_code ec; + fs::create_directories(ledger.parent_path(), ec); + platform::write_file_atomic(ledger.string(), doc.dump(2) + "\n"); + } catch (const std::exception& e) { + return std::unexpected(ledger.string() + ": " + e.what()); + } + return {}; +} + +} // namespace + +std::optional payload_root(const fs::path& p) { + fs::path root; + int after = -1; + for (const auto& part : p) { + root /= part; + if (after >= 0 && ++after == 2) return root; + if (after < 0 && part == "xpkgs") after = 0; + } + return std::nullopt; +} + +Pins pins(const fs::path& payload, std::span roots, + std::span unreadable) { + Pins out; + out.unreadable.assign(unreadable.begin(), unreadable.end()); + for (const auto& root : roots) { + for (const auto& target : root.targets) { + if (!within(target, payload)) continue; + out.holders.push_back({root.subos, root.generation}); + break; + } + } + return out; +} + +fs::path ledger_path(const fs::path& data_dir) { return data_dir / "retained.json"; } + +std::expected, std::string> read_ledger(const fs::path& ledger) { + std::error_code ec; + if (!fs::exists(ledger, ec)) { + if (ec) return std::unexpected(ledger.string() + ": " + ec.message()); + return std::vector{}; + } + std::ifstream in(ledger, std::ios::binary); + if (!in) return std::unexpected(ledger.string() + ": cannot be read"); + std::string text((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + auto doc = nlohmann::json::parse(text, nullptr, false); + if (doc.is_discarded() || !doc.is_object() || !doc.contains("retained") || !doc["retained"].is_array()) + return std::unexpected(ledger.string() + ": not a retained-payload ledger"); + std::vector out; + for (const auto& e : doc["retained"]) { + if (!e.is_object() || !e.contains("payload") || !e["payload"].is_string()) + return std::unexpected(ledger.string() + ": an entry has no payload"); + out.push_back({fs::path(e["payload"].get()), e.value("target", ""), + e.value("version", ""), e.value("since", "")}); + } + return out; +} + +std::expected retain(const fs::path& ledger, Retained entry) { + auto entries = read_ledger(ledger); + if (!entries) return std::unexpected(entries.error()); + if (entry.since.empty()) entry.since = utc_now(); + std::erase_if(*entries, [&](const Retained& e) { return e.payload == entry.payload; }); + entries->push_back(std::move(entry)); + return write_ledger(ledger, *entries); +} + +std::expected forget(const fs::path& ledger, const fs::path& payload) { + auto entries = read_ledger(ledger); + if (!entries) return std::unexpected(entries.error()); + const auto before = entries->size(); + std::erase_if(*entries, [&](const Retained& e) { return e.payload == payload; }); + if (entries->size() == before) return {}; + return write_ledger(ledger, *entries); +} + +} // namespace xlings::store diff --git a/modules/store/src/store.cppm b/modules/store/src/store.cppm new file mode 100644 index 000000000..4cc9689ca --- /dev/null +++ b/modules/store/src/store.cppm @@ -0,0 +1,72 @@ +export module xlings.store; + +import std; + +// The payload store's roots (SubOS design part 3 §7.1). +// +// A payload directory is //, under a directory named +// `xpkgs`. A workspace that has it installed holds it; so does every retained +// root generation that links into it (part 2 §6.3: "a payload an older +// generation references is not collected before that generation is"). The +// second kind is this module's: which generations hold a payload, and the +// ledger of payloads removed from every workspace that a generation still +// holds -- deleted when the last of those generations is pruned. +export namespace xlings::store { + +namespace fs = std::filesystem; + +// The payload `p` lives in: <...>/xpkgs//, or nullopt for a +// path outside a store (a sysroot link, the client itself). +std::optional payload_root(const fs::path& p); + +// One retained generation's links, as its owner read them. +struct RootSet { + std::string subos; + int generation { 0 }; + std::vector targets; // absolute link targets +}; + +// A generation that keeps a payload alive. +struct Holder { + std::string subos; + int generation { 0 }; + bool operator==(const Holder&) const = default; +}; + +// What holds a payload. `unreadable` names roots that could not be read: each +// is a holder for this decision (it might link into the payload), never an +// absence. +struct Pins { + std::vector holders; + std::vector unreadable; + [[nodiscard]] bool held() const { return !holders.empty() || !unreadable.empty(); } +}; + +// Which of `roots` link into `payload` (a payload root, compared lexically). +Pins pins(const fs::path& payload, std::span roots, + std::span unreadable); + +// ── The retained ledger: /retained.json ─────────────────────── + +struct Retained { + fs::path payload; // the payload root kept on disk + std::string target; // the xvm target it was registered under + std::string version; + std::string since; // UTC, ISO 8601: when the last workspace let go + bool operator==(const Retained&) const = default; +}; + +fs::path ledger_path(const fs::path& data_dir); + +// Absent is empty (no payload was ever retained). A present file that cannot +// be parsed is an error: what it would have listed is not known, so nothing +// it might name may be released. +std::expected, std::string> read_ledger(const fs::path& ledger); + +// Adds `entry` (replacing one for the same payload); atomic. +std::expected retain(const fs::path& ledger, Retained entry); + +// Drops the entry for `payload`, if any; atomic. The payload is not touched. +std::expected forget(const fs::path& ledger, const fs::path& payload); + +} // namespace xlings::store diff --git a/modules/subos/mcpp.toml b/modules/subos/mcpp.toml new file mode 100644 index 000000000..8591294b8 --- /dev/null +++ b/modules/subos/mcpp.toml @@ -0,0 +1,32 @@ +# The SubOS core, as its own package (design §23). +# +# What a SubOS IS, independent of how xlings installs packages into one: +# the instance model and its manifest, the policy and the function that +# decides, the compiler from policy + host capabilities to a sandbox spec, +# the platform interfaces and their providers, the session (supervisor, +# session-init, broker server), userdata rules, GPU / graphics facts. +# +# It may not import `xlings.core.*`. That is the point of the package: a +# build that reaches for the package manager from here fails to compile. What +# the SubOS core needs from xlings it receives through `Ports` (installing a +# backend, recognising a foreign shim, executing a broker request), and it +# answers in structured results -- it never renders text for a person. +# +# Possible future: published as its own mcpp package with its own semver, +# and an `apps/xsubos` front end. +[package] +namespace = "xlings" +name = "subos" +version = "0.1.0" + +[targets.subos] +kind = "lib" + +[dependencies] +libs = { path = "../libs" } +platform = { path = "../platform" } +store = { path = "../store" } # payload roots of a generation +runtime = { path = "../runtime" } # guard, observe + +[build] +include_dirs = ["../libs/src/json"] diff --git a/modules/subos/src/intent/intent.cpp b/modules/subos/src/intent/intent.cpp new file mode 100644 index 000000000..60ddc97bd --- /dev/null +++ b/modules/subos/src/intent/intent.cpp @@ -0,0 +1,92 @@ +module xlings.subos.intent; + +import std; +import xlings.subos.home_view; +import xlings.subos.policy; +import xlings.subos.spec; + +namespace xlings::subos::intent { + +namespace { + +std::string posix(const fs::path& p) { return p.generic_string(); } + +// A host path a mount may not use (design §11): the system's own trees, the +// sandbox's machinery, and the xlings home or anything above it. +std::optional mount_refusal(const std::string& src, const std::string& dst, + const HomeView& home) { + auto norm = [](const std::string& p) { return fs::path(p).lexically_normal().generic_string(); }; + const auto s = norm(src), d = norm(dst); + const auto h = posix(home.home.lexically_normal()); + if (s.empty() || s.front() != '/') return "the host path must be absolute"; + if (d.empty() || d.front() != '/') return "the path inside must be absolute"; + if (h == s || h.starts_with(s.ends_with('/') ? s : s + "/")) + return "the xlings home, or a directory above it, cannot be mapped"; + for (std::string_view sys : {"/", "/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc", "/proc", + "/dev", "/sys", "/run", "/run/xlings", "/boot"}) { + if (d == sys) return std::format("{} is the sandbox's own; map below it or elsewhere", sys); + } + if (d.starts_with("/run/xlings/") || d.starts_with("/proc/") || d.starts_with("/sys/")) + return "that path inside is the sandbox's own"; + return std::nullopt; +} + +} // namespace + +policy::Need Intent::need(std::string_view dimension) const { + if (no_degrade) return policy::Need::Must; + auto it = needs.find(dimension); + return it == needs.end() ? policy::Need::Should : it->second; +} + +bool Intent::exists(std::string_view path) const { + if (host_exists) return host_exists(path); + std::error_code ec; + return fs::exists(fs::path(path), ec); +} + +Intent lower(const policy::Policy& policy, const HomeView& home, const spec::Request& r) { + Intent in; + in.instance = r.instance; + in.instance_dir = r.instance_dir; + in.id.neutral = policy.identity == policy::Identity::Neutral; + in.id.user = in.id.neutral ? std::string("user") : r.user; + in.id.login = r.user; + in.id.home_inside = "/home/" + in.id.user; + in.id.etc_dir = r.instance_dir / (in.id.neutral ? "etc-neutral" : "etc"); + in.id.tz = policy.tz; + in.storage = r.storage; + in.image_mountpoint = r.image_mountpoint; + in.root = r.root; + in.root_mounts = r.root_mounts; + in.gpu = r.grants.contains("gpu") || policy.grants.contains("gpu"); + in.grants = policy.grants; + in.grants.insert(r.grants.begin(), r.grants.end()); + in.net.mode = policy.net; + in.net.proxy = policy.proxy; + in.net.host_loopback = policy.grants.contains("host-loopback") || r.grants.contains("host-loopback"); + in.net.publish = r.publish; + in.env.clear = !policy.env_inherit; + in.env.pass.assign(policy::kBaseEnvPass.begin(), policy::kBaseEnvPass.end()); + in.env.pass.insert(in.env.pass.end(), policy.env_pass.begin(), policy.env_pass.end()); + in.env.explicit_any = policy.env_explicit_any; + in.disable_userns = policy.disable_userns; + in.interactive = r.interactive; + in.shell = r.shell; + in.argv = r.argv; + in.cwd = r.cwd; + in.host_env = r.host_env; + in.explicit_env = r.explicit_env; + in.host_exists = r.host_exists; + in.needs = policy.needs; + in.no_degrade = policy.no_degrade; + for (const auto& m : policy.mounts) { + Grant g{m.src, m.dst.empty() ? m.src : m.dst, m.rw, std::nullopt}; + if (auto why = mount_refusal(g.source, g.inside, home)) g.refused = g.source + ": " + *why; + else if (!in.exists(g.source)) g.refused = g.source + ": does not exist on the host"; + in.mounts.push_back(std::move(g)); + } + return in; +} + +} // namespace xlings::subos::intent diff --git a/modules/subos/src/intent/intent.cppm b/modules/subos/src/intent/intent.cppm new file mode 100644 index 000000000..8a070adbf --- /dev/null +++ b/modules/subos/src/intent/intent.cppm @@ -0,0 +1,86 @@ +export module xlings.subos.intent; + +import std; +import xlings.subos.home_view; +import xlings.subos.policy; +import xlings.subos.spec; + +// What a sandbox is asked to be, before anything decides how +// (SubOS design part 3 §6.1). +// +// The policy and the call (spec::Request) lowered into the decisions that do +// not depend on the backend: who the user is, what the network should be, +// which host paths are mapped in -- in order, each with its refusal when it +// may not be, the shape of an openkal preopen: a name inside, a source, a +// mode -- which devices and sockets are granted, which variables may pass. +// An implementation (xlings.confine) turns an Intent into its own plan; it +// never reads the policy again. +export namespace xlings::subos::intent { + +namespace fs = std::filesystem; + +// A host path mapped in (`--mount`), or the reason it may not be. +struct Grant { + std::string source; // host path, POSIX form + std::string inside; // where it appears + bool writable { true }; + std::optional refused; // a Must: the sandbox is not entered +}; + +struct Identity { + bool neutral { false }; // private/locked: `user`, the instance's host name, UTC + std::string user; // login name inside + std::string login; // the caller's login (a host-side directory name) + std::string home_inside; // /home/ + fs::path etc_dir; // the instance's passwd/group/hosts + std::string tz; // neutral only; empty = UTC +}; + +struct Network { + policy::Net mode { policy::Net::Host }; + std::string proxy; // Net::Proxy + bool host_loopback { false }; + std::vector publish; +}; + +struct Environment { + bool clear { true }; // !env_inherit + std::vector pass; // base + policy env_pass + bool explicit_any { true }; // --env may name any variable +}; + +struct Intent { + std::string instance; + fs::path instance_dir; + Identity id; + spec::Storage storage { spec::Storage::Shared }; + fs::path image_mountpoint; + fs::path root; // a rootfs instance's tree; empty = a view + std::vector root_mounts; + std::vector mounts; // in the policy's order + std::set> grants; // named grants: policy's and this call's + bool gpu { false }; + Network net; + Environment env; + bool disable_userns { false }; + bool interactive { false }; + std::string shell; + std::vector argv; + std::string cwd; + std::map host_env; + std::map explicit_env; + std::function host_exists; + + // How hard a dimension is required: Must under --no-degrade, else the + // policy's word, else Should. + std::map> needs; + bool no_degrade { false }; + [[nodiscard]] policy::Need need(std::string_view dimension) const; + + // A host path exists (the request's seam, so goldens pin host facts). + [[nodiscard]] bool exists(std::string_view path) const; +}; + +Intent lower(const policy::Policy& policy, const HomeView& home, const spec::Request& request); + +} // namespace xlings::subos::intent diff --git a/modules/subos/src/model/home_view.cppm b/modules/subos/src/model/home_view.cppm new file mode 100644 index 000000000..0f0c3653c --- /dev/null +++ b/modules/subos/src/model/home_view.cppm @@ -0,0 +1,44 @@ +export module xlings.subos.home_view; + +import std; + +// Where a SubOS's things live in a home (design §6). The one answer to "which +// path", handed to the SubOS core by xlings core's HomeContext -- the SubOS +// core never reads Config. +// +// The layout separates each instance from what governs and records it: +// +// /subos// the instance (the sandbox writes here) +// /config/subos// its policy -- read-only inside, owner-written +// /logs/subos// its audit -- invisible inside, supervisor-written +// /run/subos// its sockets -- invisible inside +// /state/ host facts -- the capability cache +// +// A sandbox that could rewrite its own policy or audit would make both +// decorative; keeping them outside the instance is what makes the read-only +// mount topology enough to protect them. +export namespace xlings::subos { + +namespace fs = std::filesystem; + +struct HomeView { + fs::path home; + + fs::path subos_root() const { return home / "subos"; } + fs::path instance(std::string_view name) const { return subos_root() / name; } + fs::path config_dir(std::string_view name) const { return home / "config" / "subos" / name; } + fs::path policy_file(std::string_view name) const { return config_dir(name) / "policy.json"; } + // What the instance IS, declared when it was made (part 2 §3.4: its kind). + fs::path instance_file(std::string_view name) const { return config_dir(name) / "instance.json"; } + // Which SubOS this home boots (part 2 §8), when it is a machine's root. + fs::path boot_file() const { return home / "boot.json"; } + fs::path logs_dir(std::string_view name) const { return home / "logs" / "subos" / name; } + fs::path run_dir(std::string_view name) const { return home / "run" / "subos" / name; } + fs::path state_dir() const { return home / "state"; } + fs::path caps_cache() const { return state_dir() / "isolation-caps.json"; } + // Requests waiting for the owner's approval (design §8.2), hidden inside. + fs::path requests_dir(std::string_view name) const { return state_dir() / "subos" / name / "requests"; } + fs::path broker_socket(std::string_view name) const { return run_dir(name) / "broker.sock"; } +}; + +} // namespace xlings::subos diff --git a/src/core/subos/manifest.cpp b/modules/subos/src/model/manifest.cpp similarity index 99% rename from src/core/subos/manifest.cpp rename to modules/subos/src/model/manifest.cpp index d5b170663..4eead5b80 100644 --- a/src/core/subos/manifest.cpp +++ b/modules/subos/src/model/manifest.cpp @@ -1,4 +1,4 @@ -module xlings.core.subos.manifest; +module xlings.subos.manifest; import std; import xlings.libs.json; diff --git a/src/core/subos/manifest.cppm b/modules/subos/src/model/manifest.cppm similarity index 99% rename from src/core/subos/manifest.cppm rename to modules/subos/src/model/manifest.cppm index 8ac2913c4..cd7a6ad7c 100644 --- a/src/core/subos/manifest.cppm +++ b/modules/subos/src/model/manifest.cppm @@ -1,4 +1,4 @@ -export module xlings.core.subos.manifest; +export module xlings.subos.manifest; import std; @@ -137,7 +137,7 @@ inline constexpr std::string_view DEFAULT_RUNTIME_QUERY = "xim:glibc"; // `runtime_source: "fallback"`, so a stale pin can never be mistaken for a // resolved fact. Nothing else may read it: substituting a constant for an // answer under Describe is exactly the defect this file already fixed. -inline constexpr std::string_view DEFAULT_RUNTIME_FALLBACK = "glibc@2.44"; +constexpr std::string_view DEFAULT_RUNTIME_FALLBACK = "glibc@2.44"; // not inline: defined in this unit, so a binary of modules alone links inline constexpr std::string_view OP_SET = "set"; inline constexpr std::string_view OP_PREPEND = "prepend"; diff --git a/modules/subos/src/model/model.cppm b/modules/subos/src/model/model.cppm new file mode 100644 index 000000000..df8b5bdd4 --- /dev/null +++ b/modules/subos/src/model/model.cppm @@ -0,0 +1,191 @@ +export module xlings.subos.model; + +import std; + +// The pure part of the instance model (design §23): how a name a person or +// an agent typed resolves to an instance, and what to suggest when it does +// not. Works on names alone, so it is tested without a home and shared by +// every surface that resolves one (use, exec, start, remove, the interface). +export namespace xlings::subos::model { + +struct NameResolution { + std::string selected; // empty when nothing was chosen + // missing_name | exact | case_insensitive_exact | unique_prefix | + // ambiguous | not_found -- the interface reports this verbatim. + std::string reason; + std::vector matches; // the candidates worth showing, in order + bool autoSelected { false }; // chosen by a rule weaker than "exact" +}; + +std::size_t edit_distance(std::string_view lhs, std::string_view rhs); + +// Up to `max` names, related ones (substring either way) first, then by edit +// distance, then by name. +std::vector suggestions(std::string_view query, + std::span names, + std::size_t max = 3); + +// `names` sorted. Exact wins; then a unique case-insensitive match; then a +// unique prefix; more than one at a level is "ambiguous"; none is +// "not_found" with suggestions. +NameResolution resolve_name(std::string_view query, std::span names); + +// Where a path INSIDE an instance's sandbox lives on the host, for `subos cp`. +// Only the instance's own trees qualify: /home/ (also `~`, and any +// relative path, taken from there) and /tmp. Anything else is the host's own +// read-only userland or not there at all, and is refused. +std::optional inside_to_host(const std::filesystem::path& instance_dir, + std::string_view user, + std::string_view inside); + +// The same for a rootfs instance (design part 2 §6.1): a path inside is its +// root tree's, `~` and relative paths from /root -- except what the +// projection and the kernel provide (/usr and the merged links, /proc, /sys, +// /dev, /run) and the xlings home, none of which a copy goes into. +std::optional inside_to_root(const std::filesystem::path& tree, + std::string_view inside, + const std::filesystem::path& home); + +// "90", "90s", "10m", "2h" -> seconds; nullopt for anything else. +std::optional parse_duration(std::string_view text); + +} // namespace xlings::subos::model + +namespace xlings::subos::model { + +namespace { + +std::string lowercase(std::string_view name) { + std::string lowered(name); + for (auto& ch : lowered) { + if (ch >= 'A' && ch <= 'Z') ch = static_cast(ch - 'A' + 'a'); + } + return lowered; +} + +} // namespace + +std::size_t edit_distance(std::string_view lhs, std::string_view rhs) { + std::vector prev(rhs.size() + 1); + std::vector next(rhs.size() + 1); + std::iota(prev.begin(), prev.end(), std::size_t{0}); + for (std::size_t i = 0; i < lhs.size(); ++i) { + next[0] = i + 1; + for (std::size_t j = 0; j < rhs.size(); ++j) { + const auto replace = prev[j] + (lhs[i] == rhs[j] ? 0u : 1u); + next[j + 1] = std::min({prev[j + 1] + 1, next[j] + 1, replace}); + } + std::swap(prev, next); + } + return prev.back(); +} + +std::vector suggestions(std::string_view query, + std::span names, + std::size_t max) { + struct Scored { + int substringRank; + std::size_t distance; + std::string name; + }; + const auto loweredQuery = lowercase(query); + std::vector scored; + scored.reserve(names.size()); + for (const auto& name : names) { + const auto lowered = lowercase(name); + const bool related = lowered.contains(loweredQuery) || loweredQuery.contains(lowered); + scored.push_back({related ? 0 : 1, edit_distance(loweredQuery, lowered), name}); + } + std::ranges::sort(scored, [](const auto& lhs, const auto& rhs) { + return std::tie(lhs.substringRank, lhs.distance, lhs.name) + < std::tie(rhs.substringRank, rhs.distance, rhs.name); + }); + std::vector result; + for (std::size_t i = 0; i < std::min(max, scored.size()); ++i) + result.push_back(std::move(scored[i].name)); + return result; +} + +NameResolution resolve_name(std::string_view query, std::span names) { + if (query.empty()) { + return {.reason = "missing_name", .matches = {names.begin(), names.end()}}; + } + if (std::ranges::find(names, query) != names.end()) { + return {.selected = std::string(query), .reason = "exact", + .matches = {std::string(query)}}; + } + + const auto loweredQuery = lowercase(query); + std::vector matches; + for (const auto& name : names) + if (lowercase(name) == loweredQuery) matches.push_back(name); + if (matches.size() == 1) { + return {.selected = matches.front(), .reason = "case_insensitive_exact", + .matches = std::move(matches), .autoSelected = true}; + } + if (matches.size() > 1) return {.reason = "ambiguous", .matches = std::move(matches)}; + + for (const auto& name : names) + if (lowercase(name).starts_with(loweredQuery)) matches.push_back(name); + if (matches.size() == 1) { + return {.selected = matches.front(), .reason = "unique_prefix", + .matches = std::move(matches), .autoSelected = true}; + } + if (matches.size() > 1) return {.reason = "ambiguous", .matches = std::move(matches)}; + + return {.reason = "not_found", .matches = suggestions(query, names)}; +} + +std::optional inside_to_host(const std::filesystem::path& instance_dir, + std::string_view user, + std::string_view inside) { + namespace fs = std::filesystem; + const auto home = "/home/" + std::string(user); + std::string p(inside); + if (p == "~" || p.starts_with("~/")) p = home + p.substr(1); + else if (p.empty() || p.front() != '/') p = home + "/" + p; + auto norm = fs::path(p).lexically_normal().generic_string(); + while (norm.size() > 1 && norm.back() == '/') norm.pop_back(); + auto under = [&](std::string_view root) { + return norm == root || norm.starts_with(std::string(root) + "/"); + }; + if (under(home)) return instance_dir / "home" / std::string(user) / fs::path(norm.substr(home.size())).relative_path(); + if (under("/tmp")) return instance_dir / "tmp" / fs::path(norm.substr(4)).relative_path(); + return std::nullopt; +} + +std::optional inside_to_root(const std::filesystem::path& tree, + std::string_view inside, + const std::filesystem::path& home) { + namespace fs = std::filesystem; + std::string p(inside); + if (p == "~" || p.starts_with("~/")) p = "/root" + p.substr(1); + else if (p.empty() || p.front() != '/') p = "/root/" + p; + const auto norm = fs::path(p).lexically_normal(); + if (norm == "/" ) return std::nullopt; + for (const auto& part : norm) if (part == "..") return std::nullopt; + const auto first = (++norm.begin())->string(); + for (std::string_view provided : {"usr", "bin", "sbin", "lib", "lib64", "proc", "sys", "dev", "run"}) + if (first == provided) return std::nullopt; + const auto h = home.lexically_normal(); + if (norm == h || std::mismatch(h.begin(), h.end(), norm.begin(), norm.end()).first == h.end()) + return std::nullopt; + return tree / norm.relative_path(); +} + +std::optional parse_duration(std::string_view text) { + if (text.empty()) return std::nullopt; + long long mult = 1; + switch (text.back()) { + case 's': mult = 1; text.remove_suffix(1); break; + case 'm': mult = 60; text.remove_suffix(1); break; + case 'h': mult = 3600; text.remove_suffix(1); break; + default: break; + } + long long n = 0; + auto [ptr, ec] = std::from_chars(text.data(), text.data() + text.size(), n); + if (ec != std::errc{} || ptr != text.data() + text.size() || n < 0) return std::nullopt; + return n * mult; +} + +} // namespace xlings::subos::model diff --git a/modules/subos/src/model/ports.cppm b/modules/subos/src/model/ports.cppm new file mode 100644 index 000000000..176c1b6b7 --- /dev/null +++ b/modules/subos/src/model/ports.cppm @@ -0,0 +1,22 @@ +export module xlings.subos.ports; + +import std; + +// What the SubOS core needs from xlings, received rather than imported +// (design §23.3). xlings core implements these in src/core/subos/ports; +// tests implement them with lambdas. A port that is not set answers +// "unavailable" -- the SubOS core never calls through an empty function. +export namespace xlings::subos { + +namespace fs = std::filesystem; + +struct Ports { + // Install a sandbox backend package ("bwrap", "proot") into the home. + std::function(std::string_view package)> install_backend; + + // When `path` is an xlings shim, the home that owns it. A backend binary + // found on a host path must not be another home's shim. + std::function(const fs::path& path)> shim_owner; +}; + +} // namespace xlings::subos diff --git a/modules/subos/src/model/roles.cppm b/modules/subos/src/model/roles.cppm new file mode 100644 index 000000000..c8b87eead --- /dev/null +++ b/modules/subos/src/model/roles.cppm @@ -0,0 +1,114 @@ +export module xlings.subos.roles; + +import std; + +// What may be done to a SubOS, by what it is (design part 2 §3.4, §8.3). +// +// `kind` is declared when the instance is made: a `view` (the PATH overlay +// and the sandbox view of Part 1) or a `rootfs` (it can be presented as `/`). +// `role` is a fact of the running machine: the SubOS that IS `/` right now is +// the host, and the ones the boot configuration names are boot entries. +// Operations that are safe on an instance are a disaster on the running +// system, so every command asks this one table instead of remembering to +// check (ROOT-ROLE-TABLE: every command x every kind/role, tested). +export namespace xlings::subos::roles { + +enum class Kind { View, Rootfs }; + +std::string_view to_string(Kind k); +std::optional kind_from_string(std::string_view s); + +struct Role { + bool host { false }; // this SubOS is `/` of the running machine + bool boot_entry { false }; // boot.json's default, fallback or trial +}; + +enum class Op { + Enter, // use / exec / start + Remove, + Policy, // subos config: isolation, mounts, grants + Packages, // install / remove / use into it + Copy, // subos cp + Boot, // make it a boot entry + Export, // export as a root, an image, a disk + Rollback, // move its root to another generation +}; + +inline constexpr std::array kOps{Op::Enter, Op::Remove, Op::Policy, Op::Packages, + Op::Copy, Op::Boot, Op::Export, Op::Rollback}; + +std::string_view to_string(Op op); + +struct Verdict { + bool allowed { true }; + std::string reason; // why not + std::string next; // what to do instead, a command when there is one +}; + +Verdict check(Op op, Kind kind, Role role, std::string_view name); + +} // namespace xlings::subos::roles + +namespace xlings::subos::roles { + +std::string_view to_string(Kind k) { return k == Kind::Rootfs ? "rootfs" : "view"; } + +std::optional kind_from_string(std::string_view s) { + if (s == "view") return Kind::View; + if (s == "rootfs") return Kind::Rootfs; + return std::nullopt; +} + +std::string_view to_string(Op op) { + switch (op) { + case Op::Enter: return "enter"; + case Op::Remove: return "remove"; + case Op::Policy: return "policy"; + case Op::Packages: return "packages"; + case Op::Copy: return "copy"; + case Op::Boot: return "boot"; + case Op::Export: return "export"; + case Op::Rollback: return "rollback"; + } + return "?"; +} + +Verdict check(Op op, Kind kind, Role role, std::string_view name) { + auto no = [](std::string reason, std::string next) { + return Verdict{false, std::move(reason), std::move(next)}; + }; + const std::string n(name); + switch (op) { + case Op::Remove: + if (role.host) return no(n + " is the running system", "boot another SubOS first: xlings subos boot "); + if (role.boot_entry) + return no(n + " is a boot entry", "xlings subos boot (then remove it)"); + return {}; + case Op::Policy: + if (role.host) + return no(n + " is the host: it has no isolation policy, it is what grants one", + "declare isolation on the instances it runs"); + return {}; + case Op::Copy: + if (role.host) return no(n + " is this machine's root", "cp, as on any system"); + return {}; + case Op::Boot: + if (kind != Kind::Rootfs) + return no(n + " is a view of the host, not a root", "xlings subos new --rootfs --from " + n); + return {}; + case Op::Export: + if (kind != Kind::Rootfs) + return no(n + " has no root to export", "xlings subos new --rootfs --from " + n); + return {}; + case Op::Rollback: + if (kind != Kind::Rootfs) + return no(n + " has no root generations", "xlings use @ --subos " + n); + return {}; + case Op::Enter: + case Op::Packages: + return {}; + } + return {}; +} + +} // namespace xlings::subos::roles diff --git a/src/core/subos/userdata.cpp b/modules/subos/src/model/userdata.cpp similarity index 87% rename from src/core/subos/userdata.cpp rename to modules/subos/src/model/userdata.cpp index fd2c2865f..e2fab82bc 100644 --- a/src/core/subos/userdata.cpp +++ b/modules/subos/src/model/userdata.cpp @@ -1,9 +1,10 @@ -module xlings.core.subos.userdata; +module xlings.subos.userdata; import std; -import xlings.core.config; -import xlings.core.confirm; -import xlings.core.destructive_log; +import xlings.platform; +import xlings.guard; +import xlings.observe; +import xlings.subos.home_view; namespace fs = std::filesystem; @@ -15,8 +16,8 @@ namespace xlings::subos::userdata { // shim) sits in a SubOS, and it is not the user's. Census census(const fs::path& dir) { Census c; - c.home = destructive_log::measure(dir / "home"); - const auto image = destructive_log::measure(dir / "home.img"); + c.home = observe::destructive::measure(dir / "home"); + const auto image = observe::destructive::measure(dir / "home.img"); c.home.bytes += image.bytes; c.home.files += image.files; @@ -45,7 +46,7 @@ Census census(const fs::path& dir) { std::string describe(const Census& c) { std::string out = std::format("home {} in {} file(s)", - destructive_log::human_bytes(c.home.bytes), + observe::destructive::human_bytes(c.home.bytes), c.home.files); if (c.otherFiles > 0) { out += std::format(", plus {} other file(s) outside home/", c.otherFiles); @@ -79,7 +80,6 @@ mount_under_in(std::string_view mountinfo, const std::filesystem::path& dir) { return std::nullopt; } -#if defined(__linux__) namespace { // A live mount anywhere under `dir`. Deleting through one erases the mounted // filesystem's contents, not this tree's. @@ -88,20 +88,19 @@ std::optional mount_under(const fs::path& dir) { return mount_under_in(std::string(std::istreambuf_iterator(in), {}), dir); } } // namespace -#endif // The one way a whole SubOS directory is deleted. It takes a UserConfirmed, -// which only confirm::ask() can produce -- so a repair, an upgrade or a +// which only guard::ask() can produce -- so a repair, an upgrade or a // rollback cannot reach it. std::expected -delete_subos(const fs::path& dir, std::string_view op, - const confirm::UserConfirmed& confirmed) { +delete_subos(const HomeView& home, const fs::path& dir, std::string_view op, + const guard::UserConfirmed& confirmed) { std::error_code ec; if (fs::is_symlink(dir, ec)) { return std::unexpected(std::format( "{} is a symbolic link; refusing to delete through it", dir.string())); } - const auto root = fs::weakly_canonical(Config::paths().homeDir / "subos", ec); + const auto root = fs::weakly_canonical(home.subos_root(), ec); const auto target = fs::weakly_canonical(dir, ec); const auto name = target.filename().string(); if (target.parent_path() != root || name.empty() || name == "current") { @@ -109,18 +108,16 @@ delete_subos(const fs::path& dir, std::string_view op, "refusing to delete {}: it is not a subos directory under {}", dir.string(), root.string())); } -#if defined(__linux__) - if (auto mount = mount_under(target)) { + if (auto mount = platform::is_linux ? mount_under(target) : std::nullopt) { return std::unexpected(std::format( "refusing to delete {}: {} is a live mount, and deleting through it " "would erase the mounted filesystem -- stop what is using it " "(`xlings subos stop {}`) or unmount it first", dir.string(), mount->string(), name)); } -#endif - const auto size = destructive_log::measure(target); + const auto size = observe::destructive::measure(target); fs::remove_all(target, ec); // subos-remove-all-ok: THE deletion entry point; caller holds UserConfirmed - destructive_log::record({ + observe::destructive::record(home.home, { .op = std::string(op), .path = target, .bytes = size.bytes, diff --git a/src/core/subos/userdata.cppm b/modules/subos/src/model/userdata.cppm similarity index 83% rename from src/core/subos/userdata.cppm rename to modules/subos/src/model/userdata.cppm index cd34be524..6aaef321a 100644 --- a/src/core/subos/userdata.cppm +++ b/modules/subos/src/model/userdata.cppm @@ -1,8 +1,9 @@ -export module xlings.core.subos.userdata; +export module xlings.subos.userdata; import std; -import xlings.core.confirm; -import xlings.core.destructive_log; +import xlings.guard; +import xlings.observe; +import xlings.subos.home_view; // SubOS user data. // @@ -11,7 +12,7 @@ import xlings.core.destructive_log; // every other file in it that xlings cannot prove it owns only a deletion the // USER initiated may remove, and only after they confirmed (a terminal answer, // `-y`, or `"yes": true`). The one deletion entry point below takes a -// confirm::UserConfirmed, which nothing but confirm::ask() can produce. +// guard::UserConfirmed, which nothing but guard::ask() can produce. // // Its own module because two sides need it that cannot import each other: // `subos remove` (xlings.core.subos) and `self install` / `self uninstall` @@ -19,7 +20,7 @@ import xlings.core.destructive_log; export namespace xlings::subos::userdata { struct Census { - destructive_log::Size home; // home/ and a home.img + observe::destructive::Size home; // home/ and a home.img std::uintmax_t otherFiles { 0 }; // regular files outside them, not links into packages }; @@ -34,8 +35,8 @@ struct Census { // and on Linux while anything is mounted under it. Recorded in the // destructive log with how it was confirmed. [[nodiscard]] std::expected -delete_subos(const std::filesystem::path& dir, std::string_view op, - const confirm::UserConfirmed& confirmed); +delete_subos(const HomeView& home, const std::filesystem::path& dir, std::string_view op, + const guard::UserConfirmed& confirmed); // The mount point under `dir` (or `dir` itself) in a /proc/self/mountinfo // text, octal escapes decoded. Split out so the parsing is testable without a diff --git a/modules/subos/src/policy/policy.cpp b/modules/subos/src/policy/policy.cpp new file mode 100644 index 000000000..e8512307b --- /dev/null +++ b/modules/subos/src/policy/policy.cpp @@ -0,0 +1,729 @@ +module xlings.subos.policy; + +import std; +import xlings.libs.json; + +namespace xlings::subos::policy { + +std::string_view to_string(Preset p) { + switch (p) { + case Preset::Dev: return "dev"; + case Preset::Private: return "private"; + case Preset::Locked: return "locked"; + default: return "legacy"; + } +} + +std::string_view to_string(Net n) { + switch (n) { + case Net::Nat: return "nat"; + case Net::None: return "none"; + case Net::Proxy: return "proxy"; + default: return "host"; + } +} + +std::string_view to_string(Fetch f) { + switch (f) { + case Fetch::Ask: return "ask"; + case Fetch::Layer: return "layer"; + case Fetch::Deny: return "deny"; + default: return "auto"; + } +} + +std::string_view to_string(Observe o) { + switch (o) { + case Observe::Off: return "off"; + case Observe::Standard: return "standard"; + case Observe::Full: return "full"; + default: return "basic"; + } +} + +std::optional preset_from_string(std::string_view s) { + for (auto p : {Preset::Legacy, Preset::Dev, Preset::Private, Preset::Locked}) + if (to_string(p) == s) return p; + return std::nullopt; +} + +std::optional net_from_string(std::string_view s) { + for (auto n : {Net::Host, Net::Nat, Net::None, Net::Proxy}) + if (to_string(n) == s) return n; + return std::nullopt; +} + +std::optional fetch_from_string(std::string_view s) { + for (auto f : {Fetch::Auto, Fetch::Ask, Fetch::Layer, Fetch::Deny}) + if (to_string(f) == s) return f; + return std::nullopt; +} + +std::optional observe_from_string(std::string_view s) { + for (auto o : {Observe::Off, Observe::Basic, Observe::Standard, Observe::Full}) + if (to_string(o) == s) return o; + return std::nullopt; +} + +Policy legacy() { + Policy p; + p.preset = Preset::Legacy; + p.env_pass.assign(kDevEnvPass.begin(), kDevEnvPass.end()); + for (auto g : kGrants) p.grants_allowed.insert(std::string(g)); + return p; +} + +bool env_name_matches(std::string_view name, std::span patterns) { + for (const auto& p : patterns) { + if (!p.empty() && p.back() == '*') { + if (name.starts_with(std::string_view(p).substr(0, p.size() - 1))) return true; + } else if (name == p) { + return true; + } + } + return false; +} + +} // namespace xlings::subos::policy + +namespace xlings::subos::policy { + +namespace { + +int rank(Net n) { + switch (n) { + case Net::Host: return 0; + case Net::Nat: return 1; + case Net::Proxy: return 2; + case Net::None: return 3; + } + return 0; +} + +int rank(Fetch f) { + switch (f) { + case Fetch::Auto: return 0; + case Fetch::Layer: return 1; + case Fetch::Ask: return 2; + case Fetch::Deny: return 3; + } + return 0; +} + +int rank(Observe o) { + switch (o) { + case Observe::Off: return 0; + case Observe::Basic: return 1; + case Observe::Standard: return 2; + case Observe::Full: return 3; + } + return 0; +} + +bool free_key(std::string_view k) { return k == "comment" || k.starts_with("x-"); } + +std::expected, std::string> string_list(const nlohmann::json& j, + std::string_view where) { + if (!j.is_array()) return std::unexpected(std::format("{} must be a list of strings", where)); + std::vector out; + for (auto& e : j) { + if (!e.is_string()) return std::unexpected(std::format("{} must be a list of strings", where)); + out.push_back(e.get()); + } + return out; +} + +} // namespace + +std::string_view to_string(Action a) { + switch (a) { + case Action::Allow: return "allow"; + case Action::Ask: return "ask"; + default: return "deny"; + } +} + +bool glob_match(std::string_view pat, std::string_view text) { + std::size_t p = 0, t = 0, star = std::string_view::npos, mark = 0; + while (t < text.size()) { + if (p < pat.size() && (pat[p] == '?' || pat[p] == text[t])) { ++p; ++t; } + else if (p < pat.size() && pat[p] == '*') { star = p++; mark = t; } + else if (star != std::string_view::npos) { p = star + 1; t = ++mark; } + else return false; + } + while (p < pat.size() && pat[p] == '*') ++p; + return p == pat.size(); +} + +std::optional parse_size(std::string_view text) { + std::uint64_t mult = 1; + auto upper = std::string(text); + for (auto& c : upper) c = static_cast(std::toupper(static_cast(c))); + std::string_view v(upper); + for (auto [suffix, m] : std::initializer_list>{ + {"TB", 1ull << 40}, {"GB", 1ull << 30}, {"MB", 1ull << 20}, {"KB", 1ull << 10}, + {"T", 1ull << 40}, {"G", 1ull << 30}, {"M", 1ull << 20}, {"K", 1ull << 10}, {"B", 1}}) { + if (v.ends_with(suffix)) { mult = m; v.remove_suffix(suffix.size()); break; } + } + std::uint64_t n = 0; + auto [ptr, ec] = std::from_chars(v.data(), v.data() + v.size(), n); + if (ec != std::errc{} || ptr != v.data() + v.size() || v.empty()) return std::nullopt; + return n * mult; +} + +Policy preset(Preset which) { + Policy p = legacy(); + p.preset = which; + p.extends = std::string(to_string(which)); + switch (which) { + case Preset::Legacy: + case Preset::Dev: + break; + case Preset::Private: + p.net = Net::Nat; + p.fetch = Fetch::Ask; + p.index_update = Fetch::Ask; + p.observe = Observe::Standard; + p.identity = Identity::Neutral; + p.tz = "UTC"; + p.env_pass.assign({"http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY", + "no_proxy", "NO_PROXY", "all_proxy", "ALL_PROXY"}); + p.env_explicit_any = false; + for (auto d : {"fs", "pid", "net"}) p.needs[d] = Need::Must; + break; + case Preset::Locked: + p.net = Net::None; + p.fetch = Fetch::Deny; + p.index_update = Fetch::Deny; + p.observe = Observe::Full; + p.identity = Identity::Neutral; + p.tz = "UTC"; + p.env_pass.clear(); + p.env_explicit_any = false; + p.grants_allowed.clear(); + p.mounts_ro_default = true; + p.disable_userns = true; + for (auto d : {"fs", "pid", "net", "identity", "terminal"}) p.needs[d] = Need::Must; + break; + } + return p; +} + +namespace { + +struct ClientVersion { + std::array parts{}; + std::vector prerelease; +}; + +std::expected client_version(std::string_view text) { + const auto original = text; + auto invalid = [&]() -> std::expected { + return std::unexpected(std::format("invalid client version '{}'", original)); + }; + ClientVersion result; + const auto suffix = text.find_first_of("-+"); + const auto core = text.substr(0, suffix); + auto rest = core; + int count = 0; + while (!rest.empty()) { + if (count == 4) return invalid(); + const auto dot = rest.find('.'); + const auto part = rest.substr(0, dot); + if (part.empty() || (part.size() > 1 && part[0] == '0')) return invalid(); + std::uint64_t number = 0; + auto parsed = std::from_chars(part.data(), part.data() + part.size(), number); + if (parsed.ec != std::errc{} || parsed.ptr != part.data() + part.size()) return invalid(); + result.parts[count++] = number; + if (dot == std::string_view::npos) break; + rest.remove_prefix(dot + 1); + if (rest.empty()) return invalid(); + } + if (count != 3 && count != 4) return invalid(); + if (count == 4) { + const auto& v = result.parts; + if (suffix != std::string_view::npos || v[0] < 1000 || v[0] > 9999 + || v[1] < 1 || v[1] > 12 || v[2] < 1 || v[2] > 31) return invalid(); + const auto date = std::chrono::year(static_cast(v[0])) + / std::chrono::month(static_cast(v[1])) + / std::chrono::day(static_cast(v[2])); + if (!date.ok()) return invalid(); + return result; + } + if (suffix == std::string_view::npos) return result; + auto tail = text.substr(suffix); + auto identifiers = [&](std::string_view list, bool prerelease) { + if (list.empty()) return false; + while (!list.empty()) { + const auto dot = list.find('.'); + const auto id = list.substr(0, dot); + if (id.empty()) return false; + bool numeric = true; + for (const char c : id) { + const bool digit = c >= '0' && c <= '9'; + if (!digit && !(c >= 'a' && c <= 'z') && !(c >= 'A' && c <= 'Z') && c != '-') + return false; + numeric = numeric && digit; + } + if (prerelease && numeric && id.size() > 1 && id[0] == '0') return false; + if (prerelease) result.prerelease.emplace_back(id); + if (dot == std::string_view::npos) break; + list.remove_prefix(dot + 1); + if (list.empty()) return false; + } + return true; + }; + if (tail[0] == '-') { + tail.remove_prefix(1); + const auto plus = tail.find('+'); + if (!identifiers(tail.substr(0, plus), true)) return invalid(); + if (plus == std::string_view::npos) return result; + tail.remove_prefix(plus); + } + if (tail.empty() || tail[0] != '+' || !identifiers(tail.substr(1), false)) return invalid(); + return result; +} + +} // namespace + +std::expected compare_client_versions(std::string_view lhs, std::string_view rhs) { + const auto left = client_version(lhs), right = client_version(rhs); + if (!left) return std::unexpected(left.error()); + if (!right) return std::unexpected(right.error()); + if (left->parts != right->parts) return left->parts < right->parts ? -1 : 1; + if (left->prerelease.empty() != right->prerelease.empty()) + return left->prerelease.empty() ? 1 : -1; + const auto numeric = [](std::string_view value) { + return std::ranges::all_of(value, [](char c) { return c >= '0' && c <= '9'; }); + }; + const auto& a = left->prerelease; + const auto& b = right->prerelease; + for (std::size_t i = 0; i < std::min(a.size(), b.size()); ++i) { + if (a[i] == b[i]) continue; + const bool an = numeric(a[i]), bn = numeric(b[i]); + if (an != bn) return an ? -1 : 1; + if (an && a[i].size() != b[i].size()) return a[i].size() < b[i].size() ? -1 : 1; + return a[i] < b[i] ? -1 : 1; + } + return a.size() == b.size() ? 0 : a.size() < b.size() ? -1 : 1; +} + +std::expected check_client(const Policy& p, std::string_view version) { + if (p.min_client.empty()) return {}; + const auto order = compare_client_versions(version, p.min_client); + if (!order) return std::unexpected("E_POLICY_CLIENT: " + order.error()); + if (*order < 0) + return std::unexpected(std::format( + "E_POLICY_CLIENT: policy requires xlings >= {}, client is {}; " + "upgrade the entry with `xlings self update` before entering", p.min_client, version)); + return {}; +} + +std::expected from_json(const nlohmann::json& doc) { + if (!doc.is_object()) return std::unexpected("policy: not a JSON object"); + auto unknown = [](std::string_view where) { + return std::unexpected(std::format( + "policy: unknown field '{}' -- this version of xlings does not know it, and an " + "isolation setting it cannot enforce is refused rather than ignored", where)); + }; + auto bad = [](std::string_view where, std::string_view value) { + return std::unexpected(std::format("policy: {} = '{}' is not a value this version knows", + where, value)); + }; + + Policy p = preset(Preset::Dev); + if (auto it = doc.find("extends"); it != doc.end()) { + if (!it->is_string()) return std::unexpected("policy: extends must be a string"); + const auto ext = it->get(); + if (is_package_ref(ext)) { + // Selected from a package: the copy below is the policy; `resolved` + // says where it came from and which preset that package built on. + auto r = doc.find("resolved"); + if (r == doc.end() || !r->is_object() || !r->contains("from") || !r->contains("sha256")) + return std::unexpected(std::format( + "policy: extends = '{}' without `resolved` -- select the package with " + "`xlings subos config --sandbox {}`", ext, ext)); + auto base = preset_from_string(r->value("base", "dev")); + if (!base || *base == Preset::Legacy) return bad("resolved.base", r->value("base", "")); + p = preset(*base); + p.extends = ext; + p.package = Policy::Package{ r->value("from", ""), r->value("sha256", "") }; + } else { + auto base = preset_from_string(ext); + if (!base || *base == Preset::Legacy) return bad("extends", ext); + p = preset(*base); + } + } + for (auto it = doc.begin(); it != doc.end(); ++it) { + const auto& key = it.key(); + if (key == "extends" || key == "resolved" || free_key(key)) continue; + const auto& v = it.value(); + if (key == "min_client") { + if (!v.is_string()) return std::unexpected("policy: min_client must be a version string"); + p.min_client = v.get(); + if (auto valid = client_version(p.min_client); !valid) + return std::unexpected("policy: min_client: " + valid.error()); + } else if (key == "isolation") { + if (!v.is_object()) return std::unexpected("policy: isolation must be an object"); + for (auto f = v.begin(); f != v.end(); ++f) { + const auto& k = f.key(); + const auto& fv = f.value(); + const auto where = "isolation." + k; + if (free_key(k)) continue; + if (k == "net") { + auto n = fv.is_string() ? net_from_string(fv.get()) : std::nullopt; + if (!n) return bad(where, fv.dump()); + p.net = *n; + } else if (k == "proxy") { + if (!fv.is_string()) return bad(where, fv.dump()); + p.proxy = fv.get(); + } else if (k == "identity") { + if (fv.is_string()) { + if (fv == "host") p.identity = Identity::Host; + else if (fv == "neutral") p.identity = Identity::Neutral; + else return bad(where, fv.dump()); + } else if (fv.is_object()) { + p.identity = Identity::Neutral; + for (auto g = fv.begin(); g != fv.end(); ++g) { + if (g.key() == "tz" && g.value().is_string()) p.tz = g.value().get(); + else if (!free_key(g.key())) return unknown(where + "." + g.key()); + } + } else { + return bad(where, fv.dump()); + } + } else if (k == "grants" || k == "grants_allowed") { + auto list = string_list(fv, where); + if (!list) return std::unexpected(list.error()); + std::set> set; + for (auto& g : *list) { + if (std::ranges::find(kGrants, std::string_view(g)) == kGrants.end()) + return bad(where, g); + set.insert(g); + } + (k == "grants" ? p.grants : p.grants_allowed) = std::move(set); + } else if (k == "env_pass") { + auto list = string_list(fv, where); + if (!list) return std::unexpected(list.error()); + p.env_pass = std::move(*list); + } else if (k == "disable_userns" || k == "no_degrade") { + if (!fv.is_boolean()) return bad(where, fv.dump()); + (k == "disable_userns" ? p.disable_userns : p.no_degrade) = fv.get(); + } else if (k == "needs") { + if (!fv.is_object()) return bad(where, fv.dump()); + for (auto g = fv.begin(); g != fv.end(); ++g) { + if (g.value() == "must") p.needs[g.key()] = Need::Must; + else if (g.value() == "should") p.needs[g.key()] = Need::Should; + else return bad(where + "." + g.key(), g.value().dump()); + } + } else { + return unknown(where); + } + } + } else if (key == "mounts") { + if (!v.is_array()) return std::unexpected("policy: mounts must be a list"); + p.mounts.clear(); + for (auto& m : v) { + if (!m.is_object() || !m.contains("src") || !m["src"].is_string()) + return std::unexpected("policy: each mount needs a src"); + Mount mount{ .src = m["src"].get(), + .dst = m.value("dst", std::string{}), + .rw = !p.mounts_ro_default }; + for (auto f = m.begin(); f != m.end(); ++f) { + if (f.key() == "src" || f.key() == "dst" || free_key(f.key())) continue; + if (f.key() != "mode") return unknown("mounts[]." + f.key()); + if (f.value() == "ro") mount.rw = false; + else if (f.value() == "rw") mount.rw = true; + else return bad("mounts[].mode", f.value().dump()); + } + p.mounts.push_back(std::move(mount)); + } + } else if (key == "permissions") { + if (!v.is_object()) return std::unexpected("policy: permissions must be an object"); + for (auto f = v.begin(); f != v.end(); ++f) { + const auto& k = f.key(); + const auto& fv = f.value(); + if (free_key(k)) continue; + if (k == "index_update") { + auto a = fv.is_string() ? fetch_from_string(fv.get()) : std::nullopt; + if (!a) return bad("permissions.index_update", fv.dump()); + p.index_update = *a; + } else if (k == "fetch") { + if (fv.is_string()) { + auto a = fetch_from_string(fv.get()); + if (!a) return bad("permissions.fetch", fv.dump()); + p.fetch = *a; + continue; + } + if (!fv.is_object()) return bad("permissions.fetch", fv.dump()); + for (auto g = fv.begin(); g != fv.end(); ++g) { + if (free_key(g.key())) continue; + if (g.key() == "default") { + auto a = g.value().is_string() ? fetch_from_string(g.value().get()) + : std::nullopt; + if (!a) return bad("permissions.fetch.default", g.value().dump()); + p.fetch = *a; + } else if (g.key() == "rules") { + if (!g.value().is_array()) return bad("permissions.fetch.rules", "not a list"); + p.fetch_rules.clear(); + for (auto& r : g.value()) { + if (!r.is_object()) return bad("permissions.fetch.rules[]", r.dump()); + Policy::Rule rule; + for (auto h = r.begin(); h != r.end(); ++h) { + const auto& rk = h.key(); + if (free_key(rk)) continue; + if (rk == "match" && h.value().is_string()) rule.match = h.value().get(); + else if (rk == "index" && h.value().is_string()) rule.index = h.value().get(); + else if (rk == "size_gt" && h.value().is_string()) { + auto sz = parse_size(h.value().get()); + if (!sz) return bad("permissions.fetch.rules[].size_gt", h.value().dump()); + rule.size_gt = *sz; + } else if (rk == "action" && h.value().is_string()) { + auto a = fetch_from_string(h.value().get()); + if (!a) return bad("permissions.fetch.rules[].action", h.value().dump()); + rule.action = *a; + } else return unknown("permissions.fetch.rules[]." + rk); + } + p.fetch_rules.push_back(std::move(rule)); + } + } else return unknown("permissions.fetch." + g.key()); + } + } else { + return unknown("permissions." + k); + } + } + } else if (key == "observe") { + if (!v.is_object()) return std::unexpected("policy: observe must be an object"); + for (auto f = v.begin(); f != v.end(); ++f) { + if (free_key(f.key()) || f.key() == "retention" || f.key() == "redact") continue; + if (f.key() != "level") return unknown("observe." + f.key()); + auto o = f.value().is_string() ? observe_from_string(f.value().get()) + : std::nullopt; + if (!o) return bad("observe.level", f.value().dump()); + p.observe = *o; + } + } else { + return unknown(key); + } + } + if (auto why = not_enforced(p)) return std::unexpected("policy: " + *why); + return p; +} + +std::optional not_enforced(const Policy& p) { + (void)p; + return std::nullopt; +} + +bool fetches_into_layer(const Policy& p) { + bool layer = p.fetch == Fetch::Layer || p.index_update == Fetch::Layer; + for (const auto& r : p.fetch_rules) layer = layer || r.action == Fetch::Layer; + return layer; +} + +bool is_package_ref(std::string_view extends) { + return extends.find(':') != std::string_view::npos; +} + +std::expected from_package(const nlohmann::json& doc, std::string_view ref, + Policy::Package resolved) { + if (!doc.is_object()) return std::unexpected("not a JSON object"); + if (doc.contains("resolved")) return std::unexpected("a package's policy carries no `resolved`"); + if (auto it = doc.find("extends"); it != doc.end() && it->is_string() + && is_package_ref(it->get())) + return std::unexpected(std::format( + "it extends another package ({}); a package extends a built-in preset or nothing", + it->get())); + auto p = from_json(doc); + if (!p) return p; + p->extends = std::string(ref); + p->package = std::move(resolved); + return p; +} + +nlohmann::json to_json(const Policy& p) { + nlohmann::json j; + if (!p.min_client.empty()) j["min_client"] = p.min_client; + j["extends"] = p.extends.empty() ? std::string(to_string(p.preset)) : p.extends; + if (p.package) + j["resolved"] = {{"from", p.package->from}, {"sha256", p.package->sha256}, + {"base", std::string(to_string(p.preset))}}; + nlohmann::json iso; + iso["net"] = std::string(to_string(p.net)); + if (!p.proxy.empty()) iso["proxy"] = p.proxy; + iso["identity"] = p.identity == Identity::Neutral + ? nlohmann::json{{"tz", p.tz.empty() ? "UTC" : p.tz}} : nlohmann::json("host"); + iso["grants"] = std::vector(p.grants.begin(), p.grants.end()); + iso["grants_allowed"] = std::vector(p.grants_allowed.begin(), p.grants_allowed.end()); + iso["env_pass"] = p.env_pass; + iso["disable_userns"] = p.disable_userns; + iso["no_degrade"] = p.no_degrade; + if (!p.needs.empty()) { + nlohmann::json needs = nlohmann::json::object(); + for (auto& [d, n] : p.needs) needs[d] = n == Need::Must ? "must" : "should"; + iso["needs"] = needs; + } + j["isolation"] = iso; + j["mounts"] = nlohmann::json::array(); + for (auto& m : p.mounts) { + nlohmann::json e{{"src", m.src}, {"mode", m.rw ? "rw" : "ro"}}; + if (!m.dst.empty()) e["dst"] = m.dst; + j["mounts"].push_back(e); + } + nlohmann::json fetch{{"default", std::string(to_string(p.fetch))}}; + if (!p.fetch_rules.empty()) { + fetch["rules"] = nlohmann::json::array(); + for (auto& r : p.fetch_rules) { + nlohmann::json e{{"match", r.match}, {"action", std::string(to_string(r.action))}}; + if (!r.index.empty()) e["index"] = r.index; + if (r.size_gt) e["size_gt"] = std::to_string(r.size_gt); + fetch["rules"].push_back(e); + } + } + j["permissions"] = {{"fetch", fetch}, {"index_update", std::string(to_string(p.index_update))}}; + j["observe"] = {{"level", std::string(to_string(p.observe))}}; + return j; +} + +std::expected apply(Policy p, const Overrides& o) { + if (o.proxy) { + if (p.net == Net::Proxy && !p.proxy.empty() && *o.proxy != p.proxy) + return std::unexpected("--proxy cannot replace this instance's declared proxy; its owner may use subos config"); + p.proxy = *o.proxy; + } + if (o.net) { + if (rank(*o.net) < rank(p.net)) + return std::unexpected(std::format("--net {} would loosen this instance's policy (net {})", + to_string(*o.net), to_string(p.net))); + p.net = *o.net; + } + if (o.fetch) { + if (rank(*o.fetch) < rank(p.fetch)) + return std::unexpected(std::format("--fetch {} would loosen this instance's policy (fetch {})", + to_string(*o.fetch), to_string(p.fetch))); + p.fetch = *o.fetch; + } + if (o.observe && rank(*o.observe) > rank(p.observe)) p.observe = *o.observe; + for (const auto& g : o.allow) { + if (std::ranges::find(kGrants, std::string_view(g)) == kGrants.end()) + return std::unexpected(std::format("--allow {}: not a grant (one of: display, audio, " + "camera, gpu, ssh-agent, dbus, host-loopback)", g)); + if (!p.grants.contains(g) && !p.grants_allowed.contains(g)) + return std::unexpected(std::format("--allow {}: this instance's policy does not allow " + "granting it (grants_allowed)", g)); + p.grants.insert(g); + } + for (auto m : o.mounts) { + if (!m.mode_given && p.mounts_ro_default) m.rw = false; + if (m.rw && p.mounts_ro_default && p.preset == Preset::Locked) + return std::unexpected(std::format("--mount {}: a locked instance maps read-only", m.src)); + p.mounts.push_back(m); + } + if (o.no_degrade) p.no_degrade = true; + if (auto why = not_enforced(p)) return std::unexpected(*why); + return p; +} + +std::vector diff(const Policy& a, const Policy& b) { + // Objects are walked; a list is one value (a reordered or extended grant + // list reads as one change, not as one line per shifted index). + std::map fa, fb; + std::function&)> flat = + [&](const nlohmann::json& j, const std::string& path, std::map& out) { + if (j.is_object()) { + for (auto it = j.begin(); it != j.end(); ++it) flat(it.value(), path + "/" + it.key(), out); + } else { + out[path] = j; + } + }; + flat(to_json(a), "", fa); + flat(to_json(b), "", fb); + std::vector out; + for (auto& [k, v] : fb) { + auto it = fa.find(k); + if (it == fa.end()) out.push_back(std::format("{}: (unset) -> {}", k, v.dump())); + else if (it->second != v) out.push_back(std::format("{}: {} -> {}", k, it->second.dump(), v.dump())); + } + for (auto& [k, v] : fa) + if (!fb.contains(k)) out.push_back(std::format("{}: {} -> (unset)", k, v.dump())); + return out; +} + +Decision decide(const Policy& p, const Op& op) { + const auto inst = op.instance.empty() ? std::string("") : op.instance; + if (op.kind == "policy_change" || op.kind == "instance_admin") { + if (op.from_inside) + return {Action::Deny, "only the owner, outside the sandbox, changes this", + "xlings subos config " + inst}; + return {Action::Allow, "owner"}; + } + if (op.kind == "grant") { + if (p.grants.contains(op.target)) return {Action::Allow, "granted by the policy"}; + if (p.grants_allowed.contains(op.target) && !op.from_inside) + return {Action::Allow, "grants_allowed"}; + return {Action::Deny, "not granted by this instance's policy", + std::format("xlings subos config {} --allow {}", inst, op.target)}; + } + auto to_decision = [&](Fetch f, std::string why) -> Decision { + const auto owner = std::format("xlings install {} --subos {}", op.target, inst); + switch (f) { + case Fetch::Auto: return {Action::Allow, std::move(why)}; + case Fetch::Layer: return {Action::Allow, why + " (into the instance's own layer)"}; + case Fetch::Ask: return {Action::Ask, std::move(why), owner}; + default: return {Action::Deny, std::move(why), owner}; + } + }; + if (op.kind == "index_update") + return to_decision(p.index_update, std::format("index_update = {}", to_string(p.index_update))); + if (op.kind == "fetch") { + for (std::size_t i = 0; i < p.fetch_rules.size(); ++i) { + const auto& r = p.fetch_rules[i]; + if (!glob_match(r.match, op.target)) continue; + if (!r.index.empty() && r.index != op.index) continue; + if (r.size_gt && !(op.size > r.size_gt)) continue; + return to_decision(r.action, std::format("fetch rule {} ({} -> {})", i + 1, r.match, + to_string(r.action))); + } + return to_decision(p.fetch, std::format("fetch = {}", to_string(p.fetch))); + } + return {Action::Deny, "unknown operation '" + op.kind + "'"}; +} + +} // namespace xlings::subos::policy + +namespace xlings::subos::policy { + +std::expected parse_mount(std::string_view spec, std::string_view home, + std::string_view cwd) { + std::vector parts; + std::size_t start = 0; + while (start <= spec.size()) { + auto c = spec.find(':', start); + if (c == std::string_view::npos) c = spec.size(); + parts.emplace_back(spec.substr(start, c - start)); + start = c + 1; + } + if (parts.empty() || parts[0].empty() || parts.size() > 3) + return std::unexpected(std::format("--mount {}: expected [:][:ro|rw]", spec)); + Mount m; + auto is_mode = [](const std::string& x) { return x == "ro" || x == "rw"; }; + m.src = parts[0]; + if (parts.size() == 2 && is_mode(parts[1])) { + m.rw = parts[1] == "rw"; + m.mode_given = true; + } else if (parts.size() >= 2) { + m.dst = parts[1]; + if (parts.size() == 3) { + if (!is_mode(parts[2])) + return std::unexpected(std::format("--mount {}: the mode is ro or rw", spec)); + m.rw = parts[2] == "rw"; + m.mode_given = true; + } + } + if (m.src == "~" || m.src.starts_with("~/")) m.src = std::string(home) + m.src.substr(1); + else if (m.src.front() != '/') m.src = std::string(cwd) + "/" + m.src; + m.src = std::filesystem::path(m.src).lexically_normal().generic_string(); + if (m.dst.starts_with("~/")) m.dst.clear(); // inside, ~ is not the host's home + return m; +} + +} // namespace xlings::subos::policy diff --git a/modules/subos/src/policy/policy.cppm b/modules/subos/src/policy/policy.cppm new file mode 100644 index 000000000..8f284d8ab --- /dev/null +++ b/modules/subos/src/policy/policy.cppm @@ -0,0 +1,231 @@ +export module xlings.subos.policy; + +import std; +import xlings.libs.json; + +// The policy model (design §7): what an instance is allowed, as data. +// +// Four layers produce one Policy -- a preset (dev / private / locked), the +// overrides a user sets, ordered rules, and policy packages -- and the +// compiler (xlings.subos.spec) turns it, with the host's capabilities, into +// the sandbox that actually runs. This module holds the TYPES and the values +// every layer starts from; reading policy.json, merging and decide() build on +// them. +export namespace xlings::subos::policy { + +enum class Preset { + Legacy, // an instance nobody declared a policy for: today's behaviour + // plus the S0 fixes (design §25 "无感升级") + Dev, // host files and other instances invisible, xlings tamper-proof + Private, // + network, desktop and identity isolation + Locked, // + no network, mounts read-only by default +}; + +enum class Net { Host, Nat, None, Proxy }; +enum class Fetch { Auto, Ask, Layer, Deny }; +enum class Observe { Off, Basic, Standard, Full }; +enum class Identity { Host, Neutral }; + +// How hard a requirement is. A missing `Must` refuses entry (exit 125); a +// missing `Should` degrades and says so -- unless the call asked for +// --no-degrade, which turns every Should into a Must. +enum class Need { Must, Should }; + +struct Mount { + std::string src; // host path, `~` expanded by the caller + std::string dst; // path inside; empty = same as src + bool rw { true }; + bool mode_given { false }; // `:ro` / `:rw` was written; otherwise the policy's default +}; + +// `--mount [:][:ro|rw]` (design §11, docker -v): a second segment +// that is exactly `ro` or `rw` is the mode (`~/.gitconfig:ro`). `home` expands +// a leading `~`; a relative host path is taken from `cwd`. +std::expected parse_mount(std::string_view spec, std::string_view home, + std::string_view cwd); + +// Named grants (design §21.2): each opens exactly one capability. +inline constexpr std::array kGrants{ + "display", "audio", "camera", "gpu", "ssh-agent", "dbus", "host-loopback"}; + +// First client that implements this policy schema; new files carry this floor. +inline constexpr std::string_view kPolicyMinClient = "2026.10.9.1"; + +struct Policy { + Preset preset { Preset::Legacy }; + Net net { Net::Host }; + std::string proxy; // for Net::Proxy + Fetch fetch { Fetch::Auto }; + Fetch index_update { Fetch::Auto }; + Observe observe { Observe::Basic }; + Identity identity { Identity::Host }; + std::string tz; // neutral identity: default "UTC" + + // What isolation each dimension needs; dimensions not listed are Should. + std::map> needs; + + // Environment variables that may enter, by name or `PREFIX*`. + std::vector env_pass; + bool env_inherit { false }; // Legacy: pass everything (pre-#640) + // Whether `--env K=V` may name any variable, or only env_pass ones. + bool env_explicit_any { true }; + + std::vector mounts; + std::set> grants; // granted now + std::set> grants_allowed; // may be granted per call + + bool disable_userns { false }; // forbid nested user namespaces + bool no_degrade { false }; + bool mounts_ro_default { false }; // locked: --mount defaults to ro + // Ordered fetch rules (design §7.3): the first that matches decides. + struct Rule { + std::string match { "*" }; // package glob, e.g. "xim:*" + std::string index; // only packages from this index + std::uint64_t size_gt { 0 }; // only packages larger than this + Fetch action { Fetch::Ask }; + }; + std::vector fetch_rules; + std::string extends; // what the file said it extends + // A policy package (design §7.3) the owner selected: the payload's file + // the instance's copy was made from, locked by sha256. The instance file + // carries the whole policy, so a package update changes nothing until + // the owner asks (`subos config --policy-upgrade`). + struct Package { + std::string from; // ns:name@version, as resolved + std::string sha256; // of the payload's policy.json + }; + std::optional package; + std::string min_client; // empty only in legacy policy files +}; + +// `ns:name[@version]`: a policy package, where a preset is one word. +bool is_package_ref(std::string_view extends); + +// A policy package's own file (the payload's policy.json) as the policy an +// instance selecting it gets. A package extends a built-in preset or +// nothing; one extending another package is refused. +std::expected from_package(const nlohmann::json& doc, std::string_view ref, + Policy::Package resolved); + +std::string_view to_string(Preset p); +std::string_view to_string(Net n); +std::string_view to_string(Fetch f); +std::string_view to_string(Observe o); +std::optional preset_from_string(std::string_view s); +std::optional net_from_string(std::string_view s); +std::optional fetch_from_string(std::string_view s); +std::optional observe_from_string(std::string_view s); + +// The environment every sandbox starts from, whatever the policy says: the +// variables a shell and the toolchains need to work and that name nothing +// about the host. Everything else must be passed by name. +inline constexpr std::array kBaseEnvPass{ + "TERM", "COLORTERM", "LANG", "LC_*", "TZ", "NO_COLOR", "XLINGS_AGENT_MODE", + "XLINGS_NON_INTERACTIVE", "XLINGS_TRACE"}; + +// What dev (and an undeclared instance) passes on top of the base: the +// variables people rely on for a working toolchain and network that name no +// credential. A secret, a socket into the desktop or an agent, and the X +// authority are exactly what is NOT here (#640 F3) -- they enter only when +// named in env_pass or through a grant. +inline constexpr std::array kDevEnvPass{ + "http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY", "no_proxy", "NO_PROXY", + "all_proxy", "ALL_PROXY", "EDITOR", "VISUAL", "PAGER", "COLUMNS", "LINES", + "SSL_CERT_FILE", "SSL_CERT_DIR", "XLINGS_RELEASE_MIRROR", "XLINGS_MIRROR"}; + +// The policy of an instance nobody declared one for: what it had before, plus +// the S0 fixes every sandbox gets (environment allow-list, pid / ipc / uts +// namespaces, no terminal injection). +Policy legacy(); + +// Shell-style wildcard match: `*` and `?`. +bool glob_match(std::string_view pattern, std::string_view text); + +// "2GB", "512MB", "100KB", "123" -> bytes; nullopt otherwise. +std::optional parse_size(std::string_view text); + +// Whether `name` matches an env_pass entry (exact, or `PREFIX*`). +bool env_name_matches(std::string_view name, std::span patterns); + +// ── Presets (design §10) ───────────────────────────────────────────── +// +// dev host files and other instances invisible, xlings tamper-proof, +// network as usual; what this host cannot do is reported, not fatal +// private + network (nat), desktop and identity isolation; the isolation +// it promises is required -- missing, it does not enter +// locked + no network, mounts read-only by default, fetch denied, every +// execution observed +Policy preset(Preset p); + +// ── The policy file (design §7.2) ──────────────────────────────────── +// +// /config/subos//policy.json -- outside the instance, read-only +// inside it. Parsing FAILS CLOSED: a field this version does not know, or a +// value it does not know (`"net": "vpn"`), is an error and the instance is +// not entered -- never ignored, because ignoring a stricter setting is +// loosening it. Writers keep keys they do not know (design §25); executors +// refuse them. Keys starting with "x-" and "comment" are free text. +std::expected from_json(const nlohmann::json& doc); +nlohmann::json to_json(const Policy& p); + +// Numeric three-part semver (including prerelease/build) or four-part date versions. +// Invalid input is refused, never compared lexicographically. +std::expected compare_client_versions(std::string_view lhs, std::string_view rhs); +std::expected check_client(const Policy& p, std::string_view version); + +// ── Per-call overrides (design §7.3) ───────────────────────────────── +// +// A single call may only TIGHTEN what the policy allows, or grant from +// `grants_allowed`. Anything else is refused with the reason. +struct Overrides { + std::optional net; + std::optional proxy; + std::optional fetch; + std::optional observe; + std::set> allow; // --allow + std::vector mounts; // --mount + bool no_degrade { false }; +}; + +std::expected apply(Policy p, const Overrides& o); + +// A value this version parses but does not enforce yet, named; nullopt when +// there is none (fail closed, design §7.3). +std::optional not_enforced(const Policy& p); + +// `fetch = layer` (design part 2 §3.3): what the xlings inside installs goes +// into the root's own system scope -- which only a rootfs SubOS has. A view +// of the host has no layer of its own, and granting it there would install +// into the shared home instead: such an instance refuses entry. +bool fetches_into_layer(const Policy& p); + +// Field-by-field differences, for the audit of a policy change. +std::vector diff(const Policy& before, const Policy& after); + +// ── The one decision (design §9) ───────────────────────────────────── + +enum class Action { Allow, Ask, Deny }; +std::string_view to_string(Action a); + +struct Op { + // fetch | index_update | grant | policy_change | instance_admin + std::string kind; + std::string target; // a package spec, a grant name + std::string index; // for fetch: the index it comes from + std::uint64_t size { 0 }; // for fetch: bytes, when known + bool from_inside { false }; // asked by the xlings inside the sandbox + std::string instance; // for the owner command in a refusal +}; + +struct Decision { + Action action { Action::Deny }; + std::string reason; + // The command a person outside runs to do it anyway, when there is one. + std::string owner_command; +}; + +// The client inside (for an early, friendly answer) and the broker outside +// (for the answer that counts) call THIS, with the same policy file. +Decision decide(const Policy& p, const Op& op); + +} // namespace xlings::subos::policy diff --git a/modules/subos/src/policy/policy_store.cpp b/modules/subos/src/policy/policy_store.cpp new file mode 100644 index 000000000..2778eeb91 --- /dev/null +++ b/modules/subos/src/policy/policy_store.cpp @@ -0,0 +1,129 @@ +module xlings.subos.policy_store; + +import std; +import xlings.libs.json; +import xlings.platform; +import xlings.subos.home_view; +import xlings.subos.policy; + +namespace xlings::subos::policy_store { + +namespace { + +bool missing(const fs::file_status& status, const std::error_code& error) { + return status.type() == fs::file_type::not_found + && (!error || error == std::errc::no_such_file_or_directory); +} + +std::expected, std::string> read_document(const fs::path& path) { + std::error_code ec; + const auto status = fs::symlink_status(path, ec); + if (missing(status, ec)) return std::optional{}; + if (ec) return std::unexpected(path.string() + ": " + ec.message()); + if (!fs::is_regular_file(status)) + return std::unexpected(path.string() + ": policy must be a regular file, not a symlink or directory -- refusing to enter"); + std::ifstream in(path, std::ios::binary); + if (!in) return std::unexpected(path.string() + ": cannot read policy -- refusing to enter"); + std::string text{std::istreambuf_iterator(in), {}}; + if (in.bad()) return std::unexpected(path.string() + ": policy read failed -- refusing to enter"); + auto doc = nlohmann::json::parse(text, nullptr, false); + if (doc.is_discarded() || !doc.is_object()) + return std::unexpected(path.string() + ": not a valid JSON object -- refusing to enter rather than guess"); + return std::optional{std::move(doc)}; +} + +} // namespace + +bool has_file(const HomeView& home, std::string_view instance) { + std::error_code ec; + const auto status = fs::symlink_status(home.policy_file(instance), ec); + return !missing(status, ec); +} + +std::expected, std::string> +read(const HomeView& home, std::string_view instance, std::string_view client_version) { + const auto path = home.policy_file(instance); + auto doc = read_document(path); + if (!doc) return std::unexpected(doc.error()); + if (!*doc) return std::optional{}; + auto p = policy::from_json(**doc); + if (!p) return std::unexpected(path.string() + ": " + p.error()); + if (!client_version.empty()) + if (auto supported = policy::check_client(*p, client_version); !supported) + return std::unexpected(path.string() + ": " + supported.error()); + return std::optional{std::move(*p)}; +} + +std::expected write(const HomeView& home, std::string_view instance, + const policy::Policy& p) { + const auto path = home.policy_file(instance); + auto existing = read_document(path); + if (!existing) return std::unexpected(existing.error()); + if (*existing) + if (auto valid = policy::from_json(**existing); !valid) + return std::unexpected(path.string() + ": " + valid.error()); + policy::Policy saved = p; + if (saved.min_client.empty()) saved.min_client = policy::kPolicyMinClient; + auto order = policy::compare_client_versions(saved.min_client, policy::kPolicyMinClient); + if (!order) return std::unexpected(order.error()); + if (*order < 0) saved.min_client = policy::kPolicyMinClient; + nlohmann::json doc = policy::to_json(saved); + if (*existing) { + const auto& old = **existing; + if (old.contains("min_client")) { + const auto old_order = policy::compare_client_versions(old["min_client"].get(), saved.min_client); + if (!old_order) return std::unexpected(old_order.error()); + if (*old_order > 0) doc["min_client"] = old["min_client"]; + } + for (auto it = old.begin(); it != old.end(); ++it) + if (!doc.contains(it.key())) doc[it.key()] = it.value(); + } + std::error_code ec; + fs::create_directories(path.parent_path(), ec); + if (ec) return std::unexpected("cannot create " + path.parent_path().string() + ": " + ec.message()); + try { + platform::write_file_atomic(path.string(), doc.dump(2) + '\n'); + } catch (const std::exception& error) { + return std::unexpected(error.what()); + } + return {}; +} + +int preset_rank(policy::Preset p) { + switch (p) { + case policy::Preset::Legacy: return 0; + case policy::Preset::Dev: return 1; + case policy::Preset::Private: return 2; + case policy::Preset::Locked: return 3; + } + return 0; +} + +std::expected +effective(const HomeView& home, std::string_view instance, + std::optional call_preset, const policy::Overrides& overrides, + std::string_view client_version) { + auto file = read(home, instance); + if (file && *file) + if (auto supported = policy::check_client(**file, client_version); !supported) + return std::unexpected(supported.error()); + if (!file) return std::unexpected(file.error()); + policy::Policy p; + if (*file) { + p = std::move(**file); + if (call_preset) { + if (preset_rank(*call_preset) < preset_rank(p.preset)) + return std::unexpected(std::format( + "--sandbox={} would loosen this instance's policy ({}); the owner changes it " + "with `xlings subos config {} --sandbox={}`", + policy::to_string(*call_preset), policy::to_string(p.preset), instance, + policy::to_string(*call_preset))); + if (preset_rank(*call_preset) > preset_rank(p.preset)) p = policy::preset(*call_preset); + } + } else { + p = call_preset ? policy::preset(*call_preset) : policy::legacy(); + } + return policy::apply(std::move(p), overrides); +} + +} // namespace xlings::subos::policy_store diff --git a/modules/subos/src/policy/policy_store.cppm b/modules/subos/src/policy/policy_store.cppm new file mode 100644 index 000000000..0ba3f8d60 --- /dev/null +++ b/modules/subos/src/policy/policy_store.cppm @@ -0,0 +1,41 @@ +export module xlings.subos.policy_store; + +import std; +import xlings.libs.json; +import xlings.subos.home_view; +import xlings.subos.policy; + +// Reading and writing an instance's policy file (design §7.2), and folding +// a single call's choices into it (design §7.3). +// +// 1. the file, when the instance has one: the owner's declaration, and it +// holds however the instance is entered ("安全默认落在实例上"); +// 2. a preset named on this call (--sandbox=), which may only be +// STRICTER than the file; +// 3. this call's overrides (--net, --allow, --mount ...), tighten-only. +// +// No file and no preset: Legacy -- the instance as it was, plus the S0 fixes. +export namespace xlings::subos::policy_store { + +namespace fs = std::filesystem; + +bool has_file(const HomeView& home, std::string_view instance); + +// The file's policy; nullopt when there is none; an error when it does not +// parse or names something this version cannot enforce (fail closed). +// A supplied client_version also enforces min_client; omitted means inspect only. +std::expected, std::string> +read(const HomeView& home, std::string_view instance, std::string_view client_version = {}); + +// Written atomically, outside the instance. +std::expected write(const HomeView& home, std::string_view instance, + const policy::Policy& p); + +int preset_rank(policy::Preset p); + +std::expected +effective(const HomeView& home, std::string_view instance, + std::optional call_preset, const policy::Overrides& overrides, + std::string_view client_version); + +} // namespace xlings::subos::policy_store diff --git a/modules/subos/src/sandbox/caps.cpp b/modules/subos/src/sandbox/caps.cpp new file mode 100644 index 000000000..d0488a9da --- /dev/null +++ b/modules/subos/src/sandbox/caps.cpp @@ -0,0 +1,174 @@ +module xlings.subos.caps; + +import std; +import xlings.platform; +import xlings.subos.home_view; +import xlings.subos.ports; +import xlings.subos.tools; +import xlings.libs.json; +import xlings.observe; + +namespace xlings::subos::caps { + +std::string_view platform_name() { + if constexpr (platform::is_windows) return "windows"; + else if constexpr (platform::is_macos) return "macos"; + else return "linux"; +} + +Backend backend_of(const tools::Found& f, bool usable = false) { + return Backend{ .name = f.tool, .bin = f.bin, .source = std::string(tools::to_string(f.source)), + .usable = usable }; +} + +std::optional payload_bwrap(const HomeView& home) { + for (const auto& f : tools::candidates("bwrap", home, Ports{})) + if (f.source == tools::Source::Payload) return backend_of(f); + return std::nullopt; +} + +namespace { + +std::string read_line(const char* path) { + std::ifstream in(path); + std::string v; + std::getline(in, v); + return v; +} + +// What a probe result depends on: this boot of this kernel (a sysctl or an +// AppArmor profile can change, a reboot resets both), and the binary itself +// -- its bytes, and its owner and mode: dropping a setuid bit or handing the +// file to root changes what it can do and not its mtime. +std::optional probe_context() { + std::error_code ec; + const auto userns = platform::read_symlink("/proc/self/ns/user", ec); + if (ec) return std::nullopt; + const auto mountns = platform::read_symlink("/proc/self/ns/mnt", ec); + if (ec) return std::nullopt; + std::ifstream status("/proc/self/status"); + if (!status) return std::nullopt; + std::string credentials, line; + while (std::getline(status, line)) + if (line.starts_with("Uid:") || line.starts_with("Gid:") || line.starts_with("CapEff:") || + line.starts_with("NoNewPrivs:") || line.starts_with("Seccomp:") || + line.starts_with("Seccomp_filters:")) credentials += line + '|'; + if (status.bad() || credentials.empty()) return std::nullopt; + return userns.string() + '|' + mountns.string() + '|' + credentials; +} + +std::string cache_key(const Backend& b, std::string_view context) { + std::error_code ec; + auto size = fs::file_size(b.bin, ec); + auto mtime = fs::last_write_time(b.bin, ec).time_since_epoch().count(); + const auto own = platform::file_ownership(b.bin).value_or(platform::FileOwnership{}); + const unsigned mode = own.mode, uid = own.uid; + return std::format("userns-v2|{}|{}|{}|{:o}|{}|{}|{}|{}|{}", b.bin.string(), size, mtime, mode, uid, + read_line("/proc/sys/kernel/osrelease"), + read_line("/proc/sys/kernel/random/boot_id"), + read_line("/proc/sys/kernel/apparmor_restrict_unprivileged_userns"), context); +} + +// state/isolation-caps.json (design §18): probe results per key. A probe +// is a process start per candidate per entry; on the hot path it is not. +void probe_cached(std::vector& all, const HomeView& home, bool fresh) { + // Host and producer have different namespace/credential authority, even + // when their backend bytes match. An unobserved context is never cached. + const auto context = probe_context(); + if (!context) { + for (auto& backend : all) probe_bwrap(backend); + return; + } + const auto file = home.caps_cache(); + std::error_code ec; + nlohmann::json cache = nlohmann::json::object(); + if (!fresh && fs::exists(file, ec)) { + std::ifstream in(file); + cache = nlohmann::json::parse(in, nullptr, false); + if (!cache.is_object()) cache = nlohmann::json::object(); + } + bool dirty = false; + for (auto& b : all) { + const auto key = cache_key(b, *context); + if (auto it = cache.find(key); it != cache.end() && it->is_object()) { + b.usable = it->value("usable", false); + b.probe_output = it->value("output", ""); + observe::trace("caps", std::format("{}: cached ({})", b.bin.string(), b.usable ? "usable" : "fails")); + continue; + } + probe_bwrap(b); + observe::trace("caps", std::format("{}: probed ({})", b.bin.string(), b.usable ? "usable" : "fails")); + cache[key] = {{"usable", b.usable}, {"output", b.probe_output}}; + dirty = true; + } + if (!dirty) return; + fs::create_directories(file.parent_path(), ec); + // Bounded: entries for binaries and boots long gone are not worth keeping. + if (cache.size() > 32) cache = nlohmann::json::object(); + for (auto& b : all) cache[cache_key(b, *context)] = {{"usable", b.usable}, {"output", b.probe_output}}; + auto tmp = fs::path(file.string() + ".tmp"); + std::ofstream(tmp) << cache.dump(); + fs::rename(tmp, file, ec); +} + +} // namespace + +std::vector bwrap_candidates(const HomeView& home, const Ports& ports, bool fresh) { + std::vector out; + if constexpr (!platform::is_linux) return out; + for (const auto& f : tools::candidates("bwrap", home, ports)) out.push_back(backend_of(f)); + probe_cached(out, home, fresh); + return out; +} + +std::optional locate_bwrap(const HomeView& home, const Ports& ports) { + auto all = bwrap_candidates(home, ports); + for (auto& b : all) if (b.usable) return b; + if (!all.empty()) return all.front(); + return std::nullopt; +} + +std::optional locate_proot(const HomeView& home, const Ports& ports) { + // PATH is not searched: a `proot` on PATH may be an xlings shim, and + // running it would move the whole session to whichever home owns it. + if (auto f = tools::first("proot", home, ports)) return backend_of(*f, true); + return std::nullopt; +} + +std::optional locate_pasta(const HomeView& home, const Ports& ports, std::string& why_not) { + auto found = tools::first("pasta", home, ports); + if (!found) { + why_not = "pasta (passt) is not installed"; + return std::nullopt; + } + std::error_code ec; + if (!fs::exists("/dev/net/tun", ec)) { + why_not = "/dev/net/tun is missing on this host"; + return std::nullopt; + } + return found->bin; +} + +void probe_bwrap(Backend& b) { + auto cmd = platform::shell_quote(b.bin.string()) + " --unshare-user --ro-bind / / -- /bin/true"; + auto [status, output] = platform::run_command_capture(cmd); + b.usable = status == 0; + b.probe_output = b.usable ? std::string{} : std::move(output); +} + +Caps probe(const HomeView& home, const Ports& ports) { + Caps c; + c.platform = std::string(platform_name()); + if constexpr (platform::is_linux) { + c.bwrap = locate_bwrap(home, ports); + c.proot = locate_proot(home, ports); + c.userns = c.bwrap && c.bwrap->usable; + c.pasta = locate_pasta(home, ports, c.pasta_missing); + c.seccomp = true; + c.landlock_abi = platform::landlock::abi(); + c.kernel = platform::kernel_release(); + } + return c; +} + +} // namespace xlings::subos::caps diff --git a/modules/subos/src/sandbox/caps.cppm b/modules/subos/src/sandbox/caps.cppm new file mode 100644 index 000000000..d112ed992 --- /dev/null +++ b/modules/subos/src/sandbox/caps.cppm @@ -0,0 +1,76 @@ +export module xlings.subos.caps; + +import std; +import xlings.subos.home_view; +import xlings.subos.ports; + +// What this host can do for isolation (design §18), measured, never assumed. +// +// The compiler decides what a sandbox gets from Policy + Caps; the platform +// matrix `subos status` prints is these probes (xlings.confine.gates). A +// capability is a FACT with its evidence: a backend that was found but does +// not work carries the probe's raw output, because "bwrap failed" without +// the reason is how #640's misleading sysctl advice happened. +export namespace xlings::subos::caps { + +namespace fs = std::filesystem; + +struct Backend { + std::string name; // "bwrap" | "proot" + fs::path bin; + // root-owned | payload | runtimedir | system -- where it was found + // (xlings.subos.tools, the one table of where a tool may come from); a + // machine's binary is used only when that table names its path. + std::string source; + bool usable { false }; // the probe passed + std::string probe_output; // raw stdout+stderr of a failed probe +}; + +struct Caps { + std::string platform; // linux | macos | windows + std::optional bwrap; + std::optional proot; + bool userns { false }; // unprivileged user namespaces work + int landlock_abi { 0 }; // 0 = no Landlock + std::optional pasta; // for net=nat (only when /dev/net/tun exists) + std::string pasta_missing; // why not, when it is not there + bool seccomp { false }; + std::string kernel; // uname release, for the cache key +}; + +// Where the backends are looked for, in order (design §20, #640 F10): +// bwrap 1. /usr/lib/xlings/bwrap, root-owned and not writable by others -- +// what `self doctor --isolation --fix` installs, with a narrow +// AppArmor profile that grants it user namespaces; +// 2. the system's /usr/bin/bwrap, /usr/local/bin/bwrap (not a shim), +// when its probe passes -- used, and reported as the host's; +// 3. /data/xpkgs/xim-x-bwrap/*/bin/bwrap. +// No setuid: xlings neither makes one nor relies on one. +// proot /data/xpkgs/xim-x-proot/*/bin/proot, /runtimedir/proot, +// then /usr/bin/proot, /usr/local/bin/proot when not a shim +inline constexpr std::string_view kRootOwnedBwrap = "/usr/lib/xlings/bwrap"; + +// Every bwrap found, in that order, each probed. The doctor reports them all. +// `fresh` probes every one again (the doctor's view) and refreshes the cache. +std::vector bwrap_candidates(const HomeView& home, const Ports& ports, bool fresh = false); +// The first usable one; otherwise the first found (with its probe output). +std::optional locate_bwrap(const HomeView& home, const Ports& ports); +// The payload only (what `xlings install bwrap` put there). +std::optional payload_bwrap(const HomeView& home); +std::optional locate_proot(const HomeView& home, const Ports& ports); + +// pasta (from passt) for net=nat: the payload, then the host's at the two +// usual paths when it is not a shim. Usable only with /dev/net/tun. +std::optional locate_pasta(const HomeView& home, const Ports& ports, std::string& why_not); + +// `bwrap --unshare-user --ro-bind / / -- /bin/true`; fills usable / probe_output. +void probe_bwrap(Backend& b); + +// Locate and probe everything. Cheap enough to run per entry today; the +// cache (state/isolation-caps.json) keys on kernel + boot + backend + version. +Caps probe(const HomeView& home, const Ports& ports); + +// The current platform's name. +std::string_view platform_name(); + +} // namespace xlings::subos::caps diff --git a/modules/subos/src/sandbox/elevation.cpp b/modules/subos/src/sandbox/elevation.cpp new file mode 100644 index 000000000..94ee1a09d --- /dev/null +++ b/modules/subos/src/sandbox/elevation.cpp @@ -0,0 +1,20 @@ +module xlings.subos.elevation; + +import std; +import xlings.libs.json; +import xlings.platform; +import xlings.observe; +import xlings.subos.home_view; + +namespace xlings::subos::elevation { + +int run(const HomeView& home, const std::vector& argv, std::string_view purpose) { + const bool already = platform::is_elevated(); + const int rc = platform::run_elevated(argv); + observe::append_json(home.home / "logs" / "elevation.ndjson", + nlohmann::json{{"ts", observe::utc_now()}, {"purpose", std::string(purpose)}, + {"argv", argv}, {"exit", rc}, {"already_elevated", already}}); + return rc; +} + +} // namespace xlings::subos::elevation diff --git a/modules/subos/src/sandbox/elevation.cppm b/modules/subos/src/sandbox/elevation.cppm new file mode 100644 index 000000000..3d66a3af3 --- /dev/null +++ b/modules/subos/src/sandbox/elevation.cppm @@ -0,0 +1,15 @@ +export module xlings.subos.elevation; + +import std; +import xlings.subos.home_view; + +// Running something as the administrator, said and recorded (SubOS design +// part 3 §6.4). Every command the SubOS code runs with administrator rights +// goes through here: it is run as argv (no shell), and a line naming what, +// why and how it ended is appended to /logs/elevation.ndjson -- the +// record a later "who changed this machine" question reads first. +export namespace xlings::subos::elevation { + +int run(const HomeView& home, const std::vector& argv, std::string_view purpose); + +} // namespace xlings::subos::elevation diff --git a/src/core/subos/gpu.cppm b/modules/subos/src/sandbox/gpu.cppm similarity index 99% rename from src/core/subos/gpu.cppm rename to modules/subos/src/sandbox/gpu.cppm index 687a92b20..56ff74028 100644 --- a/src/core/subos/gpu.cppm +++ b/modules/subos/src/sandbox/gpu.cppm @@ -19,7 +19,7 @@ module; #include -export module xlings.core.subos.gpu; +export module xlings.subos.gpu; import std; diff --git a/src/core/subos/graphics.cpp b/modules/subos/src/sandbox/graphics.cpp similarity index 99% rename from src/core/subos/graphics.cpp rename to modules/subos/src/sandbox/graphics.cpp index 251758ff0..51f0310b6 100644 --- a/src/core/subos/graphics.cpp +++ b/modules/subos/src/sandbox/graphics.cpp @@ -60,7 +60,7 @@ // new place. // // Refs: .agents/docs/2026-08-10-graphics-stack-architecture-review-and-plan.md -module xlings.core.subos.graphics; +module xlings.subos.graphics; import std; diff --git a/src/core/subos/graphics.cppm b/modules/subos/src/sandbox/graphics.cppm similarity index 99% rename from src/core/subos/graphics.cppm rename to modules/subos/src/sandbox/graphics.cppm index 8ba82b711..756d17687 100644 --- a/src/core/subos/graphics.cppm +++ b/modules/subos/src/sandbox/graphics.cppm @@ -60,7 +60,7 @@ // new place. // // Refs: .agents/docs/2026-08-10-graphics-stack-architecture-review-and-plan.md -export module xlings.core.subos.graphics; +export module xlings.subos.graphics; import std; diff --git a/modules/subos/src/sandbox/network.cpp b/modules/subos/src/sandbox/network.cpp new file mode 100644 index 000000000..7fb27a6b7 --- /dev/null +++ b/modules/subos/src/sandbox/network.cpp @@ -0,0 +1,313 @@ +module xlings.subos.network; + +import std; +import xlings.platform; + +namespace xlings::subos::network { + +namespace net = platform::network; +using Clock = std::chrono::steady_clock; + +std::expected parse_proxy(std::string_view url) { + constexpr std::string_view PREFIX { "socks5h://" }; + if (!url.starts_with(PREFIX)) return std::unexpected("net=proxy requires socks5h://HOST:PORT"); + auto authority = url.substr(PREFIX.size()); + if (authority.ends_with('/')) authority.remove_suffix(1); + if (authority.empty() || authority.find_first_of("/@?#\\") != authority.npos + || std::ranges::any_of(authority, [](unsigned char c) { return c <= 32 || c >= 127; })) + return std::unexpected("proxy URL needs a host and port; credentials and paths are unsupported"); + std::string_view host, port; + if (authority.front() == '[') { + const auto end = authority.find(']'); + if (end == authority.npos || end + 1 >= authority.size() || authority[end + 1] != ':') + return std::unexpected("IPv6 proxy needs [ADDRESS]:PORT"); + host = authority.substr(1, end - 1); + port = authority.substr(end + 2); + } else { + const auto colon = authority.find(':'); + if (colon == authority.npos || colon != authority.rfind(':')) + return std::unexpected("proxy needs HOST:PORT (bracket IPv6 addresses)"); + host = authority.substr(0, colon); + port = authority.substr(colon + 1); + } + unsigned value { 0 }; + const auto [end, error] = std::from_chars(port.data(), port.data() + port.size(), value); + if (host.empty() || error != std::errc{} || end != port.data() + port.size() || value == 0 || value > 65535) + return std::unexpected("proxy needs a nonempty host and port in 1..65535"); + return Proxy{std::string(host), static_cast(value)}; +} + +std::expected resolve_proxy(std::string_view url) { + const auto parsed = parse_proxy(url); + if (!parsed) return std::unexpected(parsed.error()); + const auto addresses = net::resolve_proxy(parsed->host, parsed->port); + if (!addresses) return std::unexpected(addresses.error()); + return ResolvedProxy{*parsed, *addresses}; +} + +namespace { + +constexpr std::size_t MAX_BUFFER { 1024 * 1024 }; +constexpr std::size_t MAX_CLIENTS { 128 }; +unsigned byte(std::string_view data, std::size_t index) { return static_cast(data[index]); } +std::string failure(unsigned code) { return std::string{'\5', static_cast(code), '\0', '\1', '\0', '\0', '\0', '\0', '\0', '\0'}; } + +struct Target { std::string host; unsigned port { 0 }; bool domain { false }; std::size_t size { 0 }; }; +// Wire addresses are never resolved here. Domain bytes are forwarded to SOCKS5h. +std::expected, std::string> target(std::string_view input) { + if (input.size() < 4) return std::nullopt; + if (byte(input, 0) != 5 || byte(input, 1) != 1 || byte(input, 2) != 0) + return std::unexpected("only SOCKS5 CONNECT is permitted"); + Target result; + const auto type = byte(input, 3); + std::size_t offset { 4 }, length { 0 }; + if (type == 1) length = 4; + else if (type == 4) length = 16; + else if (type == 3) { + if (input.size() < 5) return std::nullopt; + length = byte(input, 4); + offset = 5; + result.domain = true; + if (length == 0) return std::unexpected("SOCKS target domain is empty"); + } else return std::unexpected("unsupported SOCKS address type"); + if (input.size() < offset + length + 2) return std::nullopt; + if (type == 3) { + result.host = input.substr(offset, length); + if (std::ranges::any_of(result.host, [](unsigned char c) { return c <= 32 || c >= 127; })) + return std::unexpected("SOCKS target domain contains invalid bytes"); + } else if (type == 1) { + result.host = std::format("{}.{}.{}.{}", byte(input, 4), byte(input, 5), byte(input, 6), byte(input, 7)); + } else { + for (std::size_t index = 4; index < 20; index += 2) { + if (!result.host.empty()) result.host += ':'; + result.host += std::format("{:x}", byte(input, index) * 256 + byte(input, index + 1)); + } + } + result.port = byte(input, offset + length) * 256 + byte(input, offset + length + 1); + if (result.port == 0) return std::unexpected("SOCKS target port must be positive"); + result.size = offset + length + 2; + return std::optional{std::move(result)}; +} + +bool flush(int fd, std::string& output) { + while (!output.empty()) { + const auto sent = net::send(fd, output); + if (!sent || (sent->has_value() && **sent == 0)) return false; + if (!sent->has_value()) return true; + output.erase(0, **sent); + } + return true; +} +bool read_into(int fd, std::string& input, bool& ended) { + if (ended) return true; + std::array bytes{}; + while (input.size() < MAX_BUFFER) { + const auto size = std::min(bytes.size(), MAX_BUFFER - input.size()); + const auto read = net::receive(fd, std::span(bytes.data(), size)); + if (!read) return false; + if (!read->has_value()) return true; + if (**read == 0) { ended = true; return true; } + input.append(bytes.data(), **read); + } + return true; +} + +} // namespace + +struct Relay::Impl { + enum class Stage { Greeting, Request, Connecting, ProxyMethod, ProxyReply, Forward, Closing }; + struct Peer { + std::uint64_t id { 0 }; + int client { -1 }, remote { -1 }; + Stage stage { Stage::Greeting }; + Target requested; + std::string request, clientInput, clientOutput, remoteInput, remoteOutput; + bool clientEnded { false }, remoteEnded { false }; + bool clientShutdown { false }, remoteShutdown { false }; + std::size_t candidate { 0 }; + Clock::time_point deadline { Clock::now() + std::chrono::seconds(10) }; + ~Peer() { platform::close_fd(client); platform::close_fd(remote); } + }; + ResolvedProxy proxy; + std::vector> peers; + std::uint64_t next { 1 }; + + explicit Impl(ResolvedProxy p) : proxy{std::move(p)} {} + Event event(const Peer& p, std::string name, std::string reason = {}) const { + return {p.id, std::move(name), p.requested.host, p.requested.port, p.requested.domain, std::move(reason)}; + } + void fail(Peer& p, const std::function& audit, unsigned code, std::string reason) { + const bool recorded = audit(event(p, "denied", std::move(reason))); + if (recorded) p.clientOutput += failure(code); + else p.clientOutput.clear(); + p.stage = Stage::Closing; + p.deadline = Clock::now() + std::chrono::seconds(2); + platform::close_fd(p.remote); + p.remote = -1; + } + bool dial(Peer& p) { + while (p.candidate < proxy.addresses.size()) { + const auto connection = net::connect_to(proxy.addresses[p.candidate++]); + if (!connection) continue; + p.remote = connection->fd; + p.stage = connection->ready ? Stage::ProxyMethod : Stage::Connecting; + p.remoteOutput = std::string{'\5', '\1', '\0'}; + return true; + } + return false; + } + bool step(Peer& p, const std::function& audit) { + if (p.stage == Stage::Closing) + return Clock::now() < p.deadline && flush(p.client, p.clientOutput) && !p.clientOutput.empty(); + if (p.stage != Stage::Forward && Clock::now() >= p.deadline) { + fail(p, audit, 6, "proxy handshake timed out"); + return true; + } + if (!flush(p.client, p.clientOutput)) return false; + if (p.stage == Stage::Greeting || p.stage == Stage::Request) { + if (!read_into(p.client, p.clientInput, p.clientEnded) || p.clientEnded) return false; + } + if (p.stage == Stage::Greeting) { + if (p.clientInput.size() < 2) return true; + const auto methods = byte(p.clientInput, 1); + if (p.clientInput.size() < methods + 2) return true; + if (byte(p.clientInput, 0) != 5 || methods == 0 + || p.clientInput.substr(2, methods).find('\0') == std::string::npos) { + (void)audit(event(p, "denied", "SOCKS5 no-auth method required")); + p.clientOutput = std::string{'\5', static_cast(255)}; + p.stage = Stage::Closing; + return true; + } + p.clientInput.erase(0, methods + 2); + p.clientOutput = std::string{'\5', '\0'}; + p.stage = Stage::Request; + } + if (p.stage == Stage::Request) { + const auto parsed = target(p.clientInput); + if (!parsed) { fail(p, audit, 7, parsed.error()); return true; } + if (!parsed->has_value()) return true; + p.requested = **parsed; + p.request = p.clientInput.substr(0, p.requested.size); + p.clientInput.erase(0, p.requested.size); + if (!audit(event(p, "connect-attempt"))) { p.clientOutput.clear(); return false; } + if (!dial(p)) { fail(p, audit, 5, "declared proxy is unreachable"); return true; } + } + if (p.stage == Stage::Connecting) { + const auto ready = net::connected(p.remote); + if (!ready) { + platform::close_fd(p.remote); + p.remote = -1; + if (!dial(p)) { fail(p, audit, 5, ready.error()); return true; } + } else if (ready->has_value()) p.stage = Stage::ProxyMethod; + if (p.stage == Stage::Connecting) return true; + } + if (p.stage == Stage::ProxyMethod || p.stage == Stage::ProxyReply) { + if (!flush(p.remote, p.remoteOutput) || !read_into(p.remote, p.remoteInput, p.remoteEnded) + || p.remoteEnded) { fail(p, audit, 5, "declared proxy closed its handshake"); return true; } + } + if (p.stage == Stage::ProxyMethod) { + if (p.remoteInput.size() < 2) return true; + if (byte(p.remoteInput, 0) != 5 || byte(p.remoteInput, 1) != 0) { + fail(p, audit, 2, "declared proxy does not accept no-auth SOCKS5"); return true; + } + p.remoteInput.erase(0, 2); + p.remoteOutput = p.request; + p.stage = Stage::ProxyReply; + return true; + } + if (p.stage == Stage::ProxyReply) { + if (p.remoteInput.size() < 4) return true; + const auto status = byte(p.remoteInput, 1); + auto normalized = p.remoteInput; + normalized[1] = '\1'; + // The reply has the request's address framing; port zero is valid + // for BND.PORT. Validate framing independently of a target port. + std::size_t size { 0 }; + const auto type = byte(normalized, 3); + if (type == 1) size = 10; + else if (type == 4) size = 22; + else if (type == 3) { + if (normalized.size() < 5) return true; + size = 7 + byte(normalized, 4); + } else { fail(p, audit, 1, "declared proxy sent an invalid address type"); return true; } + if (normalized.size() < size) return true; + if (byte(normalized, 0) != 5 || byte(normalized, 2) != 0) { + fail(p, audit, 1, "declared proxy sent a malformed reply"); return true; + } + if (!audit(event(p, "proxy-result", status == 0 ? "accepted" : "refused:" + std::to_string(status)))) + return false; + p.clientOutput += p.remoteInput.substr(0, size); + p.remoteInput.erase(0, size); + if (status != 0) { p.stage = Stage::Closing; return true; } + p.stage = Stage::Forward; + } + if (p.stage == Stage::Forward) { + p.remoteOutput += std::exchange(p.clientInput, {}); + p.clientOutput += std::exchange(p.remoteInput, {}); + if (!flush(p.remote, p.remoteOutput) || !flush(p.client, p.clientOutput)) return false; + if (!read_into(p.client, p.remoteOutput, p.clientEnded) + || !read_into(p.remote, p.clientOutput, p.remoteEnded)) return false; + if (p.clientEnded && p.remoteOutput.empty() && !p.remoteShutdown) { + (void)net::shutdown_write(p.remote); + p.remoteShutdown = true; + } + if (p.remoteEnded && p.clientOutput.empty() && !p.clientShutdown) { + (void)net::shutdown_write(p.client); + p.clientShutdown = true; + } + return !(p.clientEnded && p.remoteEnded && p.clientOutput.empty() && p.remoteOutput.empty()); + } + return true; + } +}; + +Relay::Relay(ResolvedProxy proxy) : impl_{std::make_unique(std::move(proxy))} {} +Relay::Relay(Relay&&) noexcept = default; +Relay& Relay::operator=(Relay&&) noexcept = default; +Relay::~Relay() = default; +bool Relay::add(int client) { + if (!impl_ || impl_->peers.size() >= MAX_CLIENTS) { platform::close_fd(client); return false; } + auto peer = std::make_unique(); + peer->id = impl_->next++; + peer->client = client; + impl_->peers.push_back(std::move(peer)); + return true; +} +std::vector Relay::fds() const { + std::vector fds; + if (!impl_) return fds; + for (const auto& peer : impl_->peers) { + if (!peer->clientEnded) fds.push_back({peer->client}); + if (peer->remote >= 0 && !peer->remoteEnded) fds.push_back({peer->remote}); + } + return fds; +} +void Relay::tick(const std::function& audit) { + if (!impl_) return; + std::erase_if(impl_->peers, [&](const auto& peer) { return !impl_->step(*peer, audit); }); +} +void Relay::stop() { impl_.reset(); } + +int gateway_run(int channel, int listener) { + for (;;) { + std::array watched{{{channel}, {listener}}}; + if (platform::poll_fds(watched, 1000) < 0) return 125; + if (watched[0].closed || watched[0].readable) { + // The supervisor has no reason to send data. Readiness is its + // shutdown/EOF, so the gate never outlives its owner. + return 0; + } + if (!watched[1].readable) continue; + for (;;) { + const auto client = net::accept(listener); + if (!client) return 125; + if (!client->has_value()) break; + const int fd = **client; + const bool sent = platform::send_message(channel, "proxy-client", std::span(&fd, 1)); + platform::close_fd(fd); + if (!sent) return 0; + } + } +} + +} // namespace xlings::subos::network diff --git a/modules/subos/src/sandbox/network.cppm b/modules/subos/src/sandbox/network.cppm new file mode 100644 index 000000000..d29808e22 --- /dev/null +++ b/modules/subos/src/sandbox/network.cppm @@ -0,0 +1,56 @@ +export module xlings.subos.network; + +import std; +import xlings.platform; + +export namespace xlings::subos::network { + +inline constexpr std::uint16_t GATEWAY_PORT { 1080 }; +struct Proxy { + std::string host; + std::uint16_t port { 0 }; +}; +// No authentication, BIND, UDP ASSOCIATE or local target-name resolution. +// Credentials in the URL are rejected rather than logged or guessed. +std::expected parse_proxy(std::string_view url); +struct ResolvedProxy { + Proxy declared; + std::vector addresses; +}; +std::expected resolve_proxy(std::string_view url); + +struct Event { + std::uint64_t connection { 0 }; + std::string event; // connect-attempt | proxy-result | denied + std::string target; + unsigned port { 0 }; + bool remote_dns { false }; // SOCKS domain passed as-is to the declared proxy + std::string reason; +}; + +class Relay { +private: + struct Impl; + std::unique_ptr impl_; + +public: + explicit Relay(ResolvedProxy proxy); + Relay(Relay&&) noexcept; + Relay& operator=(Relay&&) noexcept; + Relay(const Relay&) = delete; + ~Relay(); + // Takes ownership of accepted socket descriptors from the private netns. + bool add(int client); + std::vector fds() const; + // Every outbound attempt is audited before connecting; result is audited + // before acknowledging the SOCKS CONNECT. False terminates that stream. + void tick(const std::function& audit); + void stop(); +}; + +// Runs in a dedicated process inside an already private, loopback-only netns. +// Sends accepted fds over the private control channel; never opens an uplink. +// The channel is created before isolation and never reaches sandbox commands. +int gateway_run(int channel, int listener); + +} // namespace xlings::subos::network diff --git a/modules/subos/src/sandbox/spec.cpp b/modules/subos/src/sandbox/spec.cpp new file mode 100644 index 000000000..f3ea7e25f --- /dev/null +++ b/modules/subos/src/sandbox/spec.cpp @@ -0,0 +1,92 @@ +module xlings.subos.spec; + +import std; +import xlings.libs.json; +import xlings.subos.home_view; +import xlings.subos.policy; +import xlings.subos.network; + +namespace xlings::subos::spec { + +std::string_view to_string(Backend b) { + switch (b) { + case Backend::Bwrap: return "bwrap"; + case Backend::Proot: return "proot"; + case Backend::HomeRedirect: return "home-redirect"; + case Backend::Landlock: return "landlock"; + default: return "fake"; + } +} + +std::string_view to_string(Storage s) { + switch (s) { + case Storage::Image: return "image"; + case Storage::Tmpfs: return "tmpfs"; + default: return "shared"; + } +} + +std::optional storage_from_string(std::string_view s) { + if (s == "shared") return Storage::Shared; + if (s == "image") return Storage::Image; + if (s == "tmpfs") return Storage::Tmpfs; + return std::nullopt; +} + +namespace { + +std::string_view kind_name(MountKind k) { + switch (k) { + case MountKind::RoBind: return "ro-bind"; + case MountKind::Bind: return "bind"; + case MountKind::DevBind: return "dev-bind"; + case MountKind::Tmpfs: return "tmpfs"; + case MountKind::Proc: return "proc"; + case MountKind::Dev: return "dev"; + case MountKind::Dir: return "dir"; + } + return "?"; +} + +} // namespace + +nlohmann::json SandboxSpec::describe() const { + nlohmann::json j; + j["backend"] = std::string(to_string(backend)); + j["mounts"] = nlohmann::json::array(); + for (auto& m : mounts) { + nlohmann::json e{{"kind", std::string(kind_name(m.kind))}, {"dst", m.dst}}; + if (!m.src.empty()) e["src"] = m.src; + j["mounts"].push_back(std::move(e)); + } + j["unshare"] = {{"user", unshare_user}, {"pid", unshare_pid}, {"ipc", unshare_ipc}, + {"uts", unshare_uts}, {"net", unshare_net}}; + j["disable_userns"] = disable_userns; + if (net_nat) j["net"] = {{"mode", "nat"}, {"host_loopback", host_loopback}, {"publish", publish}}; + if (net_proxy) { + const auto proxy = network::parse_proxy(proxy_url); + j["net"] = {{"mode", "proxy"}, {"gateway", "socks5h://127.0.0.1:1080"}, {"dns", "remote"}}; + if (proxy) { j["net"]["proxy_host"] = proxy->host; j["net"]["proxy_port"] = proxy->port; } + } + j["die_with_parent"] = die_with_parent; + j["new_session"] = new_session; + j["block_tiocsti"] = block_tiocsti; + if (!hostname.empty()) j["hostname"] = hostname; + if (uid) j["uid"] = *uid; + j["clear_env"] = clear_env; + // Names only: values can hold secrets, and this lands in audits. + j["env"] = nlohmann::json::array(); + for (auto& [k, v] : env) j["env"].push_back(k); + if (backend == Backend::Landlock) { + j["landlock_rw"] = nlohmann::json::array(); + for (auto& p : landlock_rw) j["landlock_rw"].push_back(p.generic_string()); + } + j["cwd"] = cwd.string(); + j["argv"] = argv; + j["degraded"] = nlohmann::json::array(); + for (auto& d : degraded) + j["degraded"].push_back({{"dimension", d.dimension}, {"reason", d.reason}, {"fix", d.fix}}); + return j; +} + +} // namespace xlings::subos::spec diff --git a/modules/subos/src/sandbox/spec.cppm b/modules/subos/src/sandbox/spec.cppm new file mode 100644 index 000000000..a43109262 --- /dev/null +++ b/modules/subos/src/sandbox/spec.cppm @@ -0,0 +1,121 @@ +export module xlings.subos.spec; + +import std; +import xlings.libs.json; +import xlings.subos.home_view; +import xlings.subos.policy; + +// What a sandbox is, as data (design §16). xlings.confine compiles a policy +// and the host's capabilities into one (part 3 §6.2). +// +// The spec is pure data: every mount in order, every namespace, the whole +// environment, the identity, the command. A provider translates it and +// nothing else (bwrap argv, proot argv, an environment for home-redirect); +// a provider never decides what a sandbox gets. That is what makes isolation +// testable without a sandbox -- the unit tests compare compiled specs -- and +// it is the only place a security default lives. +export namespace xlings::subos::spec { + +namespace fs = std::filesystem; + +enum class Backend { Bwrap, Proot, HomeRedirect, Landlock, Fake }; +enum class Storage { Shared, Image, Tmpfs }; + +std::string_view to_string(Backend b); +std::string_view to_string(Storage s); +std::optional storage_from_string(std::string_view s); + +enum class MountKind { + RoBind, // host path, read-only + Bind, // host path, read-write + DevBind, // device node + Tmpfs, // empty, private, gone at exit + Proc, // a /proc for the sandbox's pid namespace + Dev, // a minimal /dev + Dir, // an empty directory +}; + +struct MountOp { + MountKind kind; + std::string src; // host side (binds) + std::string dst; // inside + bool operator==(const MountOp&) const = default; +}; + +// A requirement the host could not meet. +struct Unmet { + std::string dimension; // "fs", "pid", "net", "identity", ... + std::string reason; + std::string fix; // least-privilege remedy, a command when there is one + policy::Need need { policy::Need::Should }; +}; + +struct Request { + std::string instance; // its name + fs::path instance_dir; // /subos/ + std::string user; // login name + std::vector argv; // empty = the user's shell + std::string shell { "/bin/sh" }; // $SHELL + bool interactive { false }; // stdin is a terminal + Storage storage { Storage::Shared }; + fs::path image_mountpoint; // Storage::Image + std::optional preferred; // `--sandbox bwrap|proot` + std::set> grants; // per-call --allow (and legacy --gpu) + std::map host_env; + // `--env K=V`: set explicitly by the caller, not inherited. Bounded by + // the policy (Policy::env_explicit_any). + std::map explicit_env; + std::string cwd; // inside; empty = the user's home + std::vector publish; // --publish HOST:SANDBOX (net=nat) + // A rootfs instance (design part 2 §3.1): the tree that is `/` inside, + // /rootfs. Empty for a view of the host. + fs::path root; + // Host facts the compiler must not read itself, so a test can pin them. + std::function host_exists; + std::vector root_mounts; // Owner-checked, private skeleton and exact RO leaves. +}; + +struct SandboxSpec { + Backend backend { Backend::Fake }; + fs::path backend_bin; + std::vector mounts; // in application order + bool unshare_user { false }; + bool unshare_pid { false }; + bool unshare_ipc { false }; + bool unshare_uts { false }; + bool unshare_net { false }; + bool net_nat { false }; // a private network, egress through pasta + bool net_proxy { false }; // loopback-only netns, declared SOCKS5h exit + std::string proxy_url; + fs::path pasta_bin; + bool host_loopback { false }; // grant: reach the host's own services + std::vector publish; // "8080:80" host:sandbox TCP ports + bool disable_userns { false }; + bool die_with_parent { false }; + bool new_session { false }; + bool block_tiocsti { false }; + std::string hostname; + // The ids inside the user namespace (bwrap --uid/--gid): a rootfs + // instance runs as its own root, as a container does. + std::optional uid; + std::optional gid; + // clear_env: the sandbox sees exactly `env`. Otherwise the caller's + // environment is inherited and `env` is applied on top (Legacy). + bool clear_env { false }; + std::map env; + fs::path cwd; // inside + std::vector argv; // the command inside + fs::path proot_root; // Backend::Proot only + // Backend::Landlock: the only places the command may write. Everything + // else on the host stays readable -- it is a write fence, not a view. + std::vector landlock_rw; + std::vector degraded; // Should-items not met + + nlohmann::json describe() const; // for status, audit, golden tests +}; + +struct Refusal { + std::vector missing; // Must-items not met +}; + +} // namespace xlings::subos::spec diff --git a/modules/subos/src/sandbox/tools.cpp b/modules/subos/src/sandbox/tools.cpp new file mode 100644 index 000000000..d7323457e --- /dev/null +++ b/modules/subos/src/sandbox/tools.cpp @@ -0,0 +1,144 @@ +module xlings.subos.tools; + +import std; +import xlings.platform; +import xlings.subos.home_view; +import xlings.subos.ports; + +namespace xlings::subos::tools { + +namespace { + +// Where each tool may come from, in order. `kRootOwned` only for bwrap: what +// `self doctor --isolation --fix` installs, root's and writable by nobody +// else, with an AppArmor profile that grants it user namespaces. +struct Location { + Source source; + std::string_view where; // a path (RootOwned, Host), a bin dir name (Payload) +}; +struct Entry { + Tool tool; + std::string_view xpkgs_dir; // data/xpkgs/ + std::vector order; +}; + +const std::vector& table() { + static const std::vector t{ + {{"bwrap", "bwrap"}, "xim-x-bwrap", + {{Source::RootOwned, "/usr/lib/xlings/bwrap"}, + {Source::Host, "/usr/bin/bwrap"}, {Source::Host, "/usr/local/bin/bwrap"}, + {Source::Payload, "bin"}}}, + {{"proot", "proot"}, "xim-x-proot", + {{Source::Payload, "bin"}, {Source::Runtimedir, "proot"}, + {Source::Host, "/usr/bin/proot"}, {Source::Host, "/usr/local/bin/proot"}}}, + {{"pasta", "passt"}, "xim-x-passt", + {{Source::Payload, "bin"}, {Source::Host, "/usr/bin/pasta"}, {Source::Host, "/usr/local/bin/pasta"}}}, + // The machine's copy, for its copy-on-write flags (--reflink, + // APFS clonefile); a SubOS fork falls back to std::filesystem. + {{"cp", ""}, "", + {{Source::Host, "/bin/cp"}, {Source::Host, "/usr/bin/cp"}}}, + // The VMM the vz carrier drives (part 3 §5.4): a signed helper with + // Virtualization.framework's entitlement, as a payload. + {{"xlings-vm", "xlings-vm"}, "xim-x-xlings-vm", + {{Source::Payload, "bin"}}}, + {{"mkfs.ext4", "e2fsprogs"}, "xim-x-e2fsprogs", + {{Source::Payload, "sbin"}, {Source::Payload, "bin"}, + {Source::Host, "/usr/sbin/mkfs.ext4"}, {Source::Host, "/sbin/mkfs.ext4"}, + {Source::Host, "/usr/bin/mkfs.ext4"}}}, + }; + return t; +} + +const Entry* entry(std::string_view name) { + for (const auto& e : table()) if (e.tool.name == name) return &e; + return nullptr; +} + +// Sentinel iteration: a range-for over a directory_iterator through a BMI +// fails to link on the musl cross toolchain (see caps.cpp's history). +std::optional payload_bin(const fs::path& root, std::string_view dir, std::string_view bin) { + std::error_code ec; + if (!fs::is_directory(root, ec)) return std::nullopt; + std::vector versions; + for (auto it = fs::directory_iterator(root, ec); !ec && it != std::default_sentinel; it.increment(ec)) + versions.push_back(it->path()); + std::ranges::sort(versions); + for (const auto& v : versions) { + const auto candidate = v / std::string(dir) / std::string(bin); + std::error_code fe; + if (fs::is_regular_file(candidate, fe)) return candidate; + } + return std::nullopt; +} + +} // namespace + +std::string_view to_string(Source s) { + switch (s) { + case Source::RootOwned: return "root-owned"; + case Source::Payload: return "payload"; + case Source::Runtimedir: return "runtimedir"; + // "system", as `self doctor --isolation --json` has always said it. + case Source::Host: return "system"; + } + return "system"; +} + +std::span known() { + static const std::vector tools = [] { + std::vector out; + for (const auto& e : table()) out.push_back(e.tool); + return out; + }(); + return tools; +} + +std::vector candidates(std::string_view tool, const HomeView& home, const Ports& ports) { + std::vector out; + const auto* e = entry(tool); + if (!e) return out; + std::error_code ec; + for (const auto& loc : e->order) { + switch (loc.source) { + case Source::RootOwned: { + if constexpr (!platform::is_linux) break; + const fs::path p(loc.where); + if (auto own = platform::file_ownership(p); own && own->uid == 0 && !(own->mode & 022)) + out.push_back({std::string(tool), p, Source::RootOwned}); + break; + } + case Source::Payload: + if (auto bin = payload_bin(home.home / "data" / "xpkgs" / std::string(e->xpkgs_dir), loc.where, tool)) + if (std::ranges::none_of(out, [&](const Found& f) { return f.bin == *bin; })) + out.push_back({std::string(tool), *bin, Source::Payload}); + break; + case Source::Runtimedir: { + const auto p = home.home / "runtimedir" / std::string(loc.where); + if (fs::is_regular_file(p, ec)) out.push_back({std::string(tool), p, Source::Runtimedir}); + break; + } + case Source::Host: { + const fs::path p(loc.where); + if (!fs::is_regular_file(p, ec)) break; + if (ports.shim_owner && ports.shim_owner(p)) break; // another home's shim + out.push_back({std::string(tool), p, Source::Host}); + break; + } + } + } + return out; +} + +std::optional first(std::string_view tool, const HomeView& home, const Ports& ports) { + auto all = candidates(tool, home, ports); + if (all.empty()) return std::nullopt; + return all.front(); +} + +std::string install_hint(std::string_view tool) { + const auto* e = entry(tool); + if (!e || e->tool.package.empty()) return {}; + return "xlings install " + std::string(e->tool.package); +} + +} // namespace xlings::subos::tools diff --git a/modules/subos/src/sandbox/tools.cppm b/modules/subos/src/sandbox/tools.cppm new file mode 100644 index 000000000..b6f0beaef --- /dev/null +++ b/modules/subos/src/sandbox/tools.cppm @@ -0,0 +1,50 @@ +export module xlings.subos.tools; + +import std; +import xlings.subos.home_view; +import xlings.subos.ports; + +// Every external program the SubOS code runs, resolved in one place +// (SubOS design part 3 §6.4). +// +// in-process what xlings links: libarchive writes tarballs, the platform +// layer enters namespaces -- such a tool is not in this table +// payload a package xlings installed into the home (data/xpkgs), pinned +// by the index's sha256: what xlings ships for itself +// host a program the machine has, found only at the paths named +// here, never an xlings shim (running another home's shim would +// move the work into that home), and reported as the host's +// +// The table is the policy; nothing else spells a tool's path. xlings never +// installs a host tool through the host's package manager. +export namespace xlings::subos::tools { + +namespace fs = std::filesystem; + +enum class Source { RootOwned, Payload, Runtimedir, Host }; +std::string_view to_string(Source s); + +struct Found { + std::string tool; + fs::path bin; + Source source { Source::Host }; +}; + +// The tools this table knows. `package` is the xim package that provides it +// (empty: none does); `install` is the command that brings the payload. +struct Tool { + std::string_view name; + std::string_view package; +}; +std::span known(); + +// Every candidate for `tool`, in the table's order, that exists. Nothing is +// probed: whether a found binary WORKS is the caller's question (caps probes +// bwrap). An unknown name is a programming error and finds nothing. +std::vector candidates(std::string_view tool, const HomeView& home, const Ports& ports); +std::optional first(std::string_view tool, const HomeView& home, const Ports& ports); + +// "xlings install " or empty. +std::string install_hint(std::string_view tool); + +} // namespace xlings::subos::tools diff --git a/modules/subos/src/session/broker.cpp b/modules/subos/src/session/broker.cpp new file mode 100644 index 000000000..3df545873 --- /dev/null +++ b/modules/subos/src/session/broker.cpp @@ -0,0 +1,179 @@ +module xlings.subos.broker; + +import std; +import xlings.libs.json; +import xlings.observe; +import xlings.subos.home_view; +import xlings.subos.policy; +import xlings.platform; + +namespace xlings::subos::broker { + +Classified classify(std::span argv, std::string_view instance) { + Classified c; + if (argv.empty()) return c; + const auto& cmd = argv[0]; + std::vector positional; + for (std::size_t i = 1; i < argv.size(); ++i) + if (!argv[i].empty() && argv[i][0] != '-') positional.push_back(argv[i]); + auto op = [&](std::string kind, std::string target) { + return policy::Op{ .kind = std::move(kind), .target = std::move(target), .from_inside = true, + .instance = std::string(instance) }; + }; + + // Another instance is never this sandbox's to change. + for (std::size_t i = 1; i + 1 < argv.size(); ++i) { + if (argv[i] == "--subos" && argv[i + 1] != instance) { + c.route = Route::Owner; + c.ops.push_back(op("instance_admin", argv[i + 1])); + return c; + } + } + // xlings itself is the home's, not the instance's: activating a version + // of it rewrites the entry every shim in the home runs (AGENTS.md), the + // owner's included. Only the owner installs, switches or removes it. + auto names_xlings = [](std::string_view t) { + if (auto at = t.find('@'); at != std::string_view::npos) t = t.substr(0, at); + if (auto colon = t.rfind(':'); colon != std::string_view::npos) t = t.substr(colon + 1); + return t == "xlings"; + }; + if ((cmd == "install" || cmd == "update" || cmd == "remove" || cmd == "use") + && std::ranges::any_of(positional, names_xlings) + && !(cmd == "use" && positional.size() < 2)) { + c.route = Route::Owner; + c.ops.push_back(op("instance_admin", "xlings")); + return c; + } + if (cmd == "install") { + c.route = Route::Broker; + if (positional.empty()) c.ops.push_back(op("fetch", "*")); + for (auto& t : positional) c.ops.push_back(op("fetch", t)); + } else if (cmd == "update") { + c.route = Route::Broker; + c.ops.push_back(positional.empty() ? op("index_update", "") : op("fetch", positional[0])); + } else if (cmd == "remove" || (cmd == "use" && positional.size() >= 2)) { + // This instance's own state (design §8.1): allowed, but written by + // the supervisor, because the ledger it updates lives in the home. + c.route = Route::Broker; + } else if (cmd == "self") { + const bool read = argv.size() >= 2 && (argv[1] == "doctor" || argv[1] == "config") + && std::ranges::find(argv, std::string("--fix")) == argv.end(); + if (!read) { c.route = Route::Owner; c.ops.push_back(op("instance_admin", "self")); } + } else if (cmd == "subos") { + static const std::set reads{"list", "ls", "info", "i", "status", "ps", "log"}; + const bool read = argv.size() < 2 || reads.contains(argv[1]) + || (argv[1] == "config" && argv.size() <= 3); + if (!read) { c.route = Route::Owner; c.ops.push_back(op("policy_change", argv[1])); } + } else if (cmd == "config" && argv.size() > 1) { + c.route = Route::Owner; + c.ops.push_back(op("instance_admin", "config")); + } else if (cmd == "index" && argv.size() > 1 && argv[1] == "use") { + c.route = Route::Owner; + c.ops.push_back(op("instance_admin", "index")); + } else if (cmd == "profile" && argv.size() > 1 && argv[1] != "list") { + c.route = Route::Broker; + } + return c; +} + +policy::Decision decide(const policy::Policy& p, const Classified& c) { + if (c.route == Route::Local) return {policy::Action::Allow, "read-only"}; + policy::Decision worst{policy::Action::Allow, "this instance's own state"}; + for (const auto& op : c.ops) { + auto d = policy::decide(p, op); + if (static_cast(d.action) > static_cast(worst.action)) worst = std::move(d); + } + return worst; +} + +// ── queue ──────────────────────────────────────────────────────────── + +std::string enqueue(const HomeView& home, std::string_view instance, + std::span argv, std::string_view reason) { + std::random_device rd; + const auto id = std::format("r{:06x}", rd() & 0xffffff); + const auto dir = home.requests_dir(instance); + std::error_code ec; + fs::create_directories(dir, ec); + nlohmann::json j{{"id", id}, {"argv", std::vector(argv.begin(), argv.end())}, + {"created", observe::utc_now()}, {"reason", std::string(reason)}}; + std::ofstream(dir / (id + ".json")) << j.dump(2); + return id; +} + +std::vector pending(const HomeView& home, std::string_view instance) { + std::vector out; + std::error_code ec; + const auto dir = home.requests_dir(instance); + if (!fs::is_directory(dir, ec)) return out; + for (auto it = fs::directory_iterator(dir, ec); !ec && it != std::default_sentinel; it.increment(ec)) { + if (it->path().extension() != ".json") continue; + std::ifstream in(it->path()); + auto j = nlohmann::json::parse(in, nullptr, false); + if (j.is_discarded()) continue; + Request r{ .id = j.value("id", ""), .created = j.value("created", ""), .reason = j.value("reason", "") }; + for (auto& a : j.value("argv", nlohmann::json::array())) r.argv.push_back(a.get()); + out.push_back(std::move(r)); + } + std::ranges::sort(out, {}, &Request::created); + return out; +} + +std::optional take(const HomeView& home, std::string_view instance, std::string_view id) { + for (auto& r : pending(home, instance)) { + if (r.id != id) continue; + std::error_code ec; + fs::remove(home.requests_dir(instance) / (std::string(id) + ".json"), ec); + return r; + } + return std::nullopt; +} + +// ── client ─────────────────────────────────────────────────────────── + +namespace { +std::string socket_path() { + const auto env = platform::environment(); + auto it = env.find(std::string(kSocketEnv)); + return it != env.end() && !it->second.empty() ? it->second : std::string(kSocketInside); +} +} // namespace + +bool available() { + if constexpr (!platform::is_linux) return false; + const auto env = platform::environment(); + auto mode = env.find("XLINGS_SUBOS_MODE"); + if (mode == env.end() || mode->second != "sandbox") return false; + std::error_code ec; + return fs::exists(socket_path(), ec); +} + +int forward(std::span argv) { + const auto path = socket_path(); + const int fd = platform::unix_connect(path); + if (fd < 0) { + std::println(std::cerr, "xlings: the broker is not reachable at {} ({})", path, + platform::error_text(platform::last_error())); + return kExitPermission; + } + nlohmann::json req{{"op", "run"}, {"argv", std::vector(argv.begin(), argv.end())}}; + const int stdio[3] = {0, 1, 2}; + if (!platform::send_message(fd, req.dump(), stdio)) { + platform::close_fd(fd); + return kExitPermission; + } + int code = kExitPermission; + while (auto m = platform::receive_message(fd, 1 << 16)) { + platform::close_fds(m->fds); + auto j = nlohmann::json::parse(m->data, nullptr, false); + if (j.is_discarded() || j.contains("started")) continue; + code = j.value("exit", kExitPermission); + if (auto e = j.value("error", ""); !e.empty()) std::println(std::cerr, "[xlings] {}", e); + if (auto h = j.value("hint", ""); !h.empty()) std::println(std::cerr, "[xlings] {}", h); + break; + } + platform::close_fd(fd); + return code; +} + +} // namespace xlings::subos::broker diff --git a/modules/subos/src/session/broker.cppm b/modules/subos/src/session/broker.cppm new file mode 100644 index 000000000..a69c11a2b --- /dev/null +++ b/modules/subos/src/session/broker.cppm @@ -0,0 +1,68 @@ +export module xlings.subos.broker; + +import std; +import xlings.libs.json; +import xlings.subos.home_view; +import xlings.subos.policy; + +// The broker (design §8, §9): how the xlings inside a sandbox changes what it +// cannot write. +// +// Inside, the home is read-only. A command that would change it is sent, as +// argv with its stdin/stdout/stderr, to the supervisor outside, which decides +// with the SAME policy::decide the client used for its early answer -- this +// one counts -- and then runs it on the host for this instance, writing to +// the caller's terminal directly. `ask` queues the request for the owner +// (`subos requests / approve / deny`); the caller gets exit 75 and the id. +export namespace xlings::subos::broker { + +namespace fs = std::filesystem; + +inline constexpr std::string_view kSocketInside = "/run/xlings/broker.sock"; +// Where a sandbox without a view of its own (Landlock) finds it: the host +// path, named by this variable. +inline constexpr std::string_view kSocketEnv = "XLINGS_BROKER_SOCKET"; +inline constexpr int kExitPending = 75; // EX_TEMPFAIL: waiting for approval +inline constexpr int kExitPermission = 13; // E_PERMISSION + +enum class Route { + Local, // read-only, or the instance's own state: runs where it is + Broker, // changes the home: the supervisor decides and runs it + Owner, // the home, other instances, the policy: only from outside +}; + +struct Classified { + Route route { Route::Local }; + std::vector ops; // what decide() is asked, one per target +}; + +// `argv` is the command after `xlings` and without global flags. +Classified classify(std::span argv, std::string_view instance); + +// The supervisor's answer for a whole command: the strictest of its ops. +policy::Decision decide(const policy::Policy& p, const Classified& c); + +// ── inside: the client ─────────────────────────────────────────────── + +// Whether this process is a sandbox's xlings with a broker to talk to. +bool available(); + +// Send the command and this process's stdio; returns the exit code. +int forward(std::span argv); + +// ── outside: the queue of requests waiting for the owner ───────────── + +struct Request { + std::string id; + std::vector argv; + std::string created; + std::string reason; +}; + +std::string enqueue(const HomeView& home, std::string_view instance, + std::span argv, std::string_view reason); +std::vector pending(const HomeView& home, std::string_view instance); +// Removes it from the queue. +std::optional take(const HomeView& home, std::string_view instance, std::string_view id); + +} // namespace xlings::subos::broker diff --git a/modules/subos/src/session/session.cpp b/modules/subos/src/session/session.cpp new file mode 100644 index 000000000..9cb733411 --- /dev/null +++ b/modules/subos/src/session/session.cpp @@ -0,0 +1,1235 @@ +module xlings.subos.session; + +import std; +import xlings.libs.json; +import xlings.observe; +import xlings.platform; +import xlings.platform.root_mount; +import xlings.subos.home_view; +import xlings.subos.policy; +import xlings.subos.broker; +import xlings.subos.network; + +// Every system call here is xlings.platform's (:process, :isolation): this +// file is the session's logic -- who talks to whom, what is recorded, when a +// session ends -- and nothing of how a POSIX kernel spells it. +namespace xlings::subos::session { + +namespace sig = platform::sig; + +nlohmann::json to_json(const Info& i) { + return {{"instance", i.instance}, {"id", i.id}, {"supervisor_pid", i.supervisor_pid}, + {"sandbox_pid", i.sandbox_pid}, {"started", i.started}, {"backend", i.backend}, + {"digest", i.digest}, {"ttl", i.ttl}, {"detached", i.detached}}; +} + +Info info_from_json(const nlohmann::json& j) { + Info i; + i.instance = j.value("instance", ""); + i.id = j.value("id", ""); + i.supervisor_pid = j.value("supervisor_pid", 0); + i.sandbox_pid = j.value("sandbox_pid", 0); + i.started = j.value("started", ""); + i.backend = j.value("backend", ""); + i.digest = j.value("digest", ""); + i.ttl = j.value("ttl", 0); + i.detached = j.value("detached", false); + return i; +} + +namespace { + +// Sessions need a SessionHost (design §17): fork, a local socket that carries +// descriptors, a process that outlives its parent. Where there is none, +// `subos status` says so and every entry point below declines. +// macOS has one too since part 3 (§6.3): fork, and a framed SOCK_STREAM in +// place of SEQPACKET (xlings.platform). Windows has neither yet. +constexpr bool kSessions = platform::is_linux || platform::is_macos; + +// ── messages: one JSON object per datagram, descriptors attached ───── + +struct Msg { + nlohmann::json json; + std::vector fds; +}; + +bool send_msg(int sock, const nlohmann::json& j, std::span fds = {}) { + return platform::send_message(sock, j.dump(), fds); +} + +// nullopt on EOF or error; a message that is not JSON is dropped (and its +// descriptors closed) rather than trusted. +std::optional recv_msg(int sock) { + auto m = platform::receive_message(sock); + if (!m) return std::nullopt; + Msg out{ nlohmann::json::parse(m->data, nullptr, false), std::move(m->fds) }; + if (out.json.is_discarded() || !out.json.is_object()) { + platform::close_fds(out.fds); + out.json = nlohmann::json::object(); + } + return out; +} + +fs::path sock_path(const HomeView& home, std::string_view instance) { + return home.run_dir(instance) / "exec.sock"; +} +fs::path info_path(const HomeView& home, std::string_view instance) { + return home.run_dir(instance) / "session.json"; +} + +std::string new_id() { + std::random_device rd; + return std::format("{:08x}{:04x}", rd(), rd() & 0xffff); +} + +bool write_atomic(const fs::path& path, const std::string& text) { + auto tmp = fs::path(path.string() + ".tmp"); + { + std::ofstream out(tmp, std::ios::binary | std::ios::trunc); + if (!out) return false; + out << text; + out.flush(); + if (!out) return false; + out.close(); + if (out.fail()) return false; + } + std::error_code ec; + fs::rename(tmp, path, ec); + return !ec; +} + +bool env_allowed(std::string_view name, const std::vector& pass) { + for (const auto& p : pass) { + if (!p.empty() && p.back() == '*') { + if (name.starts_with(std::string_view(p).substr(0, p.size() - 1))) return true; + } else if (name == p) { + return true; + } + } + return false; +} + +void report(std::string_view what) { + std::println(std::cerr, "[xlings:subos] {}", what); +} + +} // namespace + +// ── discovery ──────────────────────────────────────────────────────── + +std::optional find(const HomeView& home, std::string_view instance) { + if constexpr (!kSessions) return std::nullopt; + auto ip = info_path(home, instance); + std::error_code ec; + if (!fs::exists(ip, ec)) return std::nullopt; + std::ifstream in(ip); + auto j = nlohmann::json::parse(in, nullptr, false); + if (j.is_discarded() || !j.is_object()) return std::nullopt; + auto info = info_from_json(j); + const bool alive = platform::is_process_alive(info.supervisor_pid); + if (alive) { + // The pid could have been reused; the supervisor answering on its + // socket is the proof. + if (int fd = platform::unix_connect(sock_path(home, instance)); fd >= 0) { + platform::close_fd(fd); + return info; + } + } else { + fs::remove(ip, ec); + fs::remove(sock_path(home, instance), ec); + } + return std::nullopt; +} + +std::vector list(const HomeView& home) { + std::vector out; + auto root = home.home / "run" / "subos"; + std::error_code ec; + if (!fs::is_directory(root, ec)) return out; + for (auto it = fs::directory_iterator(root, ec); !ec && it != std::default_sentinel; + it.increment(ec)) { + if (auto i = find(home, it->path().filename().string())) out.push_back(*i); + } + std::ranges::sort(out, {}, &Info::instance); + return out; +} + +// ── the supervisor ─────────────────────────────────────────────────── + +namespace { + +// Files changed under `root` since `since`, relative, at most `limit`. +std::pair, bool> changed_since(const fs::path& root, + fs::file_time_type since, + std::size_t limit) { + std::vector out; + bool truncated = false; + std::error_code ec; + std::size_t visited = 0; + for (auto it = fs::recursive_directory_iterator(root, fs::directory_options::skip_permission_denied, ec); + !ec && it != std::default_sentinel; it.increment(ec)) { + if (++visited > 200000) { truncated = true; break; } + std::error_code e2; + if (!it->is_regular_file(e2)) continue; + if (it->last_write_time(e2) < since) continue; + if (out.size() >= limit) { truncated = true; break; } + out.push_back(it->path().lexically_relative(root).generic_string()); + } + return {out, truncated}; +} + +struct BrokerClient { + int fd { -1 }; + int pid { -1 }; + std::string program; +}; + +struct Client { + int fd { -1 }; + std::optional exec_id; + std::optional deadline; + bool timed_out { false }; + std::string argv0; + std::chrono::steady_clock::time_point started{}; +}; + +int supervise(const HomeView& home, Launch& L, Info& info, int listen_fd, std::array ctl, + int ready_fd) { + struct RootContext { + Launch& launch; + std::array descriptors{-1, -1, -1}; + ~RootContext() { + for (const int descriptor : descriptors) + platform::close_fd(descriptor); + if (launch.finalize_root) { + try { launch.finalize_root(); } + catch (...) { report("private root view cleanup failed"); } + } + } + } rootContext{L}; + int pid = -1; + bool root_failed = false; + bool audit_failed = false; + bool audit_warned = false; + std::vector brokered; + auto audit = [&](nlohmann::json fields, observe::Kind kind = observe::Kind::Lifecycle) { + if (audit_failed) return false; + fields["instance"] = L.instance; + observe::trace(kind == observe::Kind::Perm ? "broker" : "session", fields.dump()); + const auto file = home.logs_dir(L.instance) / "events.ndjson"; + const observe::Event event{.kind = kind, .ts = observe::utc_now(), .fields = std::move(fields)}; + auto failed_file = file; + bool written = observe::append_checked(file, event); + if (written) { + failed_file = home.logs_dir(L.instance) / "sessions" / (info.id + ".ndjson"); + written = observe::append_checked(failed_file, event); + } + if (!written) { + if (!audit_warned) + report(std::format("E_AUDIT_WRITE: cannot write {}; {}", failed_file.string(), + L.audit_required ? "locked session refused or terminated" + : "continuing without a complete audit")); + audit_warned = true; + if (L.audit_required) { + audit_failed = true; + if (pid > 0) platform::send_signal(pid, sig::kill); + for (auto& b : brokered) + if (b.pid > 0) platform::send_signal(b.pid, sig::kill); + } + } + return written || !L.audit_required; + }; + auto audit_spec = L.spec; + if (audit_spec.is_object() && audit_spec.contains("argv")) { + const auto& argv = audit_spec["argv"]; + audit_spec["argc"] = argv.is_array() ? argv.size() : 0; + if (argv.is_array() && !argv.empty() && argv[0].is_string()) + audit_spec["program"] = argv[0]; + audit_spec.erase("argv"); + } + if (!audit({{"event", "session-start"}, {"session", info.id}, + {"backend", L.backend}, {"detached", L.detached}, {"ttl", L.ttl}, + {"exec_trace", L.trace_exec}, {"spec", audit_spec}})) { + platform::close_fd(listen_fd); + for (int fd : ctl) platform::close_fd(fd); + platform::close_fds(L.keep_fds); + platform::close_fd(ready_fd); + std::error_code ec; + fs::remove(sock_path(home, L.instance), ec); + return kExitSetup; + } + + std::optional proxyRelay; + std::optional> proxyBridge; + if (!L.proxy_url.empty()) { + const auto resolved = network::resolve_proxy(L.proxy_url); + if (!resolved || !(proxyBridge = platform::unix_pair())) { + report(resolved ? "cannot create the private proxy bridge" : resolved.error()); + (void)audit({{"event", "session-setup-failed"}, {"session", info.id}, {"reason", "proxy"}}); + platform::close_fd(listen_fd); + for (int fd : ctl) platform::close_fd(fd); + platform::close_fds(L.keep_fds); + platform::close_fd(ready_fd); + std::error_code ec; + fs::remove(sock_path(home, L.instance), ec); + return kExitSetup; + } + // Only this declared endpoint is resolved on the host. Sandbox target + // domain names remain SOCKS wire bytes and go to the remote proxy. + if (!audit({{"event", "proxy-endpoint-resolved"}, {"session", info.id}, + {"proxy_host", resolved->declared.host}, {"proxy_port", resolved->declared.port}, + {"resolution", "host-proxy-endpoint-only"}}, observe::Kind::Net)) return kExitSetup; + proxyRelay.emplace(*resolved); + } + + // Like system(): while the sandbox runs, Ctrl-C is the command's. SIGTERM + // and SIGHUP end the session (forwarded to the sandbox). + const int signal_fd = platform::route_signals({sig::terminate, sig::hangup, sig::child}, + {sig::interrupt, sig::quit, sig::pipe}); + + // The broker's socket exists before the backend starts: bwrap binds it + // into the sandbox at /run/xlings/broker.sock. + int broker_fd = -1; + if (L.broker_policy) { + broker_fd = platform::unix_listen(home.broker_socket(L.instance)); + if (broker_fd < 0) + report(std::format("the broker could not listen ({}); changes from inside will be refused", + platform::error_text(platform::last_error()))); + } + + // net=nat handshake: the child makes its namespaces and says so; pasta + // attaches; the child is told to go on (1) or to give up (0). + const bool nat = !L.pasta.empty(); + const bool proxy = proxyRelay.has_value(); + std::optional> ready_pipe, go_pipe; + if (nat || proxy) { + ready_pipe = platform::make_pipe(); + go_pipe = platform::make_pipe(); + if (!ready_pipe || !go_pipe) { + report(std::format("pipe failed: {}", platform::error_text(platform::last_error()))); + return kExitSetup; + } + } + const auto ids = platform::user_ids(); + + pid = platform::fork_process(); + if (pid < 0) { + report(std::format("fork failed: {}", platform::error_text(platform::last_error()))); + return kExitSetup; + } + if (pid == 0) { + platform::reset_signals(); + platform::close_fd(listen_fd); + platform::close_fd(ctl[0]); + platform::close_fd(broker_fd); + if (nat || proxy) { + // The only interfaces proxy receives are lo; no route or pasta. + if (!platform::enter_private_network(ids.uid, ids.gid)) platform::exit_now(kExitSetup); + if (proxy) { + platform::close_fd((*proxyBridge)[0]); + if (!platform::network::enable_loopback()) platform::exit_now(kExitSetup); + const auto listener = platform::network::listen_loopback(network::GATEWAY_PORT); + if (!listener) platform::exit_now(kExitSetup); + const int gate = platform::fork_process(); + if (gate < 0) platform::exit_now(kExitSetup); + if (gate == 0) { + platform::close_fd(ctl[1]); + platform::close_fd(ready_fd); + for (const auto& pair : {*ready_pipe, *go_pipe}) for (int fd : pair) platform::close_fd(fd); + platform::close_fds(L.keep_fds); + const int null = platform::open_null(); + const int stdio[3] = {null, null, null}; + platform::redirect_stdio(stdio); + platform::exit_now(network::gateway_run((*proxyBridge)[1], *listener)); + } + platform::close_fd(*listener); + platform::close_fd((*proxyBridge)[1]); + } + const char ready = 1; + (void)platform::write_fd((*ready_pipe)[1], std::string_view(&ready, 1)); + char go = 0; + if (!platform::read_exact((*go_pipe)[0], &go, 1) || go != 1) platform::exit_now(kExitSetup); + } + // The control socket and the caller's descriptors (a seccomp filter) + // cross exec; nothing else of the supervisor's does. + platform::set_inheritable(ctl[1], true); + for (int fd : L.keep_fds) platform::set_inheritable(fd, true); + const int e = platform::exec_path(L.argv, L.env); + report(std::format("cannot run {}: {}", L.argv.empty() ? "" : L.argv[0], platform::error_text(e))); + platform::exit_now(kExitSetup); + } + platform::close_fd(ctl[1]); + for (int fd : L.keep_fds) platform::close_fd(fd); + + if (proxy) platform::close_fd((*proxyBridge)[1]); + const auto pasta_pidfile = home.run_dir(L.instance) / "pasta.pid"; + if (nat || proxy) { + platform::close_fd((*ready_pipe)[1]); + platform::close_fd((*go_pipe)[0]); + platform::PollFd rp{ (*ready_pipe)[0] }; + char b = 0; + const bool ready = platform::poll_fds(std::span(&rp, 1), 10000) > 0 + && platform::read_exact((*ready_pipe)[0], &b, 1); + int pasta_rc = -1; + if (ready && nat) { + auto pargv = L.pasta; + pargv.insert(pargv.end(), {"--pid", pasta_pidfile.string(), std::to_string(pid)}); + const int pp = platform::fork_process(); + if (pp == 0) { + platform::reset_signals(); + const int null = platform::open_null(); + const int stdio[3] = {null, 1, 2}; + platform::redirect_stdio(stdio); + (void)platform::exec_path(pargv, platform::environment()); + platform::exit_now(127); + } + if (pp > 0) + if (auto st = platform::wait_process(pp, true)) pasta_rc = platform::exit_code(*st); + } + const char go = (ready && (proxy || pasta_rc == 0)) ? 1 : 0; + (void)platform::write_fd((*go_pipe)[1], std::string_view(&go, 1)); + platform::close_fd((*go_pipe)[1]); + platform::close_fd((*ready_pipe)[0]); + if (!go) { + report(proxy ? "net=proxy: private loopback gateway was not created" + : std::format("net=nat: pasta could not set up the network ({})", + ready ? std::format("pasta exited {}", pasta_rc) + : std::string("the namespace was not created"))); + audit({{"event", "session-setup-failed"}, {"session", info.id}, + {"reason", proxy ? "proxy" : "pasta"}, {"exit", pasta_rc}}); + if (proxyBridge) platform::close_fd((*proxyBridge)[0]); + (void)platform::wait_process(pid, true); + std::error_code rec; + fs::remove(sock_path(home, L.instance), rec); + platform::close_fd(listen_fd); + platform::close_fd(ctl[0]); + return kExitSetup; + } + } + + if (L.refresh_root) { + platform::PollFd control{ctl[0]}; + auto message = platform::poll_fds(std::span(&control, 1), 10000) > 0 + ? recv_msg(ctl[0]) : std::nullopt; + const bool valid = message && message->json.value("op", "") == "root-mount-context" && + message->fds.size() == 3; + std::string namespaceFailure; + if (valid) { + std::ranges::copy(message->fds, rootContext.descriptors.begin()); + message->fds.clear(); + if (auto normalized = platform::root_mount::normalize(rootContext.descriptors); !normalized) + namespaceFailure = normalized.error(); + } + if (!valid || !namespaceFailure.empty()) { + const auto reason = !namespaceFailure.empty() ? namespaceFailure : + message ? message->json.value("error", "invalid namespace descriptors") + : "trusted init did not supply namespace descriptors"; + if (message) + platform::close_fds(message->fds); + report("cannot supervise root mount refresh: " + reason); + (void)audit({{"event", "root-refresh-unavailable"}, {"reason", reason}, + {"session", info.id}}); + platform::send_signal(pid, sig::kill); + (void)platform::wait_process(pid, true); + platform::close_fd(listen_fd); + platform::close_fd(ctl[0]); + platform::close_fd(broker_fd); + platform::close_fd(ready_fd); + if (proxyRelay) proxyRelay->stop(); + if (proxyBridge) platform::close_fd((*proxyBridge)[0]); + std::error_code ignored; + if (nat) { + std::ifstream file(pasta_pidfile); + int helper = 0; + if (file >> helper && helper > 0) + platform::send_signal(helper, sig::terminate); + fs::remove(pasta_pidfile, ignored); + } + fs::remove(sock_path(home, L.instance), ignored); + fs::remove(home.broker_socket(L.instance), ignored); + return kExitSetup; + } + for (const int descriptor : rootContext.descriptors) + platform::set_inheritable(descriptor, false); + } + + // observe=full: session-init installs the exec filter inside and sends its + // listener over the control socket (handled in the loop below). Inside, + // so bwrap itself never runs under no_new_privs -- which would keep an + // AppArmor profile (self doctor --isolation --fix) from applying to it. + int net_notify_fd = -1; + const auto fs_since = fs::file_time_type::clock::now(); + + const auto started = std::chrono::steady_clock::now(); + info.sandbox_pid = pid; + write_atomic(info_path(home, L.instance), to_json(info).dump(2)); + if (ready_fd >= 0) { + (void)platform::write_fd(ready_fd, "1"); + platform::close_fd(ready_fd); + } + + std::vector clients; + std::int64_t next_id = 1; + platform::ExitStatus status{}; + bool reaped = false; + bool ctl_open = true; + bool timed_out = false; + std::optional kill_at; + const auto deadline = L.timeout ? std::optional(started + *L.timeout) : std::nullopt; + + auto finish_client = [&](Client& c, nlohmann::json reply) { + if (c.timed_out) reply = {{"exit", kExitTimeout}, {"timeout", true}}; + auto ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - c.started).count(); + nlohmann::json ev{{"event", "exec-end"}, {"session", info.id}, {"exec", *c.exec_id}, + {"program", c.argv0}, {"ms", ms}}; + for (auto k : {"exit", "signal", "error", "timeout"}) + if (reply.contains(k)) ev[k] = reply[k]; + if (!audit(ev, observe::Kind::Ops)) + reply = {{"error", "E_AUDIT_WRITE: session ended"}, {"phase", "setup"}}; + send_msg(c.fd, reply); + platform::close_fd(c.fd); + c.fd = -1; + }; + auto drop = [](int& fd) { platform::close_fd(fd); fd = -1; }; + + while (true) { + // An audit failure ends admission immediately. Draining the normal + // control channel can wait on a backend blocked by our listeners. + if (audit_failed || root_failed) break; + const auto signals = platform::pending_signals(); + if (!reaped) { + if (auto st = platform::wait_process(pid, false)) { status = *st; reaped = true; pid = -1; } + } + for (int s : signals) { + if ((s == sig::terminate || s == sig::hangup) && !reaped) platform::send_signal(pid, sig::terminate); + } + if (reaped && !ctl_open) break; + if (reaped) { + // Drain what session-init said before it went. + platform::PollFd p{ ctl[0] }; + if (platform::poll_fds(std::span(&p, 1), 0) <= 0) break; + } + + if (proxyRelay) proxyRelay->tick([&](const network::Event& event) { + return audit({{"event", event.event}, {"session", info.id}, {"connection", event.connection}, + {"target", event.target}, {"port", event.port}, {"remote_dns", event.remote_dns}, + {"reason", event.reason}, {"mode", "proxy"}}, observe::Kind::Net); + }); + std::vector pfds{{listen_fd}, {signal_fd}}; + if (proxyBridge && (*proxyBridge)[0] >= 0) pfds.push_back({(*proxyBridge)[0]}); + if (proxyRelay) { + const auto networkFds = proxyRelay->fds(); + pfds.insert(pfds.end(), networkFds.begin(), networkFds.end()); + } + if (ctl_open) pfds.push_back({ctl[0]}); + if (broker_fd >= 0) pfds.push_back({broker_fd}); + if (net_notify_fd >= 0) pfds.push_back({net_notify_fd}); + for (auto& b : brokered) if (b.fd >= 0 && b.pid < 0) pfds.push_back({b.fd}); + for (auto& c : clients) if (c.fd >= 0) pfds.push_back({c.fd}); + platform::poll_fds(pfds, 200); + + const auto now = std::chrono::steady_clock::now(); + if (deadline && !timed_out && !reaped && now >= *deadline) { + timed_out = true; + platform::send_signal(pid, sig::terminate); + kill_at = now + std::chrono::seconds(2); + audit({{"event", "session-timeout"}, {"session", info.id}}); + } + if (kill_at && !reaped && now >= *kill_at) { + platform::send_signal(pid, sig::kill); + kill_at.reset(); + } + for (auto& c : clients) { + if (c.fd >= 0 && c.exec_id && c.deadline && !c.timed_out && now >= *c.deadline) { + c.timed_out = true; + send_msg(ctl[0], {{"op", "kill"}, {"id", *c.exec_id}}); + } + } + + // Brokered commands that finished: their exit goes back to the caller. + for (auto& b : brokered) { + if (b.pid <= 0) continue; + auto st = platform::wait_process(b.pid, false); + if (!st) continue; + int code = platform::exit_code(*st); + b.pid = 0; + std::string refreshError; + if (code == 0 && L.refresh_root) { + try { + if (auto refreshed = L.refresh_root(rootContext.descriptors); !refreshed) + refreshError = refreshed.error(); + } catch (const std::exception& error) { + refreshError = error.what(); + } catch (...) { + refreshError = "owner root refresh failed"; + } + if (!refreshError.empty()) { + code = kExitSetup; + root_failed = true; + report("root refresh refused: " + refreshError); + (void)audit({{"event", "root-refresh-failed"}, {"session", info.id}, + {"reason", refreshError}}, observe::Kind::Ops); + if (pid > 0) platform::send_signal(pid, sig::kill); + for (const auto& pending : brokered) + if (pending.pid > 0) platform::send_signal(pending.pid, sig::kill); + } else { + (void)audit({{"event", "root-refresh-done"}, {"session", info.id}}, observe::Kind::Ops); + } + } + const bool recorded = audit({{"event", "broker-done"}, {"session", info.id}, + {"program", b.program}, {"exit", code}}, observe::Kind::Perm); + auto reply = recorded ? nlohmann::json{{"exit", code}} + : nlohmann::json{{"exit", kExitSetup}, {"error", "E_AUDIT_WRITE: session ended"}}; + if (!refreshError.empty()) reply["error"] = refreshError; + send_msg(b.fd, reply); + drop(b.fd); + } + std::erase_if(brokered, [](const BrokerClient& b) { return b.fd < 0; }); + if (root_failed || audit_failed) break; + + for (auto& p : pfds) { + if (audit_failed || root_failed) break; + if (!p.readable && !p.closed) continue; + if (proxyBridge && p.fd == (*proxyBridge)[0]) { + const auto client = platform::receive_message(p.fd, 128); + if (!client) { + audit({{"event", "proxy-gateway-lost"}, {"session", info.id}}, observe::Kind::Net); + if (pid > 0) platform::send_signal(pid, sig::kill); + drop((*proxyBridge)[0]); + } else { + auto descriptors = client->fds; + if (client->data == "proxy-client" && descriptors.size() == 1) { + if (!proxyRelay->add(descriptors.front())) + audit({{"event", "denied"}, {"session", info.id}, {"mode", "proxy"}, + {"reason", "gateway connection limit"}}, observe::Kind::Net); + descriptors.clear(); + } + platform::close_fds(descriptors); + } + continue; + } + if (net_notify_fd >= 0 && p.fd == net_notify_fd) { + if (p.closed && !p.readable) { drop(net_notify_fd); continue; } + if (const auto request = platform::net_notify::next(net_notify_fd)) { + bool allowed{}; + if (request->kind == platform::net_notify::Kind::Exec) { + allowed = audit({{"event", "exec"}, {"session", info.id}, + {"path", request->execPath.empty() ? std::string("?") : request->execPath}, + {"pid", request->pid}}, observe::Kind::Exec); + } else { + // A destination-less sendmsg may be control traffic; + // it remains attempted I/O, never a connection success. + allowed = audit({{"event", "net-attempt"}, {"session", info.id}, + {"mode", proxyRelay ? "proxy" : "nat"}, {"syscall", request->syscall}, {"pid", request->pid}, + {"address", request->address}, {"port", request->port}, + {"address_readable", request->address_readable}, {"result", "unknown"}}, observe::Kind::Net); + } + (void)platform::net_notify::complete(net_notify_fd, request->id, allowed); + } + continue; + } + if (broker_fd >= 0 && p.fd == broker_fd) { + if (int c = platform::unix_accept(broker_fd); c >= 0) brokered.push_back({.fd = c}); + continue; + } + if (auto bit = std::ranges::find(brokered, p.fd, &BrokerClient::fd); bit != brokered.end()) { + auto m = recv_msg(bit->fd); + if (!m) { drop(bit->fd); continue; } + std::vector argv; + for (auto& a : m->json.value("argv", nlohmann::json::array())) + if (a.is_string()) argv.push_back(a.get()); + if (argv.empty() || m->fds.size() != 3) { + send_msg(bit->fd, {{"exit", broker::kExitPermission}, {"error", "malformed request"}}); + platform::close_fds(m->fds); + drop(bit->fd); + continue; + } + // The decision that counts: the same function the client ran, + // with the policy file as the supervisor read it at start. + auto cls = broker::classify(argv, L.instance); + auto d = broker::decide(*L.broker_policy, cls); + nlohmann::json ev{{"event", "decision"}, {"session", info.id}, {"program", argv[0]}, + {"argc", argv.size()}, + {"action", std::string(policy::to_string(d.action))}, + {"reason", d.reason}}; + if (cls.route == broker::Route::Owner) d.action = policy::Action::Deny; + // Deny by default: the broker runs, as the owner, only what + // changes this instance on its behalf. Anything else -- what + // the client runs locally, or never heard of -- is refused + // here, whoever sent it: a process inside can talk to this + // socket without the client. + if (cls.route == broker::Route::Local) { + d.action = policy::Action::Deny; + d.reason = std::format("`{}` is not something the broker runs; it runs inside the sandbox", + argv[0]); + } + if (d.action == policy::Action::Deny) { + audit(ev, observe::Kind::Perm); + send_msg(bit->fd, {{"exit", broker::kExitPermission}, + {"error", "E_PERMISSION: " + d.reason}, + {"hint", d.owner_command.empty() ? std::string{} + : "outside the sandbox: " + d.owner_command}}); + platform::close_fds(m->fds); + drop(bit->fd); + continue; + } + if (d.action == policy::Action::Ask) { + auto id = broker::enqueue(home, L.instance, argv, d.reason); + ev["request"] = id; + audit(ev, observe::Kind::Perm); + send_msg(bit->fd, {{"exit", broker::kExitPending}, + {"error", "waiting for the owner's approval: request " + id}, + {"hint", std::format("outside the sandbox: xlings subos approve {} {}", + L.instance, id)}, + {"request", id}}); + platform::close_fds(m->fds); + drop(bit->fd); + continue; + } + if (!audit(ev, observe::Kind::Perm)) { + send_msg(bit->fd, {{"exit", kExitSetup}, {"error", "E_AUDIT_WRITE: session ended"}}); + platform::close_fds(m->fds); + drop(bit->fd); + continue; + } + auto full = L.broker_exe; + full.insert(full.end(), argv.begin(), argv.end()); + if (std::ranges::find(full, std::string("-y")) == full.end()) full.push_back("-y"); + const int bp = platform::fork_process(); + if (bp == 0) { + platform::reset_signals(); + // From the home: a directory that is a home ends the + // project search, so the session owner's cwd (a project, + // perhaps) never becomes the scope this runs in. + if (auto h = L.broker_env.find("XLINGS_HOME"); h != L.broker_env.end()) { + std::error_code cec; + fs::current_path(h->second, cec); + } + platform::redirect_stdio(m->fds); + (void)platform::exec_path(full, L.broker_env); + platform::exit_now(kExitCannotRun); + } + platform::close_fds(m->fds); + bit->pid = bp > 0 ? bp : 0; + bit->program = argv[0]; + if (bp <= 0) { + send_msg(bit->fd, {{"exit", kExitSetup}, {"error", "the broker could not start it"}}); + drop(bit->fd); + } else { + send_msg(bit->fd, {{"started", true}}); + } + continue; + } + if (p.fd == listen_fd) { + if (int c = platform::unix_accept(listen_fd); c >= 0) clients.push_back({.fd = c}); + continue; + } + if (p.fd == signal_fd) continue; // drained at the top + if (ctl_open && p.fd == ctl[0]) { + auto m = recv_msg(ctl[0]); + if (!m) { + ctl_open = false; + continue; + } + if (m->json.value("op", "") == "root-mount-context") { + platform::close_fds(m->fds); + report("duplicate root namespace context refused"); + root_failed = true; + if (pid > 0) platform::send_signal(pid, sig::kill); + continue; + } + if (m->json.value("op", "") == "net-listener" || + m->json.value("op", "") == "exec-listener") { + if (m->fds.size() == 1 && net_notify_fd < 0) { + net_notify_fd = m->fds.front(); + m->fds.clear(); + } else { + (void)audit({{"event", "audit-trace-unavailable"}, {"session", info.id}, + {"reason", m->json.value("reason", "invalid notification listener handoff")}}); + report("required syscall audit listener is unavailable"); + if (pid > 0) platform::send_signal(pid, sig::kill); + } + platform::close_fds(m->fds); + continue; + } + platform::close_fds(m->fds); + auto id = m->json.value("id", std::int64_t{0}); + auto it = std::ranges::find_if(clients, [&](const Client& c) { + return c.fd >= 0 && c.exec_id == id; + }); + if (it == clients.end()) continue; + if (m->json.contains("started")) { + send_msg(it->fd, {{"started", true}}); + } else { + finish_client(*it, m->json); + } + continue; + } + auto it = std::ranges::find(clients, p.fd, &Client::fd); + if (it == clients.end()) continue; + auto m = recv_msg(it->fd); + if (!m) { + // The client went away: what it started goes too. + if (it->exec_id && ctl_open) + send_msg(ctl[0], {{"op", "kill"}, {"id", *it->exec_id}}); + drop(it->fd); + continue; + } + const auto op = m->json.value("op", ""); + if (op == "ping") { + send_msg(it->fd, to_json(info)); + platform::close_fds(m->fds); + } else if (op == "stop") { + audit({{"event", "session-stop-requested"}, {"session", info.id}}); + if (!reaped) platform::send_signal(pid, sig::terminate); + send_msg(it->fd, {{"ok", true}}); + platform::close_fds(m->fds); + } else if (op == "signal" && it->exec_id && ctl_open) { + send_msg(ctl[0], {{"op", "signal"}, {"id", *it->exec_id}, + {"sig", m->json.value("sig", sig::terminate)}}); + platform::close_fds(m->fds); + } else if (op == "exec" && !it->exec_id && ctl_open) { + auto argv_j = m->json.value("argv", nlohmann::json::array()); + if (!argv_j.is_array() || argv_j.empty() || m->fds.size() != 3) { + send_msg(it->fd, {{"error", "malformed exec request"}, {"phase", "setup"}}); + platform::close_fds(m->fds); + continue; + } + // The joined command's environment: the session's own, plus + // what the caller passed that the policy lets in. + auto env = L.exec_env; + const auto req_env = m->json.value("env", nlohmann::json::object()); + for (auto e = req_env.begin(); e != req_env.end(); ++e) { + if (e.value().is_string() && env_allowed(e.key(), L.env_pass)) + env[e.key()] = e.value().get(); + } + std::string cwd = m->json.value("cwd", ""); + if (cwd.empty()) cwd = L.default_cwd; + auto id = next_id++; + it->exec_id = id; + it->started = now; + it->argv0 = argv_j[0].is_string() ? argv_j[0].get() : ""; + if (auto t = m->json.value("timeout_ms", 0LL); t > 0) + it->deadline = now + std::chrono::milliseconds(t); + nlohmann::json ev{{"event", "exec"}, {"session", info.id}, {"exec", id}, + {"program", it->argv0}, {"argc", argv_j.size()}, {"cwd", cwd}, + {"tty", m->json.value("tty", false)}}; + ev["env"] = nlohmann::json::array(); + for (auto& [k, v] : env) ev["env"].push_back(k); // names, never values + if (!audit(ev, observe::Kind::Ops)) { + send_msg(it->fd, {{"error", "E_AUDIT_WRITE: session ended"}, {"phase", "setup"}}); + platform::close_fds(m->fds); + drop(it->fd); + continue; + } + send_msg(ctl[0], {{"op", "exec"}, {"id", id}, {"argv", argv_j}, {"env", env}, + {"cwd", cwd}, {"tty", m->json.value("tty", false)}}, + m->fds); + platform::close_fds(m->fds); + } else { + send_msg(it->fd, {{"error", "unsupported request"}, {"phase", "setup"}}); + platform::close_fds(m->fds); + } + } + std::erase_if(clients, [](const Client& c) { return c.fd < 0; }); + } + + for (auto& c : clients) { + if (c.fd < 0) continue; + if (c.exec_id) send_msg(c.fd, {{"error", "the session ended"}, {"phase", "setup"}}); + platform::close_fd(c.fd); + } + platform::close_fd(net_notify_fd); + if (proxyRelay) proxyRelay->stop(); + if (proxyBridge) platform::close_fd((*proxyBridge)[0]); + // What changed in the host paths mapped read-write (design §22, fs). + for (const auto& root : L.rw_paths) { + auto [files, truncated] = changed_since(root, fs_since, 200); + if (files.empty()) continue; + audit({{"event", "changed"}, {"session", info.id}, {"mount", root}, + {"count", files.size()}, {"truncated", truncated}, {"files", files}}, + observe::Kind::Fs); + } + std::error_code ec; + if (nat) { + // pasta outlives nothing it served. + std::ifstream pf(pasta_pidfile); + int ppid = 0; + if (pf >> ppid && ppid > 0) platform::send_signal(ppid, sig::terminate); + fs::remove(pasta_pidfile, ec); + } + fs::remove(sock_path(home, L.instance), ec); + fs::remove(info_path(home, L.instance), ec); + if (broker_fd >= 0) { + for (auto& b : brokered) { + if ((root_failed || audit_failed) && b.pid > 0) + platform::send_signal(b.pid, sig::kill); + if (b.pid > 0) (void)platform::wait_process(b.pid, true); + platform::close_fd(b.fd); + } + platform::close_fd(broker_fd); + fs::remove(home.broker_socket(L.instance), ec); + } + platform::close_fd(listen_fd); + if (ctl_open) platform::close_fd(ctl[0]); + if ((audit_failed || root_failed) && !reaped && pid > 0) { + platform::send_signal(pid, sig::kill); + if (const auto exit = platform::wait_process(pid, true)) { + status = *exit; + reaped = true; + pid = -1; + } + } + const int code = (audit_failed || root_failed) ? kExitSetup : timed_out ? kExitTimeout : reaped ? platform::exit_code(status) : kExitSetup; + auto ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - started).count(); + audit({{"event", "session-end"}, {"session", info.id}, {"exit", code}, + {"ms", ms}}); + return (audit_failed || root_failed) ? kExitSetup : code; +} + +} // namespace + +int host(const HomeView& home, Launch L) { + if constexpr (!kSessions) return kExitSetup; + std::error_code ec; + fs::create_directories(home.run_dir(L.instance), ec); + fs::permissions(home.run_dir(L.instance), fs::perms::owner_all, fs::perm_options::replace, ec); + if (find(home, L.instance)) { + report(std::format("'{}' already has a session", L.instance)); + return kExitSetup; + } + const int listen_fd = platform::unix_listen(sock_path(home, L.instance)); + if (listen_fd < 0) { + report(std::format("cannot listen on {}: {}", sock_path(home, L.instance).string(), + platform::error_text(platform::last_error()))); + return kExitSetup; + } + auto ctl = platform::unix_pair(); + if (!ctl) { + platform::close_fd(listen_fd); + return kExitSetup; + } + L.env[std::string(kControlFdEnv)] = std::to_string((*ctl)[1]); + L.env[std::string(kTtlEnv)] = std::to_string(L.ttl); + if (L.refresh_root) L.env[std::string(kRootViewEnv)] = "1"; + else L.env.erase(std::string(kRootViewEnv)); + if (L.trace_exec) L.env["XLINGS_SESSION_TRACE"] = "1"; + if (L.trace_net) L.env["XLINGS_SESSION_NET_TRACE"] = "1"; + + Info info{ .instance = L.instance, .id = new_id(), .supervisor_pid = platform::get_pid(), + .started = observe::utc_now(), .backend = L.backend, .digest = L.digest, + .ttl = L.ttl, .detached = L.detached }; + + if (!L.detached) return supervise(home, L, info, listen_fd, *ctl, -1); + + // Detached: a supervisor that outlives this command. Readiness comes back + // on a pipe; EOF without it means the session never started. + auto ready = platform::make_pipe(); + if (!ready) return kExitSetup; + const int first = platform::fork_process(); + if (first < 0) return kExitSetup; + if (first == 0) { + platform::close_fd((*ready)[0]); + platform::new_session(); + const int second = platform::fork_process(); + if (second != 0) platform::exit_now(second < 0 ? 1 : 0); + // stdio of a daemon: nothing to read, its own log to write. + const int null = platform::open_null(); + auto log = home.logs_dir(L.instance) / "session.log"; + fs::create_directories(log.parent_path(), ec); + const int logfd = platform::open_for_append(log); + const int stdio[3] = {null, logfd >= 0 ? logfd : null, logfd >= 0 ? logfd : null}; + platform::redirect_stdio(stdio); + info.supervisor_pid = platform::get_pid(); + platform::exit_now(supervise(home, L, info, listen_fd, *ctl, (*ready)[1])); + } + platform::close_fd((*ready)[1]); + platform::close_fd(listen_fd); + platform::close_fd((*ctl)[0]); + platform::close_fd((*ctl)[1]); + for (int fd : L.keep_fds) platform::close_fd(fd); + (void)platform::wait_process(first, true); + platform::PollFd p{ (*ready)[0] }; + char b = 0; + const bool ok = platform::poll_fds(std::span(&p, 1), 15000) > 0 && platform::read_exact((*ready)[0], &b, 1); + platform::close_fd((*ready)[0]); + return ok ? 0 : kExitSetup; +} + +// ── joining ────────────────────────────────────────────────────────── + +ExecResult join(const HomeView& home, std::string_view instance, const ExecRequest& r) { + if constexpr (!kSessions) + return {kExitSetup, "setup", "sessions are not implemented on this platform yet"}; + if (r.argv.empty()) return {kExitSetup, "setup", "no command"}; + const int fd = platform::unix_connect(sock_path(home, instance)); + if (fd < 0) return {kExitSetup, "setup", "no running session for '" + std::string(instance) + "'"}; + + nlohmann::json req{{"op", "exec"}, {"argv", r.argv}, {"env", r.env}, {"cwd", r.cwd}, + {"tty", r.tty}}; + if (r.timeout) req["timeout_ms"] = r.timeout->count(); + const int stdio[3] = {0, 1, 2}; + if (!send_msg(fd, req, stdio)) { + platform::close_fd(fd); + return {kExitSetup, "setup", "could not reach the session"}; + } + // Ctrl-C and friends belong to the command, not to this client. + const int signal_fd = platform::route_signals({sig::interrupt, sig::terminate, sig::hangup, sig::quit}, + {sig::pipe}); + ExecResult result{kExitSetup, "setup", "the session ended"}; + while (true) { + platform::PollFd p[2] = {{fd}, {signal_fd}}; + platform::poll_fds(p, -1); + for (int s : platform::pending_signals()) send_msg(fd, {{"op", "signal"}, {"sig", s}}); + if (!p[0].readable && !p[0].closed) continue; + auto m = recv_msg(fd); + if (!m) break; + platform::close_fds(m->fds); + const auto& j = m->json; + if (j.contains("started")) continue; + if (j.contains("exit")) { + result = {j["exit"].get(), "", ""}; + } else if (j.contains("signal")) { + result = {128 + j["signal"].get(), "", ""}; + } else if (j.contains("error")) { + const int e = j.value("errno", 0); + const int code = platform::is_not_found(e) ? kExitNotFound + : platform::is_not_executable(e) ? kExitCannotRun : kExitSetup; + result = {code, code == kExitSetup ? "setup" : "exec", j.value("error", "")}; + } + break; + } + platform::close_fd(fd); + return result; +} + +bool stop(const HomeView& home, std::string_view instance) { + auto info = find(home, instance); + if (!info) return false; + if (const int fd = platform::unix_connect(sock_path(home, instance)); fd >= 0) { + send_msg(fd, {{"op", "stop"}}); + platform::PollFd p{ fd }; + platform::poll_fds(std::span(&p, 1), 5000); + platform::close_fd(fd); + } + using namespace std::chrono_literals; + for (int i = 0; i < 100 && find(home, instance); ++i) std::this_thread::sleep_for(50ms); + if (find(home, instance)) { + platform::send_signal(info->sandbox_pid, sig::kill); + for (int i = 0; i < 40 && find(home, instance); ++i) std::this_thread::sleep_for(50ms); + } + return !find(home, instance); +} + +// ── inside ─────────────────────────────────────────────────────────── + +int session_init(std::span args) { + if constexpr (!kSessions) return kExitSetup; + auto env_int = [](std::string_view name, int fallback) { + const auto v = platform::environment(); + auto it = v.find(std::string(name)); + return it == v.end() ? fallback : std::atoi(it->second.c_str()); + }; + const int ctl = env_int(kControlFdEnv, -1); + const int ttl = env_int(kTtlEnv, 0); + platform::set_inheritable(ctl, false); + // Neither variable means anything to the commands that run here. + platform::unset_env_variable(std::string(kControlFdEnv)); + platform::unset_env_variable(std::string(kTtlEnv)); + + const bool rootView = env_int(kRootViewEnv, 0) == 1; + platform::unset_env_variable(std::string(kRootViewEnv)); + if (rootView) { + auto captured = platform::root_mount::capture(); + if (!captured) { + if (ctl >= 0) + (void)send_msg(ctl, {{"op", "root-mount-context"}, {"error", captured.error()}}); + return kExitSetup; + } + const bool sent = ctl >= 0 && send_msg(ctl, {{"op", "root-mount-context"}}, *captured); + for (const int descriptor : *captured) + platform::close_fd(descriptor); + if (!sent) + return kExitSetup; + } + + // --sandbox landlock: the write fence, before anything is started, so + // everything here and everything that joins later is inside it. Failing + // to put it up is a failure to set up, never a run without it. + { + const auto env = platform::environment(); + if (auto rw = env.find(std::string(kLandlockRwEnv)); rw != env.end()) { + std::vector paths; + std::istringstream in{rw->second}; + for (std::string line; std::getline(in, line);) if (!line.empty()) paths.emplace_back(line); + platform::unset_env_variable(std::string(kLandlockRwEnv)); + if (auto r = platform::landlock::restrict_writes(paths); !r) { + std::println(std::cerr, "xlings: {}", r.error()); + return kExitSetup; + } + } + const bool traceNet = env_int("XLINGS_SESSION_NET_TRACE", 0) == 1; + const bool traceExec = env_int("XLINGS_SESSION_TRACE", 0) == 1; + platform::unset_env_variable("XLINGS_SESSION_NET_TRACE"); + platform::unset_env_variable("XLINGS_SESSION_TRACE"); + if (traceNet || traceExec) { + // One filter/listener carries both classes. The pre-filter helper + // makes its sole SCM_RIGHTS handoff without auditing its own sendmsg. + std::promise> handoff; + auto pending = handoff.get_future(); + bool transferred { false }; + std::jthread helper([&, result = std::move(pending)] () mutable { + const auto [listener, reason] = result.get(); + if (listener >= 0 && ctl >= 0) { + const int descriptor[1] = {listener}; + transferred = send_msg(ctl, {{"op", "net-listener"}}, descriptor); + platform::close_fd(listener); + } else if (ctl >= 0) transferred = send_msg(ctl, {{"op", "net-listener"}, {"reason", reason}}); + }); + const int listener = platform::net_notify::listener({.network = traceNet, .exec = traceExec}); + handoff.set_value({listener, listener < 0 ? platform::error_text(platform::last_error()) : std::string{}}); + helper.join(); + if (listener < 0 || !transferred) return kExitSetup; + } + } + + std::vector main_argv; + if (auto it = std::ranges::find(args, std::string("--")); it != args.end()) + main_argv.assign(std::next(it), args.end()); + + int main_pid = -1; + platform::ExitStatus main_status{}; + bool main_done = main_argv.empty(); + // A child can finish between reaping and polling. Its signal stays on + // the self-pipe until the next reap, rather than waiting for the TTL tick. + const int signal_fd = platform::route_signals({sig::child}, {}); + if (signal_fd < 0) return kExitSetup; + if (!main_argv.empty()) { + main_pid = platform::fork_process(); + if (main_pid == 0) { + platform::close_fd(ctl); + platform::reset_signals(); + const int e = platform::exec_program(main_argv, platform::environment()); + std::println(std::cerr, "xlings: {}: {}", main_argv[0], platform::error_text(e)); + platform::exit_now(platform::is_not_found(e) ? kExitNotFound : kExitCannotRun); + } + if (main_pid < 0) return kExitSetup; + } + + // While the main command owns the terminal, Ctrl-C is its business. + platform::ignore_signals({sig::interrupt, sig::quit, sig::pipe}); + + std::map joined; + auto last_activity = std::chrono::steady_clock::now(); + bool ctl_open = ctl >= 0; + + auto kill_all = [&](int s) { + for (auto& [pid, id] : joined) platform::send_signal_group(pid, s); + if (main_pid > 0 && !main_done) platform::send_signal(main_pid, s); + }; + + while (true) { + (void)platform::pending_signals(); + while (auto child = platform::reap_child()) { + const auto [w, st] = *child; + if (w == main_pid) { + main_done = true; + main_status = st; + } else if (auto it = joined.find(w); it != joined.end()) { + nlohmann::json reply{{"id", it->second}}; + if (st.exited) reply["exit"] = st.code; + else reply["signal"] = st.signal; + if (ctl_open) send_msg(ctl, reply); + joined.erase(it); + last_activity = std::chrono::steady_clock::now(); + } + } + if (main_done && joined.empty()) { + if (!main_argv.empty()) return platform::exit_code(main_status); + if (!ctl_open) return 0; + if (ttl > 0 && std::chrono::steady_clock::now() - last_activity >= std::chrono::seconds(ttl)) + return 0; + } + platform::PollFd watched[2]{{ctl_open ? ctl : -1}, {signal_fd}}; + if (platform::poll_fds(watched, 200) <= 0) continue; + if (!ctl_open || (!watched[0].readable && !watched[0].closed)) continue; + auto m = recv_msg(ctl); + if (!m) { + // The supervisor is gone: nothing outside watches any more. + ctl_open = false; + kill_all(sig::terminate); + continue; + } + const auto op = m->json.value("op", ""); + const auto id = m->json.value("id", std::int64_t{0}); + if (op == "exec" && m->fds.size() == 3) { + std::vector argv; + for (auto& a : m->json["argv"]) if (a.is_string()) argv.push_back(a.get()); + std::map env; + const auto req_env = m->json.value("env", nlohmann::json::object()); + for (auto e = req_env.begin(); e != req_env.end(); ++e) + if (e.value().is_string()) env[e.key()] = e.value().get(); + const auto cwd = m->json.value("cwd", std::string("/")); + const bool tty = m->json.value("tty", false); + + auto report_pipe = platform::make_pipe(); + if (!report_pipe) { + send_msg(ctl, {{"id", id}, {"error", "pipe failed"}, {"errno", platform::last_error()}}); + platform::close_fds(m->fds); + continue; + } + const int pid = platform::fork_process(); + if (pid == 0) { + platform::close_fd((*report_pipe)[0]); + platform::close_fd(ctl); + platform::reset_signals(); + // A session of its own: no controlling terminal to inject + // into. A terminal it was handed is tried as its controlling + // one; when the terminal already belongs to another session + // (the common case) it runs without job control. + platform::new_session(); + platform::redirect_stdio(m->fds); + if (tty) (void)platform::take_controlling_terminal(); + std::error_code cec; + fs::current_path(cwd, cec); + if (cec) fs::current_path("/", cec); + int e = platform::exec_program(argv, env); + (void)platform::write_fd((*report_pipe)[1], + std::string_view(reinterpret_cast(&e), sizeof(e))); + platform::exit_now(platform::is_not_found(e) ? kExitNotFound : kExitCannotRun); + } + platform::close_fd((*report_pipe)[1]); + platform::close_fds(m->fds); + if (pid < 0) { + platform::close_fd((*report_pipe)[0]); + send_msg(ctl, {{"id", id}, {"error", "fork failed"}, {"errno", platform::last_error()}}); + continue; + } + int e = 0; + if (platform::read_exact((*report_pipe)[0], &e, sizeof(e))) { + platform::close_fd((*report_pipe)[0]); + (void)platform::wait_process(pid, true); + send_msg(ctl, {{"id", id}, {"error", platform::error_text(e)}, {"errno", e}}); + continue; + } + platform::close_fd((*report_pipe)[0]); + joined[pid] = id; + last_activity = std::chrono::steady_clock::now(); + send_msg(ctl, {{"id", id}, {"started", true}, {"pid", pid}}); + } else if (op == "kill" || op == "signal") { + const int s = op == "kill" ? sig::kill : m->json.value("sig", sig::terminate); + for (auto& [pid, jid] : joined) if (jid == id) platform::send_signal_group(pid, s); + platform::close_fds(m->fds); + } else if (op == "stop") { + kill_all(sig::terminate); + platform::close_fds(m->fds); + return 0; + } else { + platform::close_fds(m->fds); + } + } +} + +} // namespace xlings::subos::session diff --git a/modules/subos/src/session/session.cppm b/modules/subos/src/session/session.cppm new file mode 100644 index 000000000..6752dc971 --- /dev/null +++ b/modules/subos/src/session/session.cppm @@ -0,0 +1,143 @@ +export module xlings.subos.session; + +import std; +import xlings.libs.json; +import xlings.subos.home_view; +import xlings.subos.policy; + +// Sessions (design §12, §16): one running sandbox per instance, hosted by a +// supervisor outside it. +// +// host: supervisor (an xlings process) -- exec.sock, audit, signals +// | fork + wait (no more execvp: something outside the +// | sandbox stays to watch it, F15) +// sandbox: session-init (xlings in an internal mode, the first process) +// |-- the main command (the shell of `use`, or `--cmd`) +// `-- commands that joined (`exec`, a second `use`) +// +// A client joins by connecting to /run/subos//exec.sock and passing +// its stdin/stdout/stderr with SCM_RIGHTS. The supervisor records the request +// and hands request and descriptors to session-init over a private socket; +// the process inside reads and writes the client's terminal or pipes +// directly -- nothing relays the bytes. The exit status comes back the same +// way. Clients never talk to the sandbox: only the supervisor does, so the +// sandbox cannot answer for it. +// +// Messages are one JSON object per SOCK_SEQPACKET datagram, descriptors +// attached to the datagram they belong to. +export namespace xlings::subos::session { + +namespace fs = std::filesystem; + +// Exit codes (design §12.2), shared by every surface that runs a command. +inline constexpr int kExitSetup = 125; // failed before the command started +inline constexpr int kExitCannotRun = 126; // found, could not be executed +inline constexpr int kExitNotFound = 127; // not found +inline constexpr int kExitTimeout = 124; // --timeout +// signal n -> 128 + n + +// What a live session publishes in run/subos//session.json. +struct Info { + std::string instance; + std::string id; + int supervisor_pid { 0 }; + int sandbox_pid { 0 }; + std::string started; // UTC + std::string backend; + std::string digest; // of the compiled spec: a join with a different spec is refused + int ttl { 0 }; // idle seconds after the last command; 0 = until stop + bool detached { false }; // started by `subos start` +}; + +nlohmann::json to_json(const Info& i); +Info info_from_json(const nlohmann::json& j); + +// The live session of an instance. A session.json whose supervisor is gone +// is stale: it is removed, and the instance has no session. +std::optional find(const HomeView& home, std::string_view instance); +std::vector list(const HomeView& home); + +// ── Hosting ────────────────────────────────────────────────────────── + +struct Launch { + std::string instance; + std::vector argv; // the backend's argv, session-init inside + std::map env; // the backend's environment + std::vector keep_fds; // descriptors the backend must inherit + std::string backend; + std::string digest; + nlohmann::json spec; // SandboxSpec::describe(), for the audit + bool audit_required { false }; // locked: refuse/terminate on a lost record + std::map exec_env; // base environment of joined commands + std::vector env_pass; // what a joined command may add + std::string default_cwd; // inside + int ttl { 0 }; + bool detached { false }; + // Attached only: end the session after this long and return 124. + std::optional timeout; + // net=nat: pasta's argv without its target. The backend then starts in a + // user + network namespace made for it, which pasta has configured. + std::vector pasta; + std::string proxy_url; // Linux, no-route netns and private relay + bool trace_net { false }; // nat connect/sendto attempts + // The broker (xlings.subos.broker): set, the supervisor listens on + // HomeView::broker_socket and runs what the policy allows with `exe` + // (this client, on the host) in `broker_env`. + std::optional broker_policy; + std::vector broker_exe; + std::map broker_env; + // observe=full (design §22): every program executed in the sandbox is + // recorded, through a seccomp user-notification filter on execve. + bool trace_exec { false }; + // observe>=standard: host paths mapped read-write; what changed in them + // during the session is listed when it ends. + std::vector rw_paths; + // Trusted owner code refreshes a root's checked mount view before a + // successful broker result becomes visible inside the sandbox. + std::function(std::span)> refresh_root; + std::function finalize_root; +}; + +// The environment variables session-init reads its control socket and its +// idle TTL from. `host` sets both; the caller does not. +inline constexpr std::string_view kControlFdEnv = "XLINGS_SESSION_FD"; +inline constexpr std::string_view kTtlEnv = "XLINGS_SESSION_TTL"; +inline constexpr std::string_view kRootViewEnv = "XLINGS_SESSION_ROOT_VIEW"; +// --sandbox landlock: the writable paths, one per line; session-init fences +// itself with them before it starts anything. +inline constexpr std::string_view kLandlockRwEnv = "XLINGS_SESSION_LANDLOCK_RW"; + +// Run the session to its end. Attached: returns the main command's exit code +// (128+n for a signal). Detached: forks a supervisor that outlives this +// process and returns 0 once the session is ready, or kExitSetup. +int host(const HomeView& home, Launch launch); + +// ── Joining ────────────────────────────────────────────────────────── + +struct ExecRequest { + std::vector argv; + std::map env; // candidates; filtered by the session + std::string cwd; // empty = the session's default + std::optional timeout; + bool tty { false }; +}; + +struct ExecResult { + int exit_code { kExitSetup }; + std::string phase; // "setup" when it never started + std::string error; +}; + +// Run a command in the live session of `instance`, with this process's +// stdin/stdout/stderr. +ExecResult join(const HomeView& home, std::string_view instance, const ExecRequest& request); + +// Ask a live session to end (SIGTERM to its sandbox through the supervisor). +bool stop(const HomeView& home, std::string_view instance); + +// ── Inside ─────────────────────────────────────────────────────────── + +// `xlings __session-init [-- ]`: the sandbox's first process. +int session_init(std::span argv); + +} // namespace xlings::subos::session diff --git a/modules/subos/src/subos.cppm b/modules/subos/src/subos.cppm new file mode 100644 index 000000000..5882f7286 --- /dev/null +++ b/modules/subos/src/subos.cppm @@ -0,0 +1,15 @@ +export module xlings.subos; + +import std; + +// The SubOS core's identity (design §23). Kept deliberately small: the parts +// live in their own modules (`xlings.subos.manifest`, `.policy`, `.spec`, ...) +// and are imported by name, so that importing the package's umbrella never +// pulls a dependency a caller did not ask for. +export namespace xlings::subos { + +// The version of the SubOS core's contracts (policy schema, spec schema, +// session protocol). Bumped when one of them changes incompatibly. +inline constexpr std::string_view kCoreVersion = "1"; + +} // namespace xlings::subos diff --git a/modules/subos/src/views/root/library_cache.cpp b/modules/subos/src/views/root/library_cache.cpp new file mode 100644 index 000000000..f6c9dcd00 --- /dev/null +++ b/modules/subos/src/views/root/library_cache.cpp @@ -0,0 +1,162 @@ +module xlings.subos.library_cache; + +import std; +import xlings.platform; +import xlings.libs.json; +import xlings.libs.sha256; +import xlings.subos.caps; + +namespace xlings::subos::library_cache { +namespace { + +class Staging { + fs::path path_; + +public: + explicit Staging(fs::path path) : path_(std::move(path)) {} + ~Staging() { + std::error_code ignored; + fs::remove_all(path_, ignored); // subos-remove-all-ok: exclusively reserved ldconfig staging directory + } + const fs::path& path() const { return path_; } +}; + +std::expected read_cache(const fs::path& path) { + std::error_code ec; + if (!fs::is_regular_file(fs::symlink_status(path, ec)) || ec) + return std::unexpected("library cache is not a regular file: " + path.string()); + if (fs::file_size(path, ec) > 128 * 1024 * 1024 || ec) + return std::unexpected("library cache size cannot be read or exceeds 128 MiB"); + std::ifstream input(path, std::ios::binary); + if (!input) return std::unexpected("cannot read library cache: " + path.string()); + std::string bytes; + try { bytes.assign(std::istreambuf_iterator(input), {}); } + catch (const std::exception& error) { return std::unexpected(error.what()); } + if (input.bad() || bytes.size() < 32 || (!bytes.starts_with("glibc-ld.so.cache") && !bytes.starts_with("ld.so-1.7.0"))) + return std::unexpected("library cache has an unknown format: " + path.string()); + return bytes; +} + +std::expected check_ownership(const fs::path& record, + const std::string& digest) { + std::error_code ec; + const auto status = fs::symlink_status(record, ec); + if (ec && ec != std::errc::no_such_file_or_directory) + return std::unexpected("cannot inspect library cache ownership: " + ec.message()); + if (status.type() == fs::file_type::not_found) { + if (digest.empty()) return {}; + return std::unexpected("existing library cache has no xlings ownership record; it is left alone"); + } + if (!fs::is_regular_file(status)) + return std::unexpected("library cache ownership record is not a regular file"); + if (fs::file_size(record, ec) > 16 * 1024 || ec) + return std::unexpected("library cache ownership record cannot be read or is too large"); + std::ifstream input(record, std::ios::binary); + if (!input) return std::unexpected("cannot read library cache ownership record"); + const auto document = nlohmann::json::parse(input, nullptr, false); + if (input.bad() || !document.is_object() || !document.contains("format") + || !document["format"].is_number_integer() || document["format"] != 1) + return std::unexpected("invalid library cache ownership record"); + for (const auto key : {"previous", "next"}) { + const auto field = document.find(key); + if (field == document.end() || !field->is_string()) + return std::unexpected("invalid library cache ownership digest"); + const auto value = field->get(); + if (!value.empty() && (value.size() != 64 || !std::ranges::all_of(value, [](char c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); + }))) return std::unexpected("invalid library cache ownership digest"); + } + if (!digest.empty() && document["previous"] != digest && document["next"] != digest) + return std::unexpected("library cache was changed outside xlings; it is left alone"); + return {}; +} + +} // namespace + +std::vector command(const fs::path& root, const HomeView& home, + const fs::path& scratch, const fs::path& bwrap, + std::span bindings) { + std::vector argv; + std::string output = (scratch / "ld.so.cache").string(); + if (root != "/") { + if (bindings.empty()) + throw std::runtime_error("root library cache needs a checked read-only store closure"); + argv = {bwrap.string(), "--unshare-all", "--die-with-parent", "--uid", "0", "--gid", "0", + "--ro-bind", root.string(), "/", "--tmpfs", "/run"}; + for (const auto& binding : bindings) + argv.insert(argv.end(), {"--ro-bind", binding.source.string(), binding.destination.string()}); + argv.insert(argv.end(), {"--bind", scratch.string(), "/run/xlings-ldcache", + "--remount-ro", "/run", "--proc", "/proc", "--dev", "/dev", "--"}); + output = "/run/xlings-ldcache/ld.so.cache"; + } + argv.insert(argv.end(), {"/usr/bin/ldconfig", "-X", "-i", "-C", output, + "-f", "/etc/ld.so.conf", "/usr/lib", "/usr/lib64"}); + return argv; +} + +std::expected refresh(const fs::path& root, const HomeView& home, + std::string_view instance, + std::span bindings) { + try { + std::error_code ec; + if (!fs::exists(root / "usr/bin/ldconfig", ec)) { + if (ec) return std::unexpected("cannot inspect ldconfig: " + ec.message()); + return false; + } + const auto cache = root / "etc/ld.so.cache"; + const auto record = root / "etc/xlings/ldcache.json"; + std::string previous; + const auto status = fs::symlink_status(cache, ec); + if (ec && ec != std::errc::no_such_file_or_directory) + return std::unexpected("cannot inspect library cache: " + ec.message()); + if (status.type() != fs::file_type::not_found) { + const auto observed = read_cache(cache); + if (!observed) return std::unexpected(observed.error()); + previous = sha256::hex(*observed); + } + if (auto owned = check_ownership(record, previous); !owned) return std::unexpected(owned.error()); + for (const auto& directory : {cache.parent_path(), record.parent_path()}) { + const auto state = fs::symlink_status(directory, ec); + if (ec && ec != std::errc::no_such_file_or_directory) + return std::unexpected("cannot inspect library cache directory: " + ec.message()); + if (fs::exists(state) && !fs::is_directory(state)) + return std::unexpected("library cache directory must be a real directory"); + } + + fs::path backend; + if (root != "/") { + const auto measured = caps::probe(home, {}); + if (!measured.bwrap || !measured.bwrap->usable) + return std::unexpected("generating a root library cache needs usable bwrap; " + "run `xlings self doctor --isolation`"); + backend = measured.bwrap->bin; + } + const auto parent = home.run_dir(instance); + fs::create_directories(parent); + std::random_device random; + fs::path directory; + for (int attempt = 0; attempt < 32; ++attempt) { + const auto candidate = parent / std::format("ldcache-{:x}-{:x}", random(), random()); + if (fs::create_directory(candidate)) { directory = candidate; break; } + } + if (directory.empty()) return std::unexpected("cannot reserve library cache staging"); + Staging staging{directory}; + fs::permissions(directory, fs::perms::owner_all, fs::perm_options::replace); + const auto argv = command(root, home, directory, backend, bindings); + const auto exit = platform::run_argv_with_timeout(argv, std::chrono::minutes(2)); + if (exit != 0) return std::unexpected(std::format("root ldconfig failed (exit {})", exit)); + const auto bytes = read_cache(directory / "ld.so.cache"); + if (!bytes) return std::unexpected(bytes.error()); + fs::create_directories(record.parent_path()); + // A write-ahead ownership record recognises either side of a crash + // during publication, without adopting a regular file the user wrote. + const nlohmann::json ownership{{"format", 1}, {"previous", previous}, {"next", sha256::hex(*bytes)}}; + platform::write_file_atomic(record.string(), ownership.dump() + "\n"); + platform::write_file_atomic(cache.string(), *bytes); + return true; + } catch (const std::exception& error) { + return std::unexpected("cannot generate root library cache: " + std::string(error.what())); + } +} + +} // namespace xlings::subos::library_cache diff --git a/modules/subos/src/views/root/library_cache.cppm b/modules/subos/src/views/root/library_cache.cppm new file mode 100644 index 000000000..aa1b37c21 --- /dev/null +++ b/modules/subos/src/views/root/library_cache.cppm @@ -0,0 +1,24 @@ +export module xlings.subos.library_cache; + +import std; +import xlings.subos.home_view; +import xlings.platform.root_mount; + +export namespace xlings::subos::library_cache { + +namespace fs = std::filesystem; + +// Only ldconfig's output staging directory is writable. The root, its +// configuration and its payloads remain read-only during cache generation. +std::vector command(const fs::path& root, const HomeView& home, + const fs::path& scratch, const fs::path& bwrap, + std::span bindings = {}); + +// Missing ldconfig means a root has no glibc cache to generate. An existing +// malformed/unowned cache or failed generator is an error. A write-ahead +// digest record proves ownership without treating unreadable state as empty. +std::expected refresh(const fs::path& root, const HomeView& home, + std::string_view instance, + std::span bindings = {}); + +} // namespace xlings::subos::library_cache diff --git a/modules/subos/src/views/root/rootfs.cpp b/modules/subos/src/views/root/rootfs.cpp new file mode 100644 index 000000000..ec1bef926 --- /dev/null +++ b/modules/subos/src/views/root/rootfs.cpp @@ -0,0 +1,697 @@ +module xlings.subos.rootfs; + +import std; +import xlings.libs.json; +import xlings.platform; +import xlings.observe; +import xlings.store; + +namespace xlings::subos::rootfs { + +namespace { + +// libfoo.so, libfoo.so.1.2 -- and the loader, ld-linux-x86-64.so.2. +bool is_shared_object(std::string_view name) { + return (name.starts_with("lib") || name.starts_with("ld-")) + && (name.ends_with(".so") || name.find(".so.") != std::string_view::npos); +} + +bool is_executable_entry(const fs::directory_entry& e) { + std::error_code ec; + // A link counts as what it names (busybox's applets are links to it). + if (!e.is_regular_file(ec)) return false; + const auto perms = e.status(ec).permissions(); + return !ec && (perms & (fs::perms::owner_exec | fs::perms::group_exec | fs::perms::others_exec)) + != fs::perms::none; +} + +std::vector sorted_entries(const fs::path& dir) { + std::vector out; + std::error_code ec; + if (!fs::is_directory(dir, ec)) return out; + for (fs::directory_iterator it(dir, ec), end; !ec && it != end; it.increment(ec)) + out.push_back(*it); + std::ranges::sort(out, {}, [](const fs::directory_entry& e) { return e.path().filename(); }); + return out; +} + +std::string utc_now() { + return std::format("{:%Y-%m-%dT%H:%M:%SZ}", + std::chrono::floor(std::chrono::system_clock::now())); +} + +fs::path generation_dir(const fs::path& subos, int k) { + return subos / std::string(kGenerations) / std::to_string(k); +} + +nlohmann::json conflicts_json(const std::vector& cs) { + auto a = nlohmann::json::array(); + for (auto& c : cs) a.push_back({{"path", c.rel}, {"kept", c.kept}, {"dropped", c.dropped}}); + return a; +} + +// The links a generation holds, as "rel\ttarget" lines in order: what makes +// two plans the same generation. +std::string fingerprint(const Plan& p) { + std::string s; + for (auto& l : p.links) s += l.rel + "\t" + l.target.generic_string() + "\n"; + return s; +} + +std::optional read_checked_text(const fs::path& path) { + std::ifstream input(path, std::ios::binary | std::ios::ate); + if (!input) + return std::nullopt; + const auto size = static_cast(input.tellg()); + if (size < 0 || size > std::numeric_limits::max()) + return std::nullopt; + std::string text(static_cast(size), '\0'); + input.seekg(0); + if (!input.read(text.data(), static_cast(text.size())) || + input.peek() != std::char_traits::eof() || input.bad()) + return std::nullopt; + return text; +} + +std::string read_text(const fs::path& path) { + std::ifstream input(path, std::ios::binary); + return {std::istreambuf_iterator(input), {}}; +} + +bool safe_relative_link(const fs::path& path) { + if (path.empty() || path.is_absolute() || path.lexically_normal() != path) return false; + for (const auto& part : path) + if (part == ".." || part == ".") return false; + return *path.begin() == "usr" && std::distance(path.begin(), path.end()) > 1; +} + +// ── durability (ROOT-GEN-DURABLE) ──────────────────────────────────── +// +// A generation is published by two renames, and a rename is durable only +// once the directory holding it is flushed -- the entries it publishes, and +// the files they name, before it. `XLINGS_TRACE=durability` prints the +// order, which is what the test of it reads. + +void trace_sync(std::string_view what, const fs::path& path) { + if (observe::trace_enabled("durability")) observe::trace("durability", std::format("{} {}", what, path.string())); +} + +void sync_file(const fs::path& path) { + trace_sync("file", path); + (void)platform::sync_file(path); +} + +void sync_dir(const fs::path& path) { + trace_sync("dir", path); + platform::sync_directory(path); +} + +class OwnedStage { +private: + fs::path path_; + +public: + explicit OwnedStage(fs::path path) : path_{std::move(path)} {} + OwnedStage(OwnedStage&& other) noexcept : path_{std::exchange(other.path_, {})} {} + OwnedStage(const OwnedStage&) = delete; + ~OwnedStage() { + if (path_.empty()) return; + std::error_code ec; + fs::remove_all(path_, ec); // subos-remove-all-ok: exclusively created staging directory, never a preexisting path + } + const fs::path& path() const { return path_; } + + static std::expected create(const fs::path& parent) { + std::error_code ec; + const auto status = fs::symlink_status(parent, ec); + if (fs::is_symlink(status)) return std::unexpected("staging parent is a symlink: " + parent.string()); + fs::create_directories(parent, ec); + if (ec) return std::unexpected("cannot create staging parent: " + ec.message()); + static std::atomic serial { 0 }; + for (int attempt = 0; attempt < 100; ++attempt) { + const auto path = parent / std::format(".xlings-stage-{}-{}", + std::chrono::steady_clock::now().time_since_epoch().count(), serial++); + if (fs::create_directory(path, ec)) return OwnedStage{path}; + if (ec && ec != std::errc::file_exists) + return std::unexpected("cannot create staging directory: " + ec.message()); + } + return std::unexpected("cannot reserve staging directory"); + } +}; + +// Metadata alone is not ownership: every entry must be a recorded projection +// link, its parent directory, or one of the two metadata files we wrote. +bool owned_generation(const fs::path& subos, int generation, + std::string* checkedRecords = nullptr) { + const auto dir = generation_dir(subos, generation); + std::error_code ec; + if (!fs::is_directory(fs::symlink_status(subos / kGenerations, ec)) || ec || + !fs::is_directory(fs::symlink_status(dir, ec)) || ec) + return false; + for (const auto name : {"generation.json", "links.tsv"}) + if (!fs::is_regular_file(fs::symlink_status(dir / name, ec)) || ec) + return false; + const auto manifest = read_checked_text(dir / "generation.json"); + auto contents = read_checked_text(dir / "links.tsv"); + if (!manifest || !contents) + return false; + const auto metadata = nlohmann::json::parse(*manifest, nullptr, false); + if (!metadata.is_object() || !metadata.contains("number") || + !metadata["number"].is_number_integer() || metadata["number"] != generation) + return false; + std::unordered_map links; + std::unordered_set directories{"usr"}; + std::istringstream records(*contents); + for (std::string line; std::getline(records, line);) { + const auto tab = line.find('\t'); + if (tab == std::string::npos) + return false; + const fs::path rel = line.substr(0, tab); + if (!safe_relative_link(rel)) + return false; + const auto key = rel.generic_string(); + if (!links.emplace(key, line.substr(tab + 1)).second) + return false; + for (auto slash = key.rfind('/'); slash != std::string::npos; + slash = key.rfind('/', slash - 1)) + directories.insert(key.substr(0, slash)); + } + const auto prefix = dir.generic_string() + "/"; + std::size_t seen{0}; + bool sawUsr = false; + for (fs::recursive_directory_iterator it(dir, ec), end; !ec && it != end; it.increment(ec)) { + const auto path = it->path().generic_string(); + if (!path.starts_with(prefix)) + return false; + const auto rel = path.substr(prefix.size()); + if (const auto link = links.find(rel); link != links.end()) { + // read_symlink proves both the entry type and its exact target. + // Do not follow it even if its directory-entry type changed. + it.disable_recursion_pending(); + if (fs::read_symlink(it->path(), ec) != link->second || ec) + return false; + ++seen; + continue; + } + const auto status = it->symlink_status(ec); + if (ec) + return false; + if (fs::is_directory(status)) { + if (!directories.contains(rel)) + return false; + if (rel == "usr") + sawUsr = true; + } else if (!fs::is_regular_file(status) || + (rel != "generation.json" && rel != "links.tsv")) { + return false; + } + } + if (ec || !sawUsr || seen != links.size()) + return false; + if (checkedRecords) + *checkedRecords = std::move(*contents); + return true; +} + +// ── the inventory (ROOT-SWITCH-SCALE) ──────────────────────────────── +// +// What `owned_generation` proves by reading every link, recorded once when +// the generation is placed: the change stamp (inode + ctime) of each of its +// directories and of its two record files, and the payloads it links into. +// Adding, removing or replacing an entry changes its directory's ctime, and +// ctime cannot be set back, so equal stamps mean "as placed". It lives beside +// the generation (root.gen/..inventory), never inside: the tree is +// immutable once placed. Missing or unequal falls back to the full walk. + +fs::path inventory_path(const fs::path& subos, int generation) { + return subos / std::string(kGenerations) / std::format(".{}.inventory", generation); +} + +std::string stamp_line(std::string_view kind, std::string_view rel, const platform::ChangeStamp& s) { + return std::format("{}\t{}\t{}\t{}\t{}\n", kind, rel, s.inode, s.seconds, s.nanoseconds); +} + +std::expected write_inventory(const fs::path& subos, int generation, + const std::set& directories, + const std::set& payloads) { + const auto dir = generation_dir(subos, generation); + std::string text; + for (const auto& rel : directories) { + auto stamp = platform::change_stamp(rel.empty() ? dir : dir / rel); + if (!stamp) return std::unexpected("cannot stamp " + (dir / rel).string()); + text += stamp_line("dir", rel.generic_string(), *stamp); + } + for (const auto* name : {"generation.json", "links.tsv"}) { + auto stamp = platform::change_stamp(dir / name); + if (!stamp) return std::unexpected("cannot stamp " + (dir / name).string()); + text += stamp_line("file", name, *stamp); + } + for (const auto& payload : payloads) text += std::format("payload\t{}\n", payload.generic_string()); + try { + platform::write_file_atomic(inventory_path(subos, generation).string(), text); + } catch (const std::exception& e) { + return std::unexpected(std::string("cannot write the generation inventory: ") + e.what()); + } + return {}; +} + +enum class Inventory { Matches, Differs, Missing }; + +// `missingPayload` names the first payload the generation links into that +// is gone: the generation is intact and still cannot be used. +Inventory check_inventory(const fs::path& subos, int generation, fs::path* missingPayload = nullptr, + bool payloads = true) { + const auto text = read_checked_text(inventory_path(subos, generation)); + if (!text) return Inventory::Missing; + const auto dir = generation_dir(subos, generation); + auto number = [](std::string_view v, auto& out) { + auto [ptr, ec] = std::from_chars(v.data(), v.data() + v.size(), out); + return ec == std::errc{} && ptr == v.data() + v.size(); + }; + bool sawDir = false; + std::string_view rest(*text); + while (!rest.empty()) { + const auto eol = rest.find('\n'); + const auto line = rest.substr(0, eol); + rest = eol == std::string_view::npos ? std::string_view{} : rest.substr(eol + 1); + if (line.empty()) continue; + std::array f{}; + std::size_t n = 0; + for (std::size_t at = 0; n < f.size();) { + const auto tab = line.find('\t', at); + f[n++] = line.substr(at, tab == std::string_view::npos ? std::string_view::npos : tab - at); + if (tab == std::string_view::npos) break; + at = tab + 1; + } + if (n == 2 && f[0] == "payload") { + // One lstat: a payload root is a directory, never a link. + if (payloads && !platform::change_stamp(fs::path(f[1]))) { + if (missingPayload) *missingPayload = fs::path(f[1]); + return Inventory::Differs; + } + continue; + } + if (n != 5 || (f[0] != "dir" && f[0] != "file")) return Inventory::Differs; + platform::ChangeStamp want; + if (!number(f[2], want.inode) || !number(f[3], want.seconds) || !number(f[4], want.nanoseconds)) + return Inventory::Differs; + const auto have = platform::change_stamp(f[1].empty() ? dir : dir / fs::path(f[1])); + if (!have || *have != want) return Inventory::Differs; + sawDir |= f[0] == "dir" && f[1].empty(); + } + return sawDir ? Inventory::Matches : Inventory::Differs; +} + +// Every payload generation `generation` links into still exists. +std::optional missing_payload(const fs::path& subos, int generation) { + const auto text = read_checked_text(generation_dir(subos, generation) / "links.tsv"); + if (!text) return std::nullopt; + std::set seen; + std::istringstream records(*text); + for (std::string line; std::getline(records, line);) { + const auto tab = line.find('\t'); + if (tab == std::string::npos) continue; + const auto root = store::payload_root(line.substr(tab + 1)); + if (!root || !seen.insert(*root).second) continue; + std::error_code ec; + if (!fs::exists(*root, ec)) return *root; + } + return std::nullopt; +} + +} // namespace + +// ── plan ───────────────────────────────────────────────────────────── + +Plan plan(const Inputs& in) { + Plan p; + std::map claimed; // rel -> index in p.links + auto claim = [&](std::string rel, fs::path target, std::string from) { + if (auto it = claimed.find(rel); it != claimed.end()) { + auto& kept = p.links[it->second]; + // The same file reached another way (lib64 -> lib, a sysroot link + // into the payload) is not a conflict. + std::error_code ec; + if (kept.target != target && !fs::equivalent(kept.target, target, ec)) + p.conflicts.push_back({rel, kept.from, from}); + return; + } + claimed.emplace(rel, p.links.size()); + p.links.push_back({std::move(rel), std::move(target), std::move(from)}); + }; + + // Fixed shape of a merged /usr; claimed first so nothing takes the names. + claim("usr/lib64", "lib", "layout"); + claim("usr/sbin", "bin", "layout"); + + for (auto& prog : in.programs) claim("usr/bin/" + prog.name, prog.target, "program"); + for (auto& lib : in.libraries) claim("usr/lib/" + lib.name, lib.target, "library"); + + for (auto& payload : in.payloads) { + const auto who = payload.generic_string(); + for (auto sub : {"bin", "sbin"}) { + for (auto& e : sorted_entries(payload / sub)) + if (is_executable_entry(e)) + claim("usr/bin/" + e.path().filename().string(), e.path(), who); + } + // A kernel package's modules (and its vmlinuz, the systemd layout): + // usr/lib/modules/. + for (auto& e : sorted_entries(payload / "lib" / "modules")) + claim("usr/lib/modules/" + e.path().filename().string(), e.path(), who); + for (auto sub : {"lib", "lib64"}) { + for (auto& e : sorted_entries(payload / sub)) { + const auto name = e.path().filename().string(); + std::error_code ec; + if (is_shared_object(name) && !e.is_directory(ec)) + claim("usr/lib/" + name, e.path(), who); + } + } + } + + if (!in.sysroot_lib.empty()) { + for (auto& e : sorted_entries(in.sysroot_lib)) + claim("usr/lib/" + e.path().filename().string(), e.path(), "sysroot"); + } + for (auto& e : sorted_entries(in.sysroot_share)) + claim("usr/share/" + e.path().filename().string(), e.path(), "sysroot"); + if (!in.sysroot_usr.empty()) { + for (auto& e : sorted_entries(in.sysroot_usr)) { + const auto name = e.path().filename().string(); + std::error_code ec; + if (name == "lib" && e.is_directory(ec)) { + for (auto& l : sorted_entries(e.path())) + claim("usr/lib/" + l.path().filename().string(), l.path(), "sysroot"); + } else if (name == "share" && e.is_directory(ec)) { + for (auto& shared : sorted_entries(e.path())) + claim("usr/share/" + shared.path().filename().string(), shared.path(), "sysroot"); + } else if (name != "bin" && name != "sbin" && name != "lib64") { + claim("usr/" + name, e.path(), "sysroot"); + } + } + } + return p; +} + +// ── generations ────────────────────────────────────────────────────── + +std::vector generations(const fs::path& subos) { + std::vector out; + for (auto& e : sorted_entries(subos / std::string(kGenerations))) { + const auto name = e.path().filename().string(); + int k = 0; + auto [ptr, ec] = std::from_chars(name.data(), name.data() + name.size(), k); + if (ec == std::errc{} && ptr == name.data() + name.size() && k > 0) out.push_back(k); + } + std::ranges::sort(out); + return out; +} + +std::optional current(const fs::path& subos) { + std::error_code ec; + const auto target = platform::read_symlink(subos / std::string(kPointer), ec); + if (ec) return std::nullopt; + const auto name = target.filename().string(); + int k = 0; + auto [ptr, err] = std::from_chars(name.data(), name.data() + name.size(), k); + if (err != std::errc{} || ptr != name.data() + name.size() || k <= 0 + || target != fs::path(kGenerations) / std::to_string(k)) return std::nullopt; + return k; +} + +fs::path usr_of(const fs::path& subos) { return subos / std::string(kPointer) / "usr"; } + +std::optional info(const fs::path& subos, int generation) { + auto j = nlohmann::json::parse(read_text(generation_dir(subos, generation) / "generation.json"), + nullptr, false); + if (j.is_discarded() || !j.is_object()) return std::nullopt; + try { + GenerationInfo g; + g.number = generation; + g.created = j.value("created", ""); + g.reason = j.value("reason", ""); + g.links = j.value("links", std::size_t{0}); + const auto conflicts = j.value("conflicts", nlohmann::json::array()); + if (!conflicts.is_array()) return std::nullopt; + for (const auto& c : conflicts) + g.conflicts.push_back({c.value("path", ""), c.value("kept", ""), c.value("dropped", "")}); + return g; + } catch (const nlohmann::json::exception&) { + return std::nullopt; + } +} + +std::expected switch_to(const fs::path& subos, int generation, Flush flush, Verify verify) { + const bool payloads = verify == Verify::TreeAndPayloads; + std::error_code ec; + fs::path gone; + switch (check_inventory(subos, generation, &gone, payloads)) { + case Inventory::Matches: break; + case Inventory::Differs: + if (!gone.empty()) + return std::unexpected(std::format("generation {} links into {}, which is gone; reinstall it " + "or choose another generation", generation, gone.string())); + [[fallthrough]]; + case Inventory::Missing: + if (!owned_generation(subos, generation)) + return std::unexpected(std::format("generation {} is absent or is not an intact projection", generation)); + if (auto missing = payloads ? missing_payload(subos, generation) : std::nullopt) + return std::unexpected(std::format("generation {} links into {}, which is gone; reinstall it " + "or choose another generation", generation, missing->string())); + } + const auto pointer = subos / std::string(kPointer); + const auto status = fs::symlink_status(pointer, ec); + if (ec && ec != std::errc::no_such_file_or_directory) + return std::unexpected("cannot inspect root pointer: " + ec.message()); + if (fs::exists(status) && !fs::is_symlink(status)) + return std::unexpected("root pointer is not a symlink; it is left alone"); + auto scratch = OwnedStage::create(subos); + if (!scratch) return std::unexpected(scratch.error()); + const auto staged = scratch->path() / "pointer"; + fs::create_directory_symlink(fs::path(kGenerations) / std::to_string(generation), staged, ec); + if (ec) return std::unexpected("cannot stage root pointer: " + ec.message()); + fs::rename(staged, pointer, ec); + if (ec) return std::unexpected("cannot move root pointer: " + ec.message()); + if (flush == Flush::Durable) sync_dir(subos); + return {}; +} + +std::expected commit(const fs::path& subos, const Plan& p, + std::string_view reason) { + std::set names; + for (const auto& link : p.links) { + const fs::path rel = link.rel; + if (!safe_relative_link(rel) || !names.insert(rel).second) + return std::unexpected("invalid projection path: " + link.rel); + if (link.rel.find_first_of("\t\n\r") != std::string::npos + || link.target.generic_string().find_first_of("\t\n\r") != std::string::npos) + return std::unexpected("projection path cannot contain record separators"); + } + for (const auto& rel : names) + for (auto parent = rel.parent_path(); !parent.empty(); parent = parent.parent_path()) + if (names.contains(parent)) return std::unexpected("projection link has a link as parent: " + rel.string()); + const auto gens = generations(subos); + const auto now = current(subos); + const auto print = fingerprint(p); + if (now) { + std::string checkedRecords; + if (check_inventory(subos, *now) == Inventory::Matches) { + if (auto text = read_checked_text(generation_dir(subos, *now) / "links.tsv")) checkedRecords = *text; + } else if (!owned_generation(subos, *now, &checkedRecords)) { + checkedRecords.clear(); + } + if (!checkedRecords.empty() && checkedRecords == print) return *now; + } + if (!gens.empty() && gens.back() == std::numeric_limits::max()) + return std::unexpected("generation numbers are exhausted"); + const int next = gens.empty() ? 1 : gens.back() + 1; + const auto dir = generation_dir(subos, next); + auto scratch = OwnedStage::create(subos / std::string(kGenerations)); + if (!scratch) return std::unexpected(scratch.error()); + const auto staging = scratch->path() / "generation"; + std::error_code ec; + fs::create_directories(staging / "usr", ec); + if (ec) return std::unexpected("cannot create generation: " + ec.message()); + for (const auto& link : p.links) { + const auto at = staging / link.rel; + fs::create_directories(at.parent_path(), ec); + if (ec) return std::unexpected("cannot create projection directory: " + ec.message()); + fs::create_symlink(link.target, at, ec); + if (ec) return std::unexpected(std::format("cannot link {}: {}", link.rel, ec.message())); + } + { + std::ofstream records(staging / "links.tsv", std::ios::binary); + records << print; + records.close(); + nlohmann::json metadata{{"number", next}, {"created", utc_now()}, {"reason", std::string(reason)}, + {"links", p.links.size()}, {"conflicts", conflicts_json(p.conflicts)}}; + std::ofstream manifest(staging / "generation.json"); + manifest << metadata.dump(2) << "\n"; + manifest.close(); + if (!records || !manifest) return std::unexpected("cannot write generation metadata"); + } + // Durable before it is published: the records, then every directory the + // links were created in, deepest first. + std::set directories{fs::path{}, fs::path{"usr"}}; + std::set payloads; + for (const auto& link : p.links) { + for (auto parent = fs::path(link.rel).parent_path(); !parent.empty(); parent = parent.parent_path()) + directories.insert(parent); + if (auto root = store::payload_root(link.target)) payloads.insert(*root); + } + sync_file(staging / "links.tsv"); + sync_file(staging / "generation.json"); + std::vector deepest(directories.begin(), directories.end()); + std::ranges::sort(deepest, std::greater<>{}, [](const fs::path& d) { + return std::ranges::distance(d.begin(), d.end()); + }); + for (const auto& d : deepest) sync_dir(d.empty() ? staging : staging / d); + if (auto placed = platform::rename_no_replace(staging, dir); !placed) + return std::unexpected(std::format("cannot place generation {}: {}", next, placed.error())); + sync_dir(subos / std::string(kGenerations)); + if (auto inventory = write_inventory(subos, next, directories, payloads); !inventory) + trace_sync("inventory-skipped", inventory_path(subos, next)); // the full check still works + if (auto sw = switch_to(subos, next, Flush::Durable, Verify::Tree); !sw) return std::unexpected(sw.error()); + return next; +} + +std::expected, std::string> linked_targets(const fs::path& subos, int generation) { + const auto file = generation_dir(subos, generation) / "links.tsv"; + const auto text = read_checked_text(file); + if (!text) return std::unexpected(file.string() + ": cannot be read"); + std::vector out; + std::istringstream records(*text); + for (std::string line; std::getline(records, line);) { + const auto tab = line.find('\t'); + if (tab == std::string::npos) return std::unexpected(file.string() + ": malformed record"); + out.emplace_back(line.substr(tab + 1)); + } + return out; +} + +std::expected validate_generation(const fs::path& subos, int generation) { + if (generation <= 0 || !owned_generation(subos, generation)) + return std::unexpected(generation_dir(subos, generation).string() + + ": projection is incomplete, modified, or not owned by xlings"); + return {}; +} + +std::vector prune(const fs::path& subos, std::size_t keep, std::span pinned) { + std::vector removed; + auto gens = generations(subos); + const auto now = current(subos); + std::error_code pointerError; + const auto pointer = fs::symlink_status(subos / std::string(kPointer), pointerError); + if (!now && (fs::exists(pointer) || (pointerError && pointerError != std::errc::no_such_file_or_directory))) + return removed; + std::size_t kept = 0; + for (auto it = gens.rbegin(); it != gens.rend(); ++it) { + const int k = *it; + if (now && k == *now) continue; + if (std::ranges::find(pinned, k) != pinned.end()) continue; + if (kept < keep) { ++kept; continue; } + if (!owned_generation(subos, k)) continue; + std::error_code ec; + fs::remove_all(generation_dir(subos, k), ec); // subos-remove-all-ok: intact manifest and complete inventory prove derived generation ownership + if (!ec) { + fs::remove(inventory_path(subos, k), ec); + removed.push_back(k); + } + } + std::ranges::sort(removed); + return removed; +} + +// ── a root tree ────────────────────────────────────────────────────── + +std::expected lay_out(const fs::path& root, const fs::path& usr_target, + const fs::path& home) { + std::error_code ec; + auto machine = platform::machine_etc::Directory::open(root, true); + if (!machine) return std::unexpected(machine.error()); + + // /usr: replaced only when it names something else (a SubOS moved). + const auto usr = root / "usr"; + if (fs::is_symlink(usr, ec)) { + if (fs::read_symlink(usr, ec) != usr_target) { + auto scratch = OwnedStage::create(root); + if (!scratch) return std::unexpected(scratch.error()); + const auto staged = scratch->path() / "usr"; + fs::create_directory_symlink(usr_target, staged, ec); + if (!ec) fs::rename(staged, usr, ec); + if (ec) return std::unexpected(std::format("cannot repoint {}: {}", usr.string(), ec.message())); + } + } else if (!fs::exists(usr, ec)) { + fs::create_directory_symlink(usr_target, usr, ec); + if (ec) return std::unexpected(std::format("cannot link {}: {}", usr.string(), ec.message())); + } else { + return std::unexpected(std::format("{} is a directory, not a projection; it is left alone", + usr.string())); + } + + for (auto [name, target] : {std::pair{"bin", "usr/bin"}, std::pair{"sbin", "usr/bin"}, + std::pair{"lib", "usr/lib"}, std::pair{"lib64", "usr/lib64"}}) { + const auto at = root / name; + const auto status = fs::symlink_status(at, ec); + if (ec && ec != std::errc::no_such_file_or_directory) + return std::unexpected("cannot inspect root entry: " + ec.message()); + if (!fs::exists(status)) { + fs::create_directory_symlink(target, at, ec); + if (ec) return std::unexpected("cannot create merged-usr link: " + ec.message()); + } + } + for (auto d : {"etc", "var/tmp", "var/log", "var/lib", "var/cache", "home", "srv", "tmp", "proc", + "sys", "dev", "run", "mnt", "opt", "root"}) { + std::optional mode; + if (std::string_view(d) == "root") mode = 0700; + else if (std::string_view(d) == "tmp" || std::string_view(d) == "var/tmp") mode = 01777; + auto directory = machine->ensure_directory(d, mode); + if (!directory) return std::unexpected(directory.error()); + if (!*directory) return std::unexpected("root directory is blocked by a user file: " + (root / d).string()); + } + if (!home.empty()) { + auto directory = machine->ensure_directory(home.relative_path()); + if (!directory) return std::unexpected(directory.error()); + if (!*directory) return std::unexpected("system home mount point is blocked by a user file: " + home.string()); + } + return {}; +} + +std::optional host_of(const fs::path& root, const fs::path& home) { + std::error_code ec; + const auto target = fs::read_symlink(root / "usr", ec); + if (ec) return std::nullopt; + // /subos//root/usr + const auto rel = target.lexically_relative(home / "subos"); + auto it = rel.begin(); + if (rel.empty() || it == rel.end() || it->string() == "..") return std::nullopt; + const auto name = it->string(); + if (++it == rel.end() || it->string() != kPointer) return std::nullopt; + if (++it == rel.end() || it->string() != "usr" || ++it != rel.end()) return std::nullopt; + return name; +} + +std::optional running_projection(const fs::path& root, const fs::path& scope) { + std::error_code ec; + const auto target = platform::read_symlink(root / "usr", ec); + if (ec || !target.is_absolute() || target.filename() != "usr") return std::nullopt; + const auto parent = target.parent_path(); + fs::path owner; + if (parent.filename() == kPointer) owner = parent.parent_path(); + else if (parent.parent_path().filename() == kGenerations) { + const auto text = parent.filename().string(); + int generation = 0; + const auto parsed = std::from_chars(text.data(), text.data() + text.size(), generation); + if (parsed.ec != std::errc{} || parsed.ptr != text.data() + text.size() || generation <= 0) + return std::nullopt; + owner = parent.parent_path().parent_path(); + } else return std::nullopt; + if (owner != scope && (!fs::equivalent(owner, scope, ec) || ec)) return std::nullopt; + for (const int generation : generations(scope)) { + ec.clear(); + if (fs::equivalent(root / "usr", generation_dir(scope, generation) / "usr", ec) && !ec) + return generation; + } + return std::nullopt; +} + +} // namespace xlings::subos::rootfs diff --git a/modules/subos/src/views/root/rootfs.cppm b/modules/subos/src/views/root/rootfs.cppm new file mode 100644 index 000000000..bd7fe63c1 --- /dev/null +++ b/modules/subos/src/views/root/rootfs.cppm @@ -0,0 +1,158 @@ +export module xlings.subos.rootfs; + +import std; +import xlings.libs.json; + +// The root projection (design part 2 §6): a SubOS presented as `/`. +// +// A SubOS's content -- its workspace, its home, its policy -- does not change +// with how a process sees it. The root projection is the third way to see it, +// after the PATH overlay and the sandbox view: `/usr` is a tree of links into +// the payloads of what the SubOS has active, the rest of a root (`/etc`, +// `/home`, `/var`) is machine state, and the two never mix. +// +// /root -> root.gen/ the current generation (one rename) +// /root.gen//usr/... links, nothing else +// /rootfs/ an instance's root tree (/usr -> /root/usr) +// +// Everything here is file-system work on paths it is given: no Config, no +// version DB. The adapter (src/core/subos/rootfs.cpp) turns a workspace into +// `Inputs`; this module decides what the tree holds and writes it. +export namespace xlings::subos::rootfs { + +namespace fs = std::filesystem; + +// ── what /usr holds ────────────────────────────────────────────────── + +struct Program { + std::string name; // usr/bin/ + fs::path target; // the payload's file, or the shim when it has alias arguments +}; + +struct Library { + std::string name; // usr/lib/ + fs::path target; +}; + +struct Inputs { + // Registered first: a name xvm knows wins over a file a payload carries. + std::vector programs; + std::vector libraries; + // The payload roots of the active packages, in workspace order. A + // package's bin/ and sbin/ are what it puts in /usr/bin, its lib/ and + // lib64/ shared objects what it puts in /usr/lib -- an ordinary + // distribution's semantics (busybox's applet links arrive this way). + std::vector payloads; + // The SubOS's sysroot (/usr: include/, share/, what the sysroot + // model wrote), xvm's library links (/lib), and runtime datasets + // (/share), lowest priority. + fs::path sysroot_usr; + fs::path sysroot_lib; + fs::path sysroot_share; +}; + +struct Link { + std::string rel; // "usr/bin/sh" + fs::path target; // absolute + std::string from; // who claimed it: "program", "library", a payload, "sysroot" +}; + +struct Conflict { + std::string rel; + std::string kept; // the claimant that has it + std::string dropped; // the one that wanted it too +}; + +struct Plan { + std::vector links; + std::vector conflicts; +}; + +Plan plan(const Inputs& in); + +// ── generations ────────────────────────────────────────────────────── + +inline constexpr std::string_view kPointer = "root"; +inline constexpr std::string_view kGenerations = "root.gen"; +inline constexpr std::string_view kTree = "rootfs"; + +// Ascending. Unreadable or absent is empty. +std::vector generations(const fs::path& subos); +std::optional current(const fs::path& subos); +// Reuse the same full inventory proof that authorizes generation pruning. +std::expected validate_generation(const fs::path& subos, int generation); +// /root/usr: what a root's /usr points at. +fs::path usr_of(const fs::path& subos); + +struct GenerationInfo { + int number { 0 }; + std::string created; // UTC, ISO 8601 + std::string reason; + std::size_t links { 0 }; + std::vector conflicts; +}; +std::optional info(const fs::path& subos, int generation); + +// Writes the next generation and moves the pointer to it. The pointer moves +// with one rename(2): a reader resolves the old generation or the new one, +// never neither (ROOT-GEN-ATOMIC). A reader acquires that link once and uses +// the referenced immutable directory for its whole read; independent lookups +// through the changing pointer do not form a snapshot. A plan identical to the current +// generation's writes nothing and returns the current number. +std::expected commit(const fs::path& subos, const Plan& plan, + std::string_view reason); +// Whether a switch waits for the pointer to reach stable storage. Every +// caller in the product switches durably (ROOT-GEN-DURABLE); Deferred exists +// so the performance lane can time the CHECK apart from the disk's flush +// latency, which is the device's number, not ours. +enum class Flush { Durable, Deferred }; + +// What a switch proves before it moves the pointer. Choosing an existing +// generation (rollback, a boot entry) proves its tree is as placed AND that +// every payload it links into is still there; publishing one commit just +// built proves the tree only -- its links may name payloads at a logical path +// (an exported image's home, a prefix domain) that exists where it is used, +// not here. +enum class Verify { TreeAndPayloads, Tree }; + +// The pointer to an existing generation (rollback). Nothing is re-planned. +std::expected switch_to(const fs::path& subos, int generation, + Flush flush = Flush::Durable, + Verify verify = Verify::TreeAndPayloads); +// How many generations a SubOS keeps besides its current one, the one a boot +// entry or the running machine uses (design part 3 §7.1). +inline constexpr std::size_t kKeepGenerations = 5; + +// Removes generations beyond the `keep` newest; never the current one or a +// `pinned` one. Only trees whose complete contents match their projection +// manifest are derived data; unknown or modified trees are left alone. +// A pointer that exists but does not name a generation removes nothing: the +// generation it really refers to is not known, so none may go. +// Returns what it removed. +std::vector prune(const fs::path& subos, std::size_t keep, std::span pinned = {}); + +// The absolute targets generation `generation` links to, from its record +// (links.tsv). A generation that cannot be read is an error, never "links to +// nothing": what it holds must stay held (design part 3 §7.1). +std::expected, std::string> linked_targets(const fs::path& subos, int generation); + +// ── a root tree ────────────────────────────────────────────────────── + +// What a root needs besides /usr: the merged-usr links, the mount points and +// the machine-state directories, and `usr` -> `usr_target` (an absolute +// link: it resolves the same inside a sandbox, a container and a booted +// machine, because the home is at the same path in all three). `home` is +// the home's logical path; its directory is created as a mount point. +// Existing files are never replaced, except a `usr` link pointing elsewhere. +std::expected lay_out(const fs::path& root, const fs::path& usr_target, + const fs::path& home); + +// The SubOS a root's /usr points at, read from the link: the host of a +// machine in deployment R. nullopt when /usr is not a projection. +std::optional host_of(const fs::path& root, const fs::path& home); + +// The scope whose immutable generation /usr actually names. Also accepts a +// session's pinned generation after its host current pointer has moved. +std::optional running_projection(const fs::path& root, const fs::path& scope); + +} // namespace xlings::subos::rootfs diff --git a/modules/testkit/include/xlings/xtest.hpp b/modules/testkit/include/xlings/xtest.hpp new file mode 100644 index 000000000..dbea1141e --- /dev/null +++ b/modules/testkit/include/xlings/xtest.hpp @@ -0,0 +1,84 @@ +// XTEST: a gtest TEST with metadata (design §24.3). +// +// #include +// import xlings.testkit; // before the header: it uses the module, +// #include "xlings/xtest.hpp" // and mcpp's scanner reads imports from +// import std; // the source file, not from headers +// +// XTEST(SubosExec, ExitCodeIsTheCommands, +// .area = "subos", .covers = {"EXIT-CMD"}, .requires_ = {"sandbox"}) { +// auto home = xlings::testkit::Home::isolated("exec"); +// ... +// } +// +// The metadata is registered at static-init time, so `XTEST_META_OUT` lists +// every test in the binary whether or not a filter ran it. Before the body +// runs, the declared capabilities are checked: missing on a developer machine +// is a skip that says why; missing on a lane that declared it is a failure. +// +// This is a header and not part of the module because a macro cannot be +// exported from a module, and the listener has to be registered by the test +// binary itself. +#pragma once + +// No standard headers here: the test source has imported xlings.testkit by +// now, and GCC rejects a std header textually included after an import that +// reaches std. Everything used below comes in with gtest.h. +#include + +namespace xlings::testkit::detail { + +inline std::string current_test_name() { + auto* info = ::testing::UnitTest::GetInstance()->current_test_info(); + if (!info) return "unknown"; + return std::string(info->test_suite_name()) + "." + info->name(); +} + +inline bool current_test_failed() { return ::testing::Test::HasFailure(); } + +// One result line per test into $XTEST_RESULTS_OUT, with the metadata joined. +class ResultListener : public ::testing::EmptyTestEventListener { + void OnTestEnd(const ::testing::TestInfo& info) override { + const auto* r = info.result(); + std::string status = r->Skipped() ? "skip" : (r->Failed() ? "fail" : "pass"); + std::string message; + for (int i = 0; i < r->total_part_count(); ++i) { + const auto& part = r->GetTestPartResult(i); + if (part.message() && *part.message()) { + message = part.message(); + break; + } + } + xlings::testkit::record_result( + std::string(info.test_suite_name()) + "." + info.name(), status, + static_cast(r->elapsed_time()), message); + } +}; + +inline const bool installed = [] { + xlings::testkit::set_probes(¤t_test_failed, ¤t_test_name); + ::testing::UnitTest::GetInstance()->listeners().Append(new ResultListener); + return true; +}(); + +} // namespace xlings::testkit::detail + +#define XTEST_CAT_(a, b) a##b +#define XTEST_CAT(a, b) XTEST_CAT_(a, b) + +#define XTEST(suite, name, ...) \ + static const ::xlings::testkit::Meta XTEST_CAT(xtest_meta_##suite##_, name) { \ + __VA_ARGS__ }; \ + [[maybe_unused]] static const bool XTEST_CAT(xtest_reg_##suite##_, name) = \ + ::xlings::testkit::register_meta(#suite "." #name, \ + XTEST_CAT(xtest_meta_##suite##_, name)); \ + static void XTEST_CAT(xtest_body_##suite##_, name)(); \ + TEST(suite, name) { \ + if (auto verdict = ::xlings::testkit::check_requirements( \ + XTEST_CAT(xtest_meta_##suite##_, name))) { \ + if (verdict->fail) FAIL() << verdict->reason; \ + GTEST_SKIP() << verdict->reason; \ + } \ + XTEST_CAT(xtest_body_##suite##_, name)(); \ + } \ + static void XTEST_CAT(xtest_body_##suite##_, name)() diff --git a/modules/testkit/mcpp.toml b/modules/testkit/mcpp.toml new file mode 100644 index 000000000..f9b43cd2b --- /dev/null +++ b/modules/testkit/mcpp.toml @@ -0,0 +1,32 @@ +# The test library, as its own package (design §24.3). +# +# What every end-to-end test needs and every test used to write for itself: +# find the binary under test, give it a home that is not the developer's, run +# it with an environment the test chose rather than one it inherited, and +# say why a test did not run. `tests/unit/test_interface_protocol.cpp` and the +# 161 shell scripts each carry their own copy of those four things, and the +# copies disagree -- one inherits XLINGS_ACTIVE_SUBOS from whoever runs it, +# which is how a developer inside a subos sees a failure CI never does. +# +# Linked by tests only (`[dev-dependencies]` of the root). It depends on the +# JSON and generic platform modules, without the product adapters: tests drive the product through the +# binary, the same way a user does, so a test cannot reach around the +# interface it is checking. +# +# gtest is NOT a dependency of the module. The `XTEST` macro and the result +# listener live in `include/xlings/xtest.hpp`, because a macro cannot be +# exported from a module and a listener must be registered by the test binary. +[package] +namespace = "xlings" +name = "testkit" +version = "0.1.0" + +[targets.testkit] +kind = "lib" + +[dependencies] +libs = { path = "../libs" } +platform = { path = "../platform" } + +[build] +include_dirs = ["../libs/src/json"] diff --git a/modules/testkit/src/fixture.cpp b/modules/testkit/src/fixture.cpp new file mode 100644 index 000000000..02c5c5373 --- /dev/null +++ b/modules/testkit/src/fixture.cpp @@ -0,0 +1,137 @@ +module xlings.testkit.fixture; + +import std; +import xlings.platform; + +namespace xlings::testkit::fixture { + +namespace { + +std::optional request_path(std::string_view url) { + url = url.substr(0, url.find('?')); + if (url.empty() || url.front() != '/') return std::nullopt; + std::string decoded; + for (std::size_t index = 1; index < url.size(); ++index) { + unsigned char byte = url[index]; + if (byte == '%') { + if (index + 2 >= url.size()) return std::nullopt; + unsigned value { 0 }; + const auto [end, error] = std::from_chars(url.data() + index + 1, url.data() + index + 3, value, 16); + if (error != std::errc{} || end != url.data() + index + 3) return std::nullopt; + byte = static_cast(value); + index += 2; + } + if (byte == 0 || byte == '\\' || byte == ':' || byte < 32 || byte == 127) return std::nullopt; + decoded += static_cast(byte); + } + const std::filesystem::path path{decoded}; + if (path.empty() || path.has_root_path()) return std::nullopt; + for (const auto& part : path) if (part == ".." || part == ".") return std::nullopt; + return path; +} + +std::string status_text(int status) { + switch (status) { + case 200: return "OK"; + case 400: return "Bad Request"; + case 403: return "Forbidden"; + case 404: return "Not Found"; + case 405: return "Method Not Allowed"; + case 413: return "Payload Too Large"; + default: return "Internal Server Error"; + } +} + +} // namespace + +Response respond(const std::filesystem::path& root, std::string_view request) { + const auto firstLine = request.substr(0, request.find("\r\n")); + std::istringstream line{std::string(firstLine)}; + std::string method, url, version, extra; + if (!(line >> method >> url >> version) || (line >> extra) || (version != "HTTP/1.1" && version != "HTTP/1.0")) + return {.status = 400, .body = "invalid request\n"}; + if (method != "GET" && method != "HEAD") return {.status = 405, .body = "GET or HEAD required\n"}; + const auto rel = request_path(url); + if (!rel) return {.status = 403, .body = "invalid fixture path\n", .head = method == "HEAD"}; + std::error_code ec; + const auto base = std::filesystem::canonical(root, ec); + if (ec) return {.status = 500, .body = "fixture root unavailable\n", .head = method == "HEAD"}; + const auto path = std::filesystem::canonical(base / *rel, ec); + if (ec) return {.status = 404, .body = "fixture file not found\n", .head = method == "HEAD"}; + const auto within = path.lexically_relative(base); + if (within.empty() || within.is_absolute() || *within.begin() == "..") + return {.status = 403, .body = "fixture path leaves its root\n", .head = method == "HEAD"}; + if (!std::filesystem::is_regular_file(path, ec) || ec) + return {.status = 404, .body = "fixture is not a file\n", .head = method == "HEAD"}; + const auto size = std::filesystem::file_size(path, ec); + if (ec) return {.status = 500, .body = "cannot inspect fixture\n", .head = method == "HEAD"}; + constexpr std::uintmax_t MAX_BYTES { 64 * 1024 * 1024 }; + if (size > MAX_BYTES) return {.status = 413, .body = "fixture exceeds 64 MiB\n", .head = method == "HEAD"}; + std::ifstream in(path, std::ios::binary); + if (!in) return {.status = 500, .body = "cannot read fixture\n", .head = method == "HEAD"}; + std::string body(static_cast(size), '\0'); + in.read(body.data(), static_cast(size)); + if (in.gcount() != static_cast(size) || in.bad() + || in.peek() != std::char_traits::eof()) + return {.status = 500, .body = "fixture read was incomplete\n", .head = method == "HEAD"}; + return {.status = 200, .body = std::move(body), .head = method == "HEAD"}; +} + +struct Server::Impl { + std::filesystem::path root; + xlings::platform::LoopbackListener listener; + std::jthread worker; + + Impl(std::filesystem::path rootPath, xlings::platform::LoopbackListener port) + : root{std::move(rootPath)}, listener{std::move(port)} { + worker = std::jthread([this](std::stop_token stop) { + while (!stop.stop_requested()) { + auto accepted = listener.accept(std::chrono::milliseconds(50)); + if (!accepted) return; + if (!accepted->has_value()) continue; + auto& connection = **accepted; + std::string request; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(1); + std::array chunk{}; + while (request.find("\r\n\r\n") == request.npos && request.size() <= 8192 && !stop.stop_requested()) { + const auto remaining = std::chrono::duration_cast( + deadline - std::chrono::steady_clock::now()); + if (remaining.count() <= 0) break; + const auto read = connection.receive(chunk, std::min(remaining, std::chrono::milliseconds(50))); + if (!read) continue; + if (*read == 0) break; + request.append(chunk.data(), *read); + } + if (stop.stop_requested()) return; + const auto response = request.size() > 8192 ? Response{.status = 413, .body = "request too large\n"} + : request.find("\r\n\r\n") == request.npos ? Response{.status = 400, .body = "incomplete request\n"} + : respond(root, request); + auto bytes = std::format("HTTP/1.1 {} {}\r\nContent-Length: {}\r\n" + "Content-Type: application/octet-stream\r\nConnection: close\r\n\r\n", + response.status, status_text(response.status), response.body.size()); + if (!response.head) bytes += response.body; + (void)connection.send_all(bytes, std::chrono::seconds(2)); + } + }); + } + ~Impl() { worker.request_stop(); if (worker.joinable()) worker.join(); } +}; + +Server::Server(std::unique_ptr impl) : impl_{std::move(impl)} {} +Server::Server(Server&&) noexcept = default; +Server& Server::operator=(Server&&) noexcept = default; +Server::~Server() = default; +std::expected Server::start(const std::filesystem::path& root, std::uint16_t port) { + std::error_code ec; + const auto directory = std::filesystem::canonical(root, ec); + if (ec || !std::filesystem::is_directory(directory, ec)) + return std::unexpected("fixture root must be a readable directory"); + auto listener = xlings::platform::LoopbackListener::open(port); + if (!listener) return std::unexpected(listener.error()); + return Server{std::make_unique(directory, std::move(*listener))}; +} +std::uint16_t Server::port() const { return impl_ ? impl_->listener.port() : 0; } +std::string Server::url() const { return std::format("http://127.0.0.1:{}", port()); } +void Server::stop() { impl_.reset(); } + +} // namespace xlings::testkit::fixture diff --git a/modules/testkit/src/fixture.cppm b/modules/testkit/src/fixture.cppm new file mode 100644 index 000000000..2dbee9a9a --- /dev/null +++ b/modules/testkit/src/fixture.cppm @@ -0,0 +1,35 @@ +export module xlings.testkit.fixture; + +import std; +import xlings.platform; + +export namespace xlings::testkit::fixture { + +struct Response { + int status { 200 }; + std::string body; + bool head { false }; +}; +// Pure request/file boundary for malformed/traversal regression tests. Only +// GET/HEAD and ordinary files beneath the canonical fixture root are served. +Response respond(const std::filesystem::path& root, std::string_view request); + +class Server { +private: + struct Impl; + std::unique_ptr impl_; + explicit Server(std::unique_ptr impl); + +public: + Server(Server&&) noexcept; + Server& operator=(Server&&) noexcept; + Server(const Server&) = delete; + ~Server(); + static std::expected start(const std::filesystem::path& root, + std::uint16_t port = 0); + std::uint16_t port() const; + std::string url() const; + void stop(); +}; + +} // namespace xlings::testkit::fixture diff --git a/modules/testkit/src/index_fixture.cpp b/modules/testkit/src/index_fixture.cpp new file mode 100644 index 000000000..9f065817b --- /dev/null +++ b/modules/testkit/src/index_fixture.cpp @@ -0,0 +1,120 @@ +module xlings.testkit.index_fixture; +import std; +import xlings.testkit.fixture; +import xlings.libs.json; +import xlings.libs.sha256; + +namespace xlings::testkit::index_fixture { +namespace { +namespace fs = std::filesystem; +void write(const fs::path& file, std::string_view data) { + fs::create_directories(file.parent_path()); + std::ofstream out(file, std::ios::binary); + out.write(data.data(), static_cast(data.size())); + out.flush(); + if (!out) throw std::runtime_error("cannot write HTTP fixture " + file.string()); +} +// A ustar archive of regular files: {name, mode, contents}. +struct Entry { std::string_view name; std::string_view mode; std::string_view contents; }; +std::string tar(std::initializer_list entries) { + std::string out; + for (const auto& e : entries) { + std::string header(512, '\0'); + header.replace(0, e.name.size(), e.name); + header.replace(100, 7, e.mode); + header.replace(108, 7, "0000000"); + header.replace(116, 7, "0000000"); + header.replace(124, 11, std::format("{:011o}", e.contents.size())); + header.replace(136, 11, "00000000000"); + header.replace(148, 8, " "); + header[156] = '0'; + header.replace(257, 5, "ustar"); + header.replace(263, 2, "00"); + unsigned sum = 0; + for (unsigned char byte : header) sum += byte; + header.replace(148, 6, std::format("{:06o}", sum)); + header[154] = '\0'; + header[155] = ' '; + std::string body(e.contents); + body.resize((body.size() + 511) / 512 * 512, '\0'); + out += header + body; + } + out.resize(out.size() + 1024, '\0'); + return out; +} +std::string payload() { return tar({{"fixture.txt", "0000644", "xlings HTTP fixture\n"}}); } +// A program a SubOS can put on PATH and a root can put in /usr/bin. +std::string tool_payload() { + // Two top-level entries, so no extractor reads the archive as one + // wrapping directory to strip. + return tar({{"bin/fixture-tool", "0000755", "#!/bin/sh\necho fixture-tool\n"}, + {"README", "0000644", "fixture-tool\n"}}); +} +struct Store { + fs::path root; + std::optional server; + Store() { + for (int attempt = 0; attempt != 64; ++attempt) { + const auto candidate = fs::temp_directory_path() / std::format("xtest-http-{}-{}", + std::chrono::steady_clock::now().time_since_epoch().count(), attempt); + std::error_code ec; + if (fs::create_directory(candidate, ec)) { root = candidate; break; } + if (ec && ec != std::errc::file_exists) throw std::runtime_error(ec.message()); + } + if (root.empty()) throw std::runtime_error("cannot reserve HTTP fixture directory"); + try { + const auto data = payload(); + write(root / "fixture-data-1.0.0.tar", data); + write(root / "index/xim-indexrepos.lua", "xim_indexrepos = {}\n"); + fs::create_directories(root / "index/pkgs/f"); + auto listening = fixture::Server::start(root); + if (!listening) throw std::runtime_error(listening.error()); + server.emplace(std::move(*listening)); + const auto version = std::format("{{url='{}',sha256='{}'}}", + server->url() + "/fixture-data-1.0.0.tar", sha256::hex(data)); + std::string recipe = "package = {spec='1',name='fixture-data',type='package'," + "archs={'x86_64','aarch64'},xpm={"; + for (const auto* platform : {"linux", "macosx", "windows"}) + recipe += std::string(platform) + "={['1.0.0']=" + version + "},"; + recipe += "}}\n"; + write(root / "index/pkgs/f/fixture-data.lua", recipe); + const auto tool = tool_payload(); + write(root / "fixture-tool-1.0.0.tar", tool); + const auto toolVersion = std::format("{{url='{}',sha256='{}'}}", + server->url() + "/fixture-tool-1.0.0.tar", sha256::hex(tool)); + std::string toolRecipe = "package = {spec='1',name='fixture-tool',type='package'," + "archs={'x86_64','aarch64'},xpm={"; + for (const auto* platform : {"linux", "macosx", "windows"}) + toolRecipe += std::string(platform) + "={['1.0.0']=" + toolVersion + "},"; + toolRecipe += "}}\n" + "import('xim.libxpkg.pkginfo')\nimport('xim.libxpkg.xvm')\n" + "function config()\n" + " xvm.add('fixture-tool', { bindir = path.join(pkginfo.install_dir(), 'bin') })\n" + " return true\nend\n"; + write(root / "index/pkgs/f/fixture-tool.lua", toolRecipe); + } catch (...) { + server.reset(); + std::error_code ec; + fs::remove_all(root, ec); + throw; + } + } + ~Store() { + server.reset(); + std::error_code ec; + fs::remove_all(root, ec); + } +}; +Store& store() { static Store value; return value; } +} +std::string config(std::string_view mirror) { + const auto& fixture = store(); + const auto index = (fixture.root / "index").generic_string(); + return nlohmann::json{ + {"mirror", mirror}, {"xim", {{"index-repo", index}}}, + {"index_repos", nlohmann::json::array({{{"name", "xim"}, {"url", index}, {"source", "git"}}})}, + {"XLINGS_RES", {{"GLOBAL", {fixture.server->url()}}, {"CN", {fixture.server->url()}}}} + }.dump(2); +} +std::string url() { return store().server->url(); } +} diff --git a/modules/testkit/src/index_fixture.cppm b/modules/testkit/src/index_fixture.cppm new file mode 100644 index 000000000..11510a2cc --- /dev/null +++ b/modules/testkit/src/index_fixture.cppm @@ -0,0 +1,7 @@ +export module xlings.testkit.index_fixture; +import std; +export namespace xlings::testkit::index_fixture { +// One owned loopback mirror per test process, shared by its isolated homes. +std::string config(std::string_view mirror); +std::string url(); +} diff --git a/modules/testkit/src/testkit.cpp b/modules/testkit/src/testkit.cpp new file mode 100644 index 000000000..86ed9670e --- /dev/null +++ b/modules/testkit/src/testkit.cpp @@ -0,0 +1,894 @@ +module; + +#include +#include +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#else +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#if defined(__APPLE__) +#include +#define XTEST_ENVIRON (*_NSGetEnviron()) +#else +// glibc and musl declare environ in under _GNU_SOURCE +#define XTEST_ENVIRON environ +#endif +#endif + +module xlings.testkit; + +import std; +import xlings.libs.json; +import xlings.testkit.index_fixture; + +namespace xlings::testkit { + +namespace { + +std::string env_or(const char* name, std::string fallback = {}) { + if (const char* v = std::getenv(name); v && *v) return v; + return fallback; +} + +std::vector split_csv(std::string_view s) { + std::vector out; + std::size_t start = 0; + while (start <= s.size()) { + auto end = s.find(',', start); + if (end == std::string_view::npos) end = s.size(); + auto item = s.substr(start, end - start); + while (!item.empty() && item.front() == ' ') item.remove_prefix(1); + while (!item.empty() && item.back() == ' ') item.remove_suffix(1); + if (!item.empty()) out.emplace_back(item); + start = end + 1; + } + return out; +} + +// Never destroyed: the meta writer runs during static destruction, and on +// libc++ a function-local static destroyed before it turns the last write into +// `mutex lock failed: Invalid argument` (macOS). +std::map>& registry_mut() { + static auto* r = new std::map>(); + return *r; +} + +FailureProbe g_failed = nullptr; +NameProbe g_name = nullptr; + +std::mutex& out_mutex() { + static auto* m = new std::mutex(); + return *m; +} + +void append_line(const std::string& path, const std::string& line) { + std::lock_guard lock(out_mutex()); + std::ofstream f(path, std::ios::app | std::ios::binary); + f << line << '\n'; +} + +// Written once, at exit, so a filtered run still lists every test it holds. +struct MetaWriter { + ~MetaWriter() { + auto path = env_or("XTEST_META_OUT"); + if (path.empty()) return; + for (auto& [name, m] : registry_mut()) { + nlohmann::json j; + j["test"] = name; + j["area"] = m.area; + j["cost"] = std::string(to_string(m.cost)); + j["covers"] = m.covers; + j["requires"] = m.requires_; + j["resources"] = m.resources; + j["proves"] = m.proves; + append_line(path, j.dump()); + } + } +}; +MetaWriter g_meta_writer; + +} // namespace + +std::string_view to_string(Cost c) { + switch (c) { + case Cost::Medium: return "medium"; + case Cost::Slow: return "slow"; + default: return "fast"; + } +} + +bool register_meta(std::string_view test, Meta meta) { + registry_mut().insert_or_assign(std::string(test), std::move(meta)); + return true; +} + +const std::map>& registry() { return registry_mut(); } + +// ── Capabilities ───────────────────────────────────────────────────── + +namespace { + +constexpr std::array kCapabilities { + "linux", "macos", "windows", "posix", + "xlings-bin", // a built binary to drive + "pty", // pseudo-terminals (agent contract scan) + "userns", // unprivileged user namespaces + "bwrap", // a bwrap that can create a sandbox here + "sandbox", // `subos use --sandbox` can enter (any backend) + "network", // the lane allows network access (opt-in) + "root", + "sudo", // non-interactive sudo + "pasta", // pasta (passt) and /dev/net/tun: net=nat can run + "landlock", // the kernel answers landlock_create_ruleset (ABI >= 1) + "docker", // a docker daemon this user can drive + "qemu", // qemu-system for this architecture + "kvm", // /dev/kvm is usable (a boot finishes in seconds, not minutes) +}; + +// Run a probe command quietly; true when it exits 0. +bool quiet_ok(const std::vector& argv) { + RunOptions o; + o.argv = argv; + o.env = { {"PATH", "/usr/local/bin:/usr/bin:/bin"} }; + o.timeout = std::chrono::seconds(20); + auto r = run(o); + return r.exit_code == 0; +} + +std::optional probe_uncached(std::string_view cap) { + if (cap == "linux") { + if constexpr (is_linux) return std::nullopt; + else return "not Linux"; + } + if (cap == "macos") { + if constexpr (is_macos) return std::nullopt; + else return "not macOS"; + } + if (cap == "windows") { + if constexpr (is_windows) return std::nullopt; + else return "not Windows"; + } + if (cap == "posix" || cap == "pty") { + if constexpr (is_posix) return std::nullopt; + else return "not POSIX"; + } + if (cap == "xlings-bin") { + if (xlings_binary().empty()) + return "no xlings binary (set XLINGS_BIN or run `mcpp build`)"; + return std::nullopt; + } + if (cap == "network") { + if (env_or("XDEV_NETWORK") == "1" || lane_declares("network")) + return std::nullopt; + return "network is opt-in (XDEV_NETWORK=1); runs are hermetic by default"; + } +#if defined(_WIN32) + return "not available on Windows"; +#else + if (cap == "root") { + if (::geteuid() == 0) return std::nullopt; + return "not running as root"; + } + if (cap == "sudo") { + if (::geteuid() == 0 || quiet_ok({"sudo", "-n", "true"})) return std::nullopt; + return "no non-interactive sudo"; + } + if (cap == "userns") { + if constexpr (!is_linux) return "user namespaces are Linux-only"; + if (quiet_ok({"unshare", "-Ur", "true"})) return std::nullopt; + return "unprivileged user namespaces are not available " + "(kernel.apparmor_restrict_unprivileged_userns or " + "kernel.unprivileged_userns_clone)"; + } + if (cap == "bwrap") { + if constexpr (!is_linux) return "bwrap is Linux-only"; + for (const auto& candidate : {env_or("XDEV_BWRAP"), + std::string("/usr/lib/xlings/bwrap"), + std::string("/usr/bin/bwrap"), + std::string("/usr/local/bin/bwrap")}) { + if (candidate.empty() || !fs::exists(candidate)) continue; + if (quiet_ok({candidate, "--ro-bind", "/", "/", "--", "/bin/true"})) + return std::nullopt; + } + return "no bwrap that can create a sandbox on this host"; + } + if (cap == "pasta") { + if constexpr (!is_linux) return "pasta is Linux-only"; + std::error_code ec; + if (!fs::exists("/dev/net/tun", ec)) return "/dev/net/tun is missing"; + for (auto p : {"/usr/bin/pasta", "/usr/local/bin/pasta"}) + if (fs::exists(p, ec)) return std::nullopt; + return "pasta (passt) is not installed"; + } + if (cap == "landlock") { + if constexpr (!is_linux) return "Landlock is Linux-only"; +#if defined(__linux__) + // landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION) + if (::syscall(444, nullptr, 0, 1u) >= 1) return std::nullopt; +#endif + return "the kernel has no Landlock, or it is not in the LSM list"; + } + if (cap == "docker") { + if (quiet_ok({"docker", "info"})) return std::nullopt; + return "no docker daemon this user can drive"; + } + if (cap == "qemu") { + if constexpr (!is_linux) return "the boot scenarios run on Linux"; + for (auto p : {"/usr/bin/qemu-system-x86_64", "/usr/bin/qemu-system-aarch64"}) + if (fs::exists(p)) return std::nullopt; + return "qemu-system is not installed"; + } + if (cap == "kvm") { + if (::access("/dev/kvm", R_OK | W_OK) == 0) return std::nullopt; + return "/dev/kvm is not usable by this user"; + } + if (cap == "sandbox") { + // Elsewhere home-redirect is always available. + if constexpr (is_linux) return probe("bwrap"); + else return std::nullopt; + } + return "unknown capability"; +#endif +} + +} // namespace + +std::span capability_names() { return kCapabilities; } + +std::optional probe(std::string_view capability) { + static std::mutex m; + static std::map, std::less<>> cache; + { + std::lock_guard lock(m); + if (auto it = cache.find(capability); it != cache.end()) return it->second; + } + auto result = probe_uncached(capability); + std::lock_guard lock(m); + cache.insert_or_assign(std::string(capability), result); + return result; +} + +bool lane_declares(std::string_view capability) { + static const auto caps = split_csv(env_or("XDEV_LANE_CAPS")); + return std::ranges::find(caps, capability) != caps.end(); +} + +std::optional check_requirements(const Meta& meta) { + static const auto caps = split_csv(env_or("XDEV_LANE_CAPS")); + return check_requirements(meta, caps); +} + +std::optional check_requirements(const Meta& meta, + std::span lane_caps) { + auto declared = [&](std::string_view cap) { + return std::ranges::find(lane_caps, cap) != lane_caps.end(); + }; + for (const auto& cap : meta.requires_) { + if (std::ranges::find(kCapabilities, std::string_view(cap)) == kCapabilities.end()) { + return Verdict{ .fail = true, + .reason = "unknown capability '" + cap + "' in requires" }; + } + if (auto why = probe(cap)) { + if (declared(cap)) { + return Verdict{ .fail = true, + .reason = "this lane declares '" + cap + + "' (XDEV_LANE_CAPS) but it is missing: " + *why }; + } + return Verdict{ .fail = false, .reason = "requires " + cap + ": " + *why }; + } + } + return std::nullopt; +} + +// ── Processes ──────────────────────────────────────────────────────── + +std::vector RunResult::json_lines() const { + std::vector lines; + std::istringstream in(out); + std::string line; + while (std::getline(in, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + if (line.empty() || line.front() != '{') continue; + auto j = nlohmann::json::parse(line, nullptr, false); + if (!j.is_discarded() && j.is_object()) lines.push_back(std::move(j)); + } + return lines; +} + +std::string RunResult::transcript() const { + std::string s = std::format("exit={} signal={} timed_out={} elapsed={}ms\n", + exit_code, signal, timed_out, elapsed.count()); + s += "--- stdout\n" + out; + if (!out.empty() && out.back() != '\n') s += '\n'; + s += "--- stderr\n" + err; + return s; +} + +#if !defined(_WIN32) + +namespace { + +std::string resolve_program(const std::string& prog, + const std::map& env) { + if (prog.find('/') != std::string::npos) return prog; + std::string path = "/usr/bin:/bin"; + if (auto it = env.find("PATH"); it != env.end()) path = it->second; + std::size_t start = 0; + while (start <= path.size()) { + auto end = path.find(':', start); + if (end == std::string::npos) end = path.size(); + auto dir = path.substr(start, end - start); + if (!dir.empty()) { + auto candidate = dir + "/" + prog; + if (::access(candidate.c_str(), X_OK) == 0) return candidate; + } + start = end + 1; + } + return prog; +} + +void set_nonblock(int fd) { + int flags = ::fcntl(fd, F_GETFL, 0); + ::fcntl(fd, F_SETFL, flags | O_NONBLOCK); +} + +} // namespace + +RunResult run(const RunOptions& o) { + RunResult r; + if (o.argv.empty()) { r.err = "testkit::run: empty argv"; return r; } + + auto program = resolve_program(o.argv[0], o.env); + std::vector env_strings; + for (auto& [k, v] : o.env) env_strings.push_back(k + "=" + v); + std::vector envp; + for (auto& s : env_strings) envp.push_back(s.data()); + envp.push_back(nullptr); + std::vector argv_copy = o.argv; + std::vector argv; + for (auto& s : argv_copy) argv.push_back(s.data()); + argv.push_back(nullptr); + auto cwd = o.cwd.empty() ? fs::temp_directory_path() : o.cwd; + auto cwd_s = cwd.string(); + + int in_pipe[2] = {-1, -1}, out_pipe[2] = {-1, -1}, err_pipe[2] = {-1, -1}; + int master = -1; + std::string slave_name; + if (o.pty) { + master = ::posix_openpt(O_RDWR | O_NOCTTY); + if (master < 0 || ::grantpt(master) != 0 || ::unlockpt(master) != 0) { + r.err = std::format("testkit::run: no pty: {}", std::strerror(errno)); + if (master >= 0) ::close(master); + return r; + } + slave_name = ::ptsname(master); + ::fcntl(master, F_SETFD, FD_CLOEXEC); + } else { + if (::pipe(in_pipe) != 0 || ::pipe(out_pipe) != 0 || ::pipe(err_pipe) != 0) { + r.err = std::format("testkit::run: pipe: {}", std::strerror(errno)); + return r; + } + for (int fd : {in_pipe[1], out_pipe[0], err_pipe[0]}) + ::fcntl(fd, F_SETFD, FD_CLOEXEC); + } + + const auto started = std::chrono::steady_clock::now(); + pid_t pid = ::fork(); + if (pid < 0) { + r.err = std::format("testkit::run: fork: {}", std::strerror(errno)); + return r; + } + if (pid == 0) { + if (o.pty) { + ::setsid(); + int slave = ::open(slave_name.c_str(), O_RDWR); + if (slave < 0) ::_exit(126); + ::ioctl(slave, TIOCSCTTY, 0); + ::dup2(slave, 0); ::dup2(slave, 1); ::dup2(slave, 2); + if (slave > 2) ::close(slave); + } else { + ::setpgid(0, 0); + ::dup2(in_pipe[0], 0); + ::dup2(out_pipe[1], 1); + ::dup2(err_pipe[1], 2); + for (int fd : {in_pipe[0], out_pipe[1], err_pipe[1]}) if (fd > 2) ::close(fd); + } + if (::chdir(cwd_s.c_str()) != 0) ::_exit(126); + ::execve(program.c_str(), argv.data(), envp.data()); + std::fprintf(stderr, "testkit: exec %s: %s\n", program.c_str(), std::strerror(errno)); + ::_exit(127); + } + + std::vector readers; + if (o.pty) { + if (!o.stdin_data.empty()) + (void)::write(master, o.stdin_data.data(), o.stdin_data.size()); + set_nonblock(master); + readers = {master}; + } else { + ::close(in_pipe[0]); ::close(out_pipe[1]); ::close(err_pipe[1]); + if (!o.stdin_data.empty()) + (void)::write(in_pipe[1], o.stdin_data.data(), o.stdin_data.size()); + ::close(in_pipe[1]); + set_nonblock(out_pipe[0]); + set_nonblock(err_pipe[0]); + readers = {out_pipe[0], err_pipe[0]}; + } + + const auto deadline = started + o.timeout; + std::optional drain_until; + int status = 0; + bool reaped = false; + std::array buf{}; + auto open_count = [&] { + return std::ranges::count_if(readers, [](int fd) { return fd >= 0; }); + }; + + while (true) { + if (!reaped) { + pid_t w = ::waitpid(pid, &status, WNOHANG); + if (w == pid) { + reaped = true; + // Grandchildren may hold the pipes; read what is there, then stop. + drain_until = std::chrono::steady_clock::now() + std::chrono::milliseconds(500); + } + } + auto now = std::chrono::steady_clock::now(); + if (!reaped && now >= deadline) { + r.timed_out = true; + ::kill(-pid, SIGKILL); + ::kill(pid, SIGKILL); + ::waitpid(pid, &status, 0); + reaped = true; + drain_until = now + std::chrono::milliseconds(200); + } + if (open_count() == 0 && reaped) break; + if (drain_until && now >= *drain_until) break; + + std::vector pfds; + for (int fd : readers) if (fd >= 0) pfds.push_back({fd, POLLIN, 0}); + if (pfds.empty()) { + ::usleep(10 * 1000); + continue; + } + ::poll(pfds.data(), pfds.size(), 50); + for (auto& p : pfds) { + if (!(p.revents & (POLLIN | POLLHUP | POLLERR))) continue; + ssize_t n = ::read(p.fd, buf.data(), buf.size()); + auto& sink = (o.pty || p.fd == out_pipe[0]) ? r.out : r.err; + if (n > 0) { + sink.append(buf.data(), static_cast(n)); + } else if (n == 0 || (n < 0 && errno != EAGAIN && errno != EINTR)) { + // EOF, or EIO on a pty whose last slave closed. + for (auto& fd : readers) if (fd == p.fd) { ::close(fd); fd = -1; } + } + } + } + for (int fd : readers) if (fd >= 0) ::close(fd); + + r.elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - started); + if (!r.timed_out) { + if (WIFEXITED(status)) r.exit_code = WEXITSTATUS(status); + else if (WIFSIGNALED(status)) r.signal = WTERMSIG(status); + } + return r; +} + +#else // _WIN32 + +namespace { + +std::wstring widen(std::string_view s) { + if (s.empty()) return {}; + int n = ::MultiByteToWideChar(CP_UTF8, 0, s.data(), static_cast(s.size()), nullptr, 0); + std::wstring w(static_cast(n), L'\0'); + ::MultiByteToWideChar(CP_UTF8, 0, s.data(), static_cast(s.size()), w.data(), n); + return w; +} + +// The MSVC CRT's argv rules: backslashes are literal unless they precede a +// quote, where they double. +std::wstring quote_arg(const std::wstring& arg) { + if (!arg.empty() && arg.find_first_of(L" \t\n\v\"") == std::wstring::npos) return arg; + std::wstring q = L"\""; + std::size_t backslashes = 0; + for (wchar_t c : arg) { + if (c == L'\\') { ++backslashes; continue; } + if (c == L'"') q.append(backslashes * 2 + 1, L'\\'); + else q.append(backslashes, L'\\'); + backslashes = 0; + q.push_back(c); + } + q.append(backslashes * 2, L'\\'); + q.push_back(L'"'); + return q; +} + +void drain(HANDLE h, std::string& sink) { + std::array buf{}; + DWORD n = 0; + while (::ReadFile(h, buf.data(), static_cast(buf.size()), &n, nullptr) && n > 0) + sink.append(buf.data(), n); +} + +} // namespace + +RunResult run(const RunOptions& o) { + RunResult r; + if (o.argv.empty()) { r.err = "testkit::run: empty argv"; return r; } + if (o.pty) { r.err = "testkit::run: pty is POSIX-only"; return r; } + + SECURITY_ATTRIBUTES sa{ sizeof(sa), nullptr, TRUE }; + HANDLE in_r{}, in_w{}, out_r{}, out_w{}, err_r{}, err_w{}; + ::CreatePipe(&in_r, &in_w, &sa, 0); + ::CreatePipe(&out_r, &out_w, &sa, 0); + ::CreatePipe(&err_r, &err_w, &sa, 0); + ::SetHandleInformation(in_w, HANDLE_FLAG_INHERIT, 0); + ::SetHandleInformation(out_r, HANDLE_FLAG_INHERIT, 0); + ::SetHandleInformation(err_r, HANDLE_FLAG_INHERIT, 0); + + std::wstring cmdline; + for (auto& a : o.argv) { + if (!cmdline.empty()) cmdline.push_back(L' '); + cmdline += quote_arg(widen(a)); + } + std::wstring env_block; + for (auto& [k, v] : o.env) { + env_block += widen(k + "=" + v); + env_block.push_back(L'\0'); + } + env_block.push_back(L'\0'); + auto cwd = (o.cwd.empty() ? fs::temp_directory_path() : o.cwd).wstring(); + + STARTUPINFOW si{}; + si.cb = sizeof(si); + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdInput = in_r; + si.hStdOutput = out_w; + si.hStdError = err_w; + PROCESS_INFORMATION pi{}; + + HANDLE job = ::CreateJobObjectW(nullptr, nullptr); + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{}; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + ::SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits)); + + const auto started = std::chrono::steady_clock::now(); + BOOL ok = ::CreateProcessW(nullptr, cmdline.data(), nullptr, nullptr, TRUE, + CREATE_UNICODE_ENVIRONMENT | CREATE_SUSPENDED, + env_block.data(), cwd.c_str(), &si, &pi); + ::CloseHandle(in_r); ::CloseHandle(out_w); ::CloseHandle(err_w); + if (!ok) { + r.err = std::format("testkit::run: CreateProcess failed ({})", ::GetLastError()); + ::CloseHandle(in_w); ::CloseHandle(out_r); ::CloseHandle(err_r); ::CloseHandle(job); + return r; + } + ::AssignProcessToJobObject(job, pi.hProcess); + ::ResumeThread(pi.hThread); + + std::thread t_out([&] { drain(out_r, r.out); }); + std::thread t_err([&] { drain(err_r, r.err); }); + if (!o.stdin_data.empty()) { + DWORD written = 0; + ::WriteFile(in_w, o.stdin_data.data(), static_cast(o.stdin_data.size()), &written, nullptr); + } + ::CloseHandle(in_w); + + auto wait = ::WaitForSingleObject(pi.hProcess, static_cast(o.timeout.count())); + if (wait == WAIT_TIMEOUT) { + r.timed_out = true; + ::TerminateJobObject(job, 1); + ::WaitForSingleObject(pi.hProcess, INFINITE); + } + DWORD code = 0; + ::GetExitCodeProcess(pi.hProcess, &code); + ::CloseHandle(job); // kills any grandchild still holding the pipes + t_out.join(); + t_err.join(); + ::CloseHandle(out_r); ::CloseHandle(err_r); + ::CloseHandle(pi.hProcess); ::CloseHandle(pi.hThread); + + r.elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - started); + if (!r.timed_out) r.exit_code = static_cast(code); + return r; +} + +#endif + +std::map inherited_env() { + std::map e; +#if defined(_WIN32) + wchar_t* block = ::GetEnvironmentStringsW(); + for (wchar_t* p = block; p && *p; p += std::wcslen(p) + 1) { + std::wstring w(p); + auto eq = w.find(L'=', 1); // "=C:=C:\" entries start with '=' + if (eq == std::wstring::npos) continue; + auto narrow = [](const std::wstring& ws) { + int n = ::WideCharToMultiByte(CP_UTF8, 0, ws.data(), static_cast(ws.size()), + nullptr, 0, nullptr, nullptr); + std::string out(static_cast(n), '\0'); + ::WideCharToMultiByte(CP_UTF8, 0, ws.data(), static_cast(ws.size()), + out.data(), n, nullptr, nullptr); + return out; + }; + e[narrow(w.substr(0, eq))] = narrow(w.substr(eq + 1)); + } + if (block) ::FreeEnvironmentStringsW(block); +#else + for (char** p = XTEST_ENVIRON; p && *p; ++p) { + std::string_view kv(*p); + auto eq = kv.find('='); + if (eq == std::string_view::npos) continue; + e[std::string(kv.substr(0, eq))] = std::string(kv.substr(eq + 1)); + } +#endif + return e; +} + +fs::path xlings_binary() { + static const fs::path cached = [] { + // Only a FILE. The xlings shell profile exports XLINGS_BIN as the + // installed home's bin DIRECTORY, so in a developer's shell the name + // already means "the xlings you use", which is not the one under test. + if (auto e = env_or("XLINGS_BIN"); !e.empty()) { + std::error_code ec; + if (fs::is_regular_file(e, ec)) { + auto abs = fs::absolute(e, ec); + return ec ? fs::path(e) : abs; + } + } + const fs::path exe = is_windows ? "xlings.exe" : "xlings"; + fs::path newest; + fs::file_time_type newest_time{}; + std::error_code ec; + if (fs::exists("target", ec)) { + for (auto it = fs::recursive_directory_iterator("target", ec); + !ec && it != std::default_sentinel; it.increment(ec)) { + if (!it->is_regular_file(ec)) continue; + if (it->path().filename() != exe) continue; + if (it->path().parent_path().filename() != "bin") continue; + auto t = fs::last_write_time(it->path(), ec); + if (ec) continue; + if (newest.empty() || t > newest_time) { + newest = it->path(); + newest_time = t; + } + } + } + if (newest.empty()) return fs::path{}; + return fs::absolute(newest, ec); + }(); + return cached; +} + +// ── Homes ──────────────────────────────────────────────────────────── + +std::string current_user() { +#if defined(_WIN32) + return env_or("USERNAME", "user"); +#else + if (auto u = env_or("USER"); !u.empty()) return u; + if (auto* pw = ::getpwuid(::getuid()); pw && pw->pw_name) return pw->pw_name; + return "user"; +#endif +} + +Home Home::isolated(std::string_view name) { + static std::atomic counter{0}; + Home h; +#if defined(_WIN32) + auto pid = static_cast(::GetCurrentProcessId()); +#else + auto pid = static_cast(::getpid()); +#endif + h.root_ = fs::temp_directory_path() + / std::format("xtest-{}-{}-{}", name, pid, counter++); + std::error_code ec; + fs::remove_all(h.root_, ec); + h.dir_ = h.root_ / ".xlings"; + fs::create_directories(h.dir_); + fs::create_directories(h.root_ / "tmp"); + // The mirror the shell suite uses, for the same reason (AGENTS.md: an + // unreachable mirror looks like the command under test hanging). + const auto mirror = env_or("XLINGS_TEST_MIRROR", "GLOBAL"); + bool network = false; + if (g_name) { + const auto found = registry().find(g_name()); + network = found != registry().end() + && std::ranges::find(found->second.requires_, "network") != found->second.requires_.end(); + } + write_file(h.dir_ / ".xlings.json", network + ? nlohmann::json{{"mirror", mirror}}.dump(2) : index_fixture::config(mirror)); + return h; +} + +Home::Home(Home&& o) noexcept + : root_(std::move(o.root_)), dir_(std::move(o.dir_)), keep_(o.keep_) { + o.root_.clear(); +} + +Home& Home::operator=(Home&& o) noexcept { + if (this != &o) { + root_ = std::move(o.root_); + dir_ = std::move(o.dir_); + keep_ = o.keep_; + o.root_.clear(); + } + return *this; +} + +Home::~Home() { + if (root_.empty()) return; + const bool failed = g_failed && g_failed(); + std::error_code ec; + if (failed) { + // Keep what explains the failure: the home's config, logs and state. + // Payloads are left out -- they are large and say nothing new. + auto test = g_name ? g_name() : std::string("unknown"); + auto dest = artifacts_dir() / test; + fs::create_directories(dest, ec); + for (auto rel : {".xlings.json", "logs", "state", "config"}) { + auto src = dir_ / rel; + if (fs::exists(src, ec)) + fs::copy(src, dest / rel, fs::copy_options::recursive + | fs::copy_options::overwrite_existing, ec); + } + std::cerr << "[testkit] home kept for the failed test: " << root_.string() + << "\n[testkit] artefacts: " << dest.string() << "\n"; + return; + } + if (keep_ || env_or("XTEST_KEEP") == "1") { + std::cerr << "[testkit] home kept: " << root_.string() << "\n"; + return; + } + fs::remove_all(root_, ec); +} + +std::map Home::env() const { + std::map e; + e["XLINGS_HOME"] = dir_.string(); + if constexpr (is_windows) { + // Windows cannot start much without these; none of them names a home. + for (auto name : {"SystemRoot", "SystemDrive", "WINDIR", "COMSPEC", "PATHEXT", + "PATH", "NUMBER_OF_PROCESSORS", "PROCESSOR_ARCHITECTURE", "OS", + "ProgramFiles", "ProgramFiles(x86)", "ProgramW6432", "ProgramData", + "CommonProgramFiles", "PSModulePath", "ALLUSERSPROFILE", "PUBLIC"}) { + if (auto v = env_or(name); !v.empty()) e[name] = v; + } + e["USERPROFILE"] = root_.string(); + e["USERNAME"] = current_user(); + e["TEMP"] = (root_ / "tmp").string(); + e["TMP"] = (root_ / "tmp").string(); + } else { + e["HOME"] = root_.string(); + e["USER"] = current_user(); + e["LOGNAME"] = current_user(); + e["PATH"] = "/usr/local/bin:/usr/bin:/bin"; + e["SHELL"] = "/bin/sh"; + e["LANG"] = "C.UTF-8"; + e["TMPDIR"] = (root_ / "tmp").string(); + } + // The lane's mirror and network choices pass through; nothing else does. + for (auto name : {"XLINGS_RELEASE_MIRROR", "XLINGS_TEST_MIRROR"}) { + if (auto v = env_or(name); !v.empty()) e[name] = v; + } + return e; +} + +RunResult Home::xlings(std::vector args, + std::map extra_env, + std::chrono::milliseconds timeout) const { + RunOptions o; + o.argv = std::move(args); + o.env = std::move(extra_env); + o.timeout = timeout; + return xlings(std::move(o)); +} + +RunResult Home::xlings(RunOptions o) const { + auto bin = xlings_binary(); + o.argv.insert(o.argv.begin(), bin.string()); + auto base = env(); + for (auto& [k, v] : o.env) base[k] = v; + o.env = std::move(base); + if (o.cwd.empty()) o.cwd = root_; + return run(o); +} + +bool Home::seed_sandbox_backend() const { + // Elsewhere the sandbox is home-redirect and needs no binary. + if constexpr (!is_linux) return true; + for (const auto& candidate : {env_or("XDEV_BWRAP"), + std::string("/usr/lib/xlings/bwrap"), + std::string("/usr/bin/bwrap"), + std::string("/usr/local/bin/bwrap")}) { + if (candidate.empty() || !fs::exists(candidate)) continue; + auto dest = dir_ / "data" / "xpkgs" / "xim-x-bwrap" / "0.0.0-host" / "bin"; + std::error_code ec; + fs::create_directories(dest, ec); + fs::copy_file(candidate, dest / "bwrap", fs::copy_options::overwrite_existing, ec); + if (!ec) return true; + } + return false; +} + +// ── Failure capture and results ────────────────────────────────────── + +void set_probes(FailureProbe failed, NameProbe name) { + g_failed = failed; + g_name = name; +} + +fs::path artifacts_dir() { + if (auto e = env_or("XTEST_ARTIFACTS"); !e.empty()) return e; + std::error_code ec; + return fs::absolute("target/xtest-artifacts", ec); +} + +void record_result(std::string_view test, std::string_view status, + long long elapsed_ms, std::string_view message) { + auto path = env_or("XTEST_RESULTS_OUT"); + if (path.empty()) return; + nlohmann::json j; + j["test"] = test; + j["status"] = status; + j["ms"] = elapsed_ms; + if (!message.empty()) j["message"] = message; + if (auto it = registry_mut().find(test); it != registry_mut().end()) { + j["area"] = it->second.area; + j["covers"] = it->second.covers; + j["proves"] = it->second.proves; + } + append_line(path, j.dump()); +} + +std::string read_file(const fs::path& path) { + std::ifstream f(path, std::ios::binary); + return {std::istreambuf_iterator(f), std::istreambuf_iterator()}; +} + +void write_file(const fs::path& path, std::string_view content) { + std::error_code ec; + if (path.has_parent_path()) fs::create_directories(path.parent_path(), ec); + std::ofstream f(path, std::ios::binary | std::ios::trunc); + f.write(content.data(), static_cast(content.size())); +} + +} // namespace xlings::testkit + +namespace xlings::testkit { + +void set_env(const std::string& name, const std::string& value) { +#if defined(_WIN32) + ::_putenv_s(name.c_str(), value.c_str()); +#else + ::setenv(name.c_str(), value.c_str(), 1); +#endif +} + +int system_exit_code(int raw) { +#if defined(_WIN32) + return raw; +#else + if (raw == -1) return -1; + if (WIFEXITED(raw)) return WEXITSTATUS(raw); + return 128 + (WIFSIGNALED(raw) ? WTERMSIG(raw) : 0); +#endif +} + +} // namespace xlings::testkit diff --git a/modules/testkit/src/testkit.cppm b/modules/testkit/src/testkit.cppm new file mode 100644 index 000000000..bff9ec572 --- /dev/null +++ b/modules/testkit/src/testkit.cppm @@ -0,0 +1,201 @@ +export module xlings.testkit; + +import std; +import xlings.libs.json; + +// End-to-end test support (design §24.3, checkpoint T1). +// +// Four things, each with one answer: +// +// which binary `xlings_binary()`: $XLINGS_BIN, else the newest +// target/**/bin/xlings. A test never builds or guesses. +// which home `Home::isolated()`: a fresh directory under the system +// temp dir, never under $HOME, removed afterwards unless +// the test failed or XTEST_KEEP=1. +// which environment `run()` starts from NOTHING and adds what the test +// names. Inheriting is what let XLINGS_ACTIVE_SUBOS from a +// developer's shell decide which subos a unit test saw. +// why it did not run `check_requirements()`: a missing capability is a skip +// on a developer machine and a FAILURE on a CI lane that +// declared it (XDEV_LANE_CAPS). A lane that silently skips +// its own subject is F13 in the design. +export namespace xlings::testkit { + +namespace fs = std::filesystem; + +// The platform, as constants for `if constexpr`. Spelled like +// xlings::platform's, and not imported from it: the tool that measures the +// product does not share its modules. +#if defined(_WIN32) +inline constexpr bool is_windows = true; +#else +inline constexpr bool is_windows = false; +#endif +#if defined(__APPLE__) +inline constexpr bool is_macos = true; +#else +inline constexpr bool is_macos = false; +#endif +#if defined(__linux__) +inline constexpr bool is_linux = true; +#else +inline constexpr bool is_linux = false; +#endif +inline constexpr bool is_posix = !is_windows; + +// ── Metadata ───────────────────────────────────────────────────────── + +enum class Cost { Fast, Medium, Slow }; + +struct Meta { + std::string area; // "subos", "xim", "testkit", ... + Cost cost { Cost::Fast }; + std::vector covers; // requirement IDs (tests/requirements.toml) + std::vector requires_; // capabilities, see `capability_names()` + std::vector resources; // exclusive locks: "sandbox", "port:8080", ... + // "flow" for a test that runs the fake provider: it proves the path is + // wired, not that anything is isolated. Isolation requirement IDs are only + // covered by "isolation" tests (design §24.4). + std::string proves { "flow" }; +}; + +std::string_view to_string(Cost c); + +// Static registration; returns true so it can initialise a namespace-scope +// bool. Every registered test is written to $XTEST_META_OUT (NDJSON) at exit, +// whether or not it ran -- the coverage map needs the tests a filter skipped. +bool register_meta(std::string_view test, Meta meta); + +// The registry, for xdev and for testkit's own tests. +const std::map>& registry(); + +// ── Capabilities ───────────────────────────────────────────────────── + +// Known capability names. An unknown name in `requires_` is a test bug and +// fails the test: a typo must not turn into a permanent skip. +std::span capability_names(); + +// Probe one capability on this host. Cached per process. +// nullopt = present; otherwise the reason it is missing. +std::optional probe(std::string_view capability); + +// For the tools around the tests (xdev): this process's environment, and +// what std::system's return value means, without their own system headers. +void set_env(const std::string& name, const std::string& value); +// The command's exit code, or 128 + the signal that ended it; -1 when the +// shell itself could not run. +int system_exit_code(int raw); + +// The capabilities this lane promised, from XDEV_LANE_CAPS (comma separated). +bool lane_declares(std::string_view capability); + +struct Verdict { + bool fail { false }; // true: report as a failure, false: a skip + std::string reason; +}; + +// nullopt = run the test. The lane's declarations come from XDEV_LANE_CAPS; +// the second form takes them explicitly (the rule itself is tested that way). +std::optional check_requirements(const Meta& meta); +std::optional check_requirements(const Meta& meta, + std::span lane_caps); + +// ── Processes ──────────────────────────────────────────────────────── + +struct RunOptions { + std::vector argv; // argv[0] is the program + std::map env; // the WHOLE environment + fs::path cwd; // empty = temp dir + std::chrono::milliseconds timeout { std::chrono::seconds(120) }; + std::string stdin_data; // written, then stdin closed + // Run on a pseudo-terminal with nothing typed. A program that waits for + // input there times out instead of finishing -- which is the observation + // the agent contract scan makes. POSIX only. + bool pty { false }; +}; + +struct RunResult { + int exit_code { -1 }; // -1 when it did not exit (timeout / signal) + int signal { 0 }; + bool timed_out { false }; + std::string out; // with pty: everything the terminal received + std::string err; + std::chrono::milliseconds elapsed { 0 }; + + // Each line of `out` that parses as a JSON object, in order. + std::vector json_lines() const; + // stdout + stderr, for failure messages. + std::string transcript() const; +}; + +RunResult run(const RunOptions& options); + +// The binary under test. Empty when there is none (the caller decides). +fs::path xlings_binary(); + +// This process's own environment, for tools (xdev) that run commands the way +// a developer would. Tests use Home::env() instead. +std::map inherited_env(); + +// ── Homes ──────────────────────────────────────────────────────────── + +class Home { +public: + // A new, empty xlings home in the system temp dir, with a user home + // next to it. `name` only makes the directory recognisable. + // Its default index/payloads use an owned loopback HTTP fixture. Only an + // XTEST explicitly requiring network selects the real mirror. + static Home isolated(std::string_view name); + + Home(Home&&) noexcept; + Home& operator=(Home&&) noexcept; + Home(const Home&) = delete; + Home& operator=(const Home&) = delete; + ~Home(); + + const fs::path& root() const { return root_; } // $HOME of the run + const fs::path& dir() const { return dir_; } // $XLINGS_HOME + + // The hermetic environment every run in this home starts from. + std::map env() const; + + // `xlings ` in this home. `extra_env` is added on top of env(). + RunResult xlings(std::vector args, + std::map extra_env = {}, + std::chrono::milliseconds timeout = std::chrono::seconds(120)) const; + RunResult xlings(RunOptions options) const; // argv without the binary + + // Place a sandbox backend this host already has into the home, so a + // sandbox test does not download one. Returns false when there is none. + bool seed_sandbox_backend() const; + + // Keep the directory after the test (set automatically on failure). + void keep() { keep_ = true; } + +private: + Home() = default; + fs::path root_; + fs::path dir_; + bool keep_ { false }; +}; + +// ── Failure capture ────────────────────────────────────────────────── + +// Installed by xtest.hpp: whether the running test has failed, and its name. +using FailureProbe = bool (*)(); +using NameProbe = std::string (*)(); +void set_probes(FailureProbe failed, NameProbe name); + +// Where failure artefacts go: $XTEST_ARTIFACTS, else target/xtest-artifacts. +fs::path artifacts_dir(); + +// Write the result line for one finished test to $XTEST_RESULTS_OUT. +void record_result(std::string_view test, std::string_view status, + long long elapsed_ms, std::string_view message); + +// Small helpers shared by tests. +std::string read_file(const fs::path& path); +void write_file(const fs::path& path, std::string_view content); +std::string current_user(); + +} // namespace xlings::testkit diff --git a/modules/theme/mcpp.toml b/modules/theme/mcpp.toml deleted file mode 100644 index 8944ce308..000000000 --- a/modules/theme/mcpp.toml +++ /dev/null @@ -1,21 +0,0 @@ -# The colour scheme, as its own package. -# -# A leaf on purpose: it imports `std` and the JSON reader and nothing else. -# That is what lets everyone depend on it without a cycle -- the frontends need -# it, and so does `core/log.cppm` until the Sink work lands and core stops -# writing SGR itself. -# -# It therefore owns `Rgb` and `Background` rather than borrowing them from -# `core/palette`: palette's remaining job is deciding whether the terminal can -# take colour at all, which is a different question from which colour to use -# and needs `xlings.platform` to answer. -[package] -namespace = "xlings" -name = "theme" -version = "0.1.0" - -[targets.theme] -kind = "lib" - -[build] -include_dirs = ["../json/src/json"] diff --git a/modules/tinyhttps/mcpp.toml b/modules/tinyhttps/mcpp.toml deleted file mode 100644 index 56f8620c1..000000000 --- a/modules/tinyhttps/mcpp.toml +++ /dev/null @@ -1,18 +0,0 @@ -# HTTPS fetch, wrapping mcpplibs.tinyhttps. -# -# It used to `import xlings.core.log` for a single `log::debug` about proxy -# selection. A workspace member may not depend on the ROOT package, and that -# one line was the only thing standing in the way -- so the proxy in use is now -# reported through the return path instead of written to a log from three -# layers down. A library that logs is a library that has an opinion about -# where its caller's output goes. -[package] -namespace = "xlings" -name = "xhttp" -version = "0.1.0" - -[targets.xhttp] -kind = "lib" - -[dependencies.mcpplibs] -tinyhttps = "0.2.9" diff --git a/modules/ui/mcpp.toml b/modules/ui/mcpp.toml new file mode 100644 index 000000000..009d6d128 --- /dev/null +++ b/modules/ui/mcpp.toml @@ -0,0 +1,25 @@ +# What the interaction surfaces show, independent of how they draw it. +# +# theme the colour scheme. Its module imports `std` and the JSON reader +# only, so the frontends and core/log can all depend on it without +# a cycle. It owns `Rgb` and `Background`; whether the terminal can +# take colour at all is core/palette's question. +# i18n message catalogues, built in: nobody authors a translation table, +# so embedding it removes a delivery chain and every way it fails +# (themes, which users do copy and edit, stay files). `en` is the +# complete base; every other language states only what it changes. +# Adding a language is adding a file under src/i18n/. "auto" +# resolves through platform's system-language probe. +[package] +namespace = "xlings" +name = "ui" +version = "0.1.0" + +[targets.ui] +kind = "lib" + +[dependencies] +platform = { path = "../platform" } + +[build] +include_dirs = ["../libs/src/json"] diff --git a/modules/i18n/src/i18n.cpp b/modules/ui/src/i18n.cpp similarity index 100% rename from modules/i18n/src/i18n.cpp rename to modules/ui/src/i18n.cpp diff --git a/modules/i18n/src/i18n.cppm b/modules/ui/src/i18n.cppm similarity index 100% rename from modules/i18n/src/i18n.cppm rename to modules/ui/src/i18n.cppm diff --git a/modules/i18n/src/i18n/en.cppm b/modules/ui/src/i18n/en.cppm similarity index 100% rename from modules/i18n/src/i18n/en.cppm rename to modules/ui/src/i18n/en.cppm diff --git a/modules/i18n/src/i18n/zh.cppm b/modules/ui/src/i18n/zh.cppm similarity index 56% rename from modules/i18n/src/i18n/zh.cppm rename to modules/ui/src/i18n/zh.cppm index da66787a7..85c27a74f 100644 --- a/modules/i18n/src/i18n/zh.cppm +++ b/modules/ui/src/i18n/zh.cppm @@ -95,17 +95,116 @@ inline constexpr Entry kEntries[] = { { "Optional SubOS name; omit to list candidates", "SubOS 名字(可选);省略则列出候选" }, { "Persist the active SubOS", "持久保存当前激活的 SubOS" }, { "Emit shell activation code", "输出 shell 激活代码" }, - { "Enable sandbox (bwrap or proot on Linux)", "启用沙箱(Linux 上为 bwrap 或 proot)" }, { "Run one command", "只运行一条命令" }, - { "Keep the namespace keeper", "保留命名空间守护进程" }, - { "Disable the namespace keeper", "禁用命名空间守护进程" }, - { "Keeper idle timeout", "守护进程空闲超时" }, + { "Run the configuration step again, even where it already ran", "即使已经运行过,也再运行一次配置步骤" }, + { "Install into this subos instead of the current one", "装进这个 subos,而不是当前的" }, + { "Remove even if packages depend on it, the recipe is gone, or its uninstall hook fails", "即使有包依赖它、配方已不存在或卸载钩子失败,也照样移除" }, + { "Remove every installed version, not just the active one", "移除所有已安装的版本,而不只是当前版本" }, + { "Remove from every subos that has it installed", "从每个装了它的 subos 中移除" }, + { "Act on this subos only, instead of the current one", "只作用于这个 subos,而不是当前的" }, + { "List local recipes and how they relate to the synced index", "列出本地配方及其与已同步索引的关系" }, + { "Remove one local recipe", "移除一个本地配方" }, + { "Remove local recipes (all, or stale = identical/behind the synced index)", "移除本地配方(all,或 stale = 与已同步索引相同/更旧)" }, + { "Enable sandbox (bwrap, proot or landlock on Linux); --sandbox=dev|private|locked picks a preset", "启用沙箱(Linux 上为 bwrap、proot 或 landlock);--sandbox=dev|private|locked 选择预设" }, + { "This call only: host, nat, none or proxy (may only tighten)", "仅本次调用:host、nat、none 或 proxy(只能收紧)" }, + { "This call only: auto, ask or deny (may only tighten)", "仅本次调用:auto、ask 或 deny(只能收紧)" }, + { "This call only: grant from the policy's grants_allowed", "仅本次调用:授予策略 grants_allowed 中的一项" }, + { "Refuse to enter when anything asked for is missing", "要求的任何一项缺失时拒绝进入" }, + { "This call only: map a host path into the SubOS; repeatable", "仅本次调用:把宿主路径映射进 SubOS;可重复" }, + { "Keep the session after the shell exits", "shell 退出后保留会话" }, + { "End the session with the shell", "会话随 shell 结束" }, + { "Session idle timeout", "会话空闲超时" }, + { "Stop a SubOS's running session", "结束一个 SubOS 正在运行的会话" }, + { "Run a command in a SubOS from outside it", "从外部在 SubOS 中运行一条命令" }, + { "SubOS name (omit with --temp)", "SubOS 名字(使用 --temp 时省略)" }, + { "The command and its arguments, after --", "命令及其参数,写在 -- 之后" }, + { "Run in the SubOS's sandbox (bwrap, proot or landlock on Linux); --sandbox=dev|private|locked picks a preset", "在 SubOS 的沙箱中运行(Linux 上为 bwrap、proot 或 landlock);--sandbox=dev|private|locked 选择预设" }, + { "Refuse to run when anything asked for is missing", "要求的任何一项缺失时拒绝运行" }, + { "With net=nat: publish a TCP port; repeatable", "net=nat 时:发布一个 TCP 端口;可重复" }, + { "Working directory inside", "沙箱内的工作目录" }, + { "Set a variable; repeatable", "设置一个变量;可重复" }, + { "End the command after DURATION (90, 30s, 10m, 2h); exits 124", "DURATION 后结束命令(90、30s、10m、2h);退出码 124" }, + { "Print the result as JSON on stderr when the command ends", "命令结束时把结果以 JSON 输出到 stderr" }, + { "Use a throwaway SubOS, removed afterwards (its audit is kept)", "使用一次性 SubOS,用完删除(审计保留)" }, + { "With --temp: fork it from this SubOS or package", "配合 --temp:从这个 SubOS 或包派生" }, + { "Start a SubOS session that runs without a terminal", "启动一个不占用终端的 SubOS 会话" }, + { "Sandbox backend (bwrap, proot or landlock); --sandbox=dev|private|locked picks a preset", "沙箱后端(bwrap、proot 或 landlock);--sandbox=dev|private|locked 选择预设" }, + { "host, nat, none or proxy (may only tighten)", "host、nat、none 或 proxy(只能收紧)" }, + { "Grant from the policy's grants_allowed", "授予策略 grants_allowed 中的一项" }, + { "Refuse to start when anything asked for is missing", "要求的任何一项缺失时拒绝启动" }, + { "End after DURATION idle (90, 30s, 10m, 2h); default: until stop", "空闲 DURATION 后结束(90、30s、10m、2h);默认直到 stop" }, + { "Copy files into or out of a SubOS", "在宿主和 SubOS 之间拷贝文件" }, + { "Host path, or :", "宿主路径,或 :" }, + { "Show or change what a SubOS may do (its policy)", "查看或修改一个 SubOS 能做什么(它的策略)" }, + { "Start from a preset (dev, private, locked) or a policy package (ns:name[@version])", "从预设(dev、private、locked)或策略包(ns:name[@version])开始" }, + { "Move to the newest version of the selected policy package", "升级到所选策略包的最新版本" }, + { "host, nat, none or proxy", "host、nat、none 或 proxy" }, + { "Installing a missing package from inside: auto, ask or deny", "在沙箱内安装缺失的包:auto、ask 或 deny" }, + { "Updating the index from inside: auto, ask or deny", "在沙箱内更新索引:auto、ask 或 deny" }, + { "off, basic, standard or full", "off、basic、standard 或 full" }, + { "Grant display, audio, camera, gpu, ssh-agent, dbus or host-loopback", "授予 display、audio、camera、gpu、ssh-agent、dbus 或 host-loopback" }, + { "Withdraw a grant", "撤销一项授权" }, + { "Grants a single call may add", "单次调用可以自行添加的授权" }, + { "Let this host variable in; NAME* for a prefix", "放行这个宿主环境变量;NAME* 表示前缀" }, + { "Map a host path into the SubOS, every time it is entered", "每次进入时都把宿主路径映射进 SubOS" }, + { "Stop mapping it", "不再映射它" }, + { "Enter and report what is missing", "照常进入,并报告缺失的项" }, + { "Remove the policy file", "删除策略文件" }, + { "Show what a SubOS asks for and what this host gives it", "显示一个 SubOS 要求什么、这台机器实际给了什么" }, + { "Check each SubOS: policy, entry on this host, policy package, sessions", "检查每个 SubOS:策略、能否在这台机器进入、策略包、会话" }, + { "SubOS name; omit for all", "SubOS 名字;省略则检查全部" }, + { "Re-install a selected policy package whose payload is missing", "重新安装产物缺失的已选策略包" }, + { "List what a SubOS's sandbox asked for and is waiting on", "列出 SubOS 沙箱发出、正在等待批准的请求" }, + { "One JSON object per request", "每个请求一个 JSON 对象" }, + { "Run a request a SubOS's sandbox is waiting on", "批准并执行 SubOS 沙箱正在等待的请求" }, + { "Request id", "请求 id" }, + { "Refuse a request a SubOS's sandbox is waiting on", "拒绝 SubOS 沙箱正在等待的请求" }, + { "Summarise what happened in a SubOS: sessions, programs, permissions, files", "汇总一个 SubOS 中发生了什么:会话、程序、权限、文件" }, + { "Only this session", "只看这个会话" }, + { "List running SubOS sessions", "列出正在运行的 SubOS 会话" }, + { "One JSON object per session", "每个会话一个 JSON 对象" }, + { "Show a SubOS's audit events", "显示一个 SubOS 的审计事件" }, + { "Only this kind (ops, lifecycle, perm, exec, net, fs); repeatable", "只看这一类(ops、lifecycle、perm、exec、net、fs);可重复" }, + { "Show the last N events (default 50)", "显示最近 N 条事件(默认 50)" }, + { "Keep printing new events", "持续输出新事件" }, + { "One JSON object per event", "每条事件一个 JSON 对象" }, + { "Rebind a SubOS to another runtime", "把 SubOS 重新绑定到另一个运行时" }, + { "Runtime binding, e.g. glibc@2.39", "运行时绑定,例如 glibc@2.39" }, + { "Optional SubOS; omit for the current one", "SubOS(可选);省略则为当前的" }, + { "Install into this home even when xlings is a system package's", "即使 xlings 来自系统包,也装进这个 home" }, + { "Check/repair one specific subos instead of the active one", "检查/修复指定的 subos,而不是当前激活的" }, + { "Repair (implies --deep; walks every subos that owns a finding)", "修复(隐含 --deep;遍历每个有问题的 subos)" }, + { "Show all findings, including non-defects", "显示全部结果,包括非缺陷项" }, + { "Deprecated alias for --show-ok", "--show-ok 的旧别名" }, + { "Check what this host can isolate SubOS sandboxes with; with --fix, install a root-owned bwrap and its AppArmor profile", "检查这台机器能用什么隔离 SubOS 沙箱;配合 --fix 安装 root 所有的 bwrap 及其 AppArmor profile" }, + { "Machine-readable output (with --isolation)", "机器可读输出(配合 --isolation)" }, + { "Declare its isolation once: dev (default), private or locked", "一次性声明它的隔离程度:dev(默认)、private 或 locked" }, + { "Make it a root: entered, exported or booted as /", "作为一个根:以 / 进入、导出或启动" }, + { "Install into the system layer (/xlings): every user of this machine gets it", "装进系统层(/xlings):这台机器的每个用户都能用" }, + { "Move a root SubOS back to an earlier generation", "把根 SubOS 切回更早的一代" }, + { "This generation (default: the one before)", "切到这一代(默认:上一代)" }, + { "List the generations", "列出各代" }, + { "Choose the SubOS a machine boots, from the next boot on", "选择机器启动的 SubOS,下次启动起生效" }, + { "SubOS name; omit to show", "SubOS 名称;省略则显示当前设置" }, + { "Only the next boot (a trial)", "只用于下一次启动(试启动)" }, + { "Boot it when the default fails", "默认项启动失败时启动它" }, + { "This boot worked: keep booting it", "这次启动正常:之后继续启动它" }, + { "Switch user space to it now, where the init can (no reboot)", "现在就把用户态切换过去(init 支持时,不重启)" }, + { "Export a root SubOS as a directory, a tarball or a disk image", "把根 SubOS 导出为目录、tar 包或磁盘镜像" }, + { "A root directory (chroot, bwrap, nspawn)", "一个根目录(chroot、bwrap、nspawn)" }, + { "A root tarball (docker import, podman import, wsl --import)", "一个根 tar 包(docker import、podman import、wsl --import)" }, + { "An ext4 disk image to boot", "可启动的 ext4 磁盘镜像" }, + { "Disk size (default 4G)", "磁盘大小(默认 4G)" }, + { "Include /root, /home, /var, /srv, /opt", "包含 /root、/home、/var、/srv、/opt" }, + { "Compare the packages of two SubOS", "比较两个 SubOS 的包" }, + { "Pack a SubOS's declaration as a subos-type xpkg", "把 SubOS 的声明打包成 subos 类型的 xpkg" }, + { "The package it becomes", "打包成的包" }, + { "Where the tarball goes", "tar 包的输出目录" }, { "Expose GPU devices (bwrap only)", "暴露 GPU 设备(仅 bwrap)" }, { "List SubOS environments", "列出 SubOS 环境" }, { "Remove a SubOS", "移除一个 SubOS" }, { "Show SubOS details", "查看 SubOS 详情" }, { "Optional SubOS", "SubOS(可选)" }, - { "Stop a SubOS keeper", "停止 SubOS 守护进程" }, { "Manage xlings itself", "管理 xlings 自身" }, { "Install xlings", "安装 xlings" }, { "Uninstall xlings", "卸载 xlings" }, diff --git a/modules/theme/src/theme.cpp b/modules/ui/src/theme.cpp similarity index 100% rename from modules/theme/src/theme.cpp rename to modules/ui/src/theme.cpp diff --git a/modules/theme/src/theme.cppm b/modules/ui/src/theme.cppm similarity index 100% rename from modules/theme/src/theme.cppm rename to modules/ui/src/theme.cppm diff --git a/src/capabilities.cpp b/src/capabilities.cpp index f19840393..af4c6a787 100644 --- a/src/capabilities.cpp +++ b/src/capabilities.cpp @@ -2,6 +2,7 @@ module xlings.capabilities; import std; import xlings.platform; +import xlings.platform.stream; import xlings.runtime.event; import xlings.runtime.event_stream; import xlings.runtime.capability; @@ -12,12 +13,16 @@ import xlings.core.xim.repo; import xlings.core.xvm.commands; import xlings.core.config; import xlings.core.home_config; +import xlings.core.home.domain_producer; import xlings.core.subos; import xlings.core.subos.sandbox; import xlings.core.xself; import xlings.platform; import xlings.runtime.cancellation; import xlings.core.utf8; +import xlings.observe; +import xlings.subos.home_view; +import xlings.core.subos.ports; namespace xlings::capabilities { @@ -45,6 +50,10 @@ capability::Registry build_registry() { reg.register_capability(std::make_unique()); reg.register_capability(std::make_unique()); reg.register_capability(std::make_unique()); + reg.register_capability(std::make_unique()); + reg.register_capability(std::make_unique()); + reg.register_capability(std::make_unique()); + reg.register_capability(std::make_unique()); reg.register_capability(std::make_unique()); // Index repo management (added 2026-04-26 per interface-api-v1-eval P1) reg.register_capability(std::make_unique()); @@ -331,21 +340,7 @@ auto ListSubos::spec() const -> CapabilitySpec { } auto ListSubos::execute(Params, EventStream& stream) -> Result { - auto all = subos::candidate_view().candidates; - nlohmann::json entries = nlohmann::json::array(); - for (auto& s : all) { - entries.push_back({ - {"name", s.name}, - {"dir", s.dir.string()}, - {"commands", s.commandCount}, - {"packages", s.packageCount}, - {"active", s.isActive}, - }); - } - nlohmann::json payload; - payload["entries"] = std::move(entries); - stream.emit(DataEvent{"subos_list", payload.dump()}); - return exit_result(0); + return exit_result(subos::list(stream)); } auto ListSubosShims::spec() const -> CapabilitySpec { @@ -634,3 +629,156 @@ auto Env::execute(Params, EventStream& stream) -> Result { } } // namespace xlings::capabilities + + +// ── sub-OS sessions over the interface ───────────────────────── + +namespace xlings::capabilities { + +namespace { + +std::expected scope_home_(std::string_view name) { + const auto owner = subos::home_view(); + auto scope = home::domain_producer::read_scope(owner.home, name); + if (!scope) return std::unexpected(scope.error()); + return *scope ? subos::HomeView{(**scope).domain.physicalHome} : owner; +} + +// `xlings subos ...` as a child of this process, its stdout and stderr +// captured. The interface's own stdout is the NDJSON channel: a command's +// output must never reach it unframed, so it runs in a process of its own and +// comes back as events. +int run_subos_child_(std::vector args, EventStream& stream, std::string_view kind, + CancellationToken* cancel = nullptr) { + args.insert(args.begin(), platform::get_executable_path().string()); + const auto base64 = [](std::string_view bytes) { + constexpr std::string_view alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::string encoded; + unsigned value{}, bits{}; + for (const unsigned char byte : bytes) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 6) { bits -= 6; encoded += alphabet[(value >> bits) & 63]; } + } + if (bits) encoded += alphabet[(value << (6 - bits)) & 63]; + while (encoded.size() % 4) encoded += '='; + return encoded; + }; + const auto code = platform::stream::run(args, [&](std::string_view channel, std::string_view bytes) { + nlohmann::json payload{{"stream", channel}, {"data", std::string(bytes)}}; + std::string content; + try { content = payload.dump(); } + catch (const nlohmann::json::type_error&) { + payload["encoding"] = "base64"; + payload["data"] = base64(bytes); + content = payload.dump(); + } + stream.emit(DataEvent{std::string(kind), std::move(content)}); + }, [&] { return cancel && cancel->is_cancelled(); }); + if (cancel) cancel->throw_if_cancelled(); + return code; +} + +} // namespace + +auto SubosExec::spec() const -> CapabilitySpec { + return { + .name = "subos_exec", + .description = "Run one command in a sub-OS from outside it, as argv (no shell quoting). Joins the sub-OS's running session when there is one. Output arrives as subos_exec_output data events; exitCode is the command's own, 125 when it never started, 126/127 when it cannot run / is not found, 124 on timeout, 128+n for signal n", + .inputSchema = R"({"type":"object","properties":{"name":{"type":"string","description":"Sub-OS name; omit with temp"},"argv":{"type":"array","items":{"type":"string"},"description":"The command and its arguments"},"sandbox":{"type":"boolean","description":"Run in the sub-OS's sandbox"},"cwd":{"type":"string"},"env":{"type":"object","additionalProperties":{"type":"string"}},"timeout":{"type":"string","description":"90, 30s, 10m, 2h"},"temp":{"type":"boolean","description":"A throwaway sub-OS, removed afterwards; its audit is kept"},"from":{"type":"string","description":"With temp: fork it from this sub-OS or package"}},"required":["argv"]})", + .outputSchema = R"({"type":"object","properties":{"exitCode":{"type":"integer"}}})", + .destructive = true, + }; +} + +auto SubosExec::execute(Params params, EventStream& stream) -> Result { + return execute(params, stream, nullptr); +} + +auto SubosExec::execute(Params params, EventStream& stream, CancellationToken* cancel) -> Result { + auto json = nlohmann::json::parse(params, nullptr, false); + std::vector args{"subos", "exec"}; + if (json.value("temp", false)) args.push_back("--temp"); + else args.push_back(json.value("name", "")); + if (json.value("sandbox", false)) args.push_back("--sandbox"); + if (auto v = json.value("cwd", ""); !v.empty()) args.insert(args.end(), {"--cwd", v}); + if (auto v = json.value("timeout", ""); !v.empty()) args.insert(args.end(), {"--timeout", v}); + if (auto v = json.value("from", ""); !v.empty()) args.insert(args.end(), {"--from", v}); + if (auto env = json.value("env", nlohmann::json::object()); env.is_object()) { + for (auto it = env.begin(); it != env.end(); ++it) + if (it.value().is_string()) args.insert(args.end(), {"--env", it.key() + "=" + it.value().get()}); + } + args.push_back("--"); + for (auto& a : json.value("argv", nlohmann::json::array())) + if (a.is_string()) args.push_back(a.get()); + return exit_result(run_subos_child_(std::move(args), stream, "subos_exec_output", cancel)); +} + +auto SubosStart::spec() const -> CapabilitySpec { + return { + .name = "subos_start", + .description = "Start a sub-OS session that runs without a terminal, for a series of subos_exec calls. ttl ends it after that long idle", + .inputSchema = R"({"type":"object","properties":{"name":{"type":"string"},"ttl":{"type":"string","description":"90, 30s, 10m, 2h; omit to run until subos_stop"}},"required":["name"]})", + .outputSchema = R"({"type":"object","properties":{"exitCode":{"type":"integer"}}})", + .destructive = false, + }; +} + +auto SubosStart::execute(Params params, EventStream& stream) -> Result { + auto json = nlohmann::json::parse(params, nullptr, false); + std::vector args{"subos", "start", json.value("name", "")}; + if (auto v = json.value("ttl", ""); !v.empty()) args.insert(args.end(), {"--ttl", v}); + return exit_result(run_subos_child_(std::move(args), stream, "subos_start_output")); +} + +auto SubosStop::spec() const -> CapabilitySpec { + return { + .name = "subos_stop", + .description = "End a sub-OS's running session. Succeeds when none is running", + .inputSchema = R"({"type":"object","properties":{"name":{"type":"string"}},"required":["name"]})", + .outputSchema = R"({"type":"object","properties":{"exitCode":{"type":"integer"}}})", + .destructive = false, + }; +} + +auto SubosStop::execute(Params params, EventStream& stream) -> Result { + auto json = nlohmann::json::parse(params, nullptr, false); + return exit_result(run_subos_child_({"subos", "stop", json.value("name", "")}, stream, + "subos_stop_output")); +} + +auto SubosEvents::spec() const -> CapabilitySpec { + return { + .name = "subos_events", + .description = "A sub-OS's audit events (sessions, commands, permission decisions), recorded outside its sandbox. Each event is a subos_event data event; variable values are never recorded, only names", + .inputSchema = R"({"type":"object","properties":{"name":{"type":"string"},"kind":{"type":"string","description":"ops, lifecycle, perm, exec, net or fs"},"session":{"type":"string"},"limit":{"type":"integer","description":"The last N events; default 200"}},"required":["name"]})", + .outputSchema = R"({"type":"object","properties":{"exitCode":{"type":"integer"},"count":{"type":"integer"}}})", + .destructive = false, + }; +} + +auto SubosEvents::execute(Params params, EventStream& stream) -> Result { + auto json = nlohmann::json::parse(params, nullptr, false); + const auto name = json.value("name", ""); + const auto kind = json.value("kind", ""); + const auto session = json.value("session", ""); + const auto limit = static_cast(std::max(1, json.value("limit", 200))); + const auto home = scope_home_(name); + if (!home) { + stream.emit(ErrorEvent{ .code = ErrorCode::InvalidInput, .message = home.error(), .recoverable = false }); + return exit_result(1); + } + auto events = observe::read(home->logs_dir(name) / "events.ndjson"); + std::vector shown; + for (auto& e : events) { + if (!kind.empty() && e.value("kind", "") != kind) continue; + if (!session.empty() && e.value("session", "") != session) continue; + shown.push_back(std::move(e)); + } + const auto from = shown.size() > limit ? shown.size() - limit : 0; + for (auto i = from; i < shown.size(); ++i) + stream.emit(DataEvent{"subos_event", shown[i].dump()}); + return nlohmann::json({{"exitCode", 0}, {"count", shown.size() - from}}).dump(); +} + +} // namespace xlings::capabilities diff --git a/src/capabilities.cppm b/src/capabilities.cppm index cfaa67533..b2e5ed288 100644 --- a/src/capabilities.cppm +++ b/src/capabilities.cppm @@ -122,6 +122,34 @@ public: auto execute(Params params, EventStream& stream) -> Result override; }; +// Running commands in a sub-OS from outside it (design §12.3). The agent's +// way in: argv, no shell quoting, the command's output streamed as events, +// the exit code as the result. +class SubosExec : public Capability { +public: + auto spec() const -> CapabilitySpec override; + auto execute(Params params, EventStream& stream) -> Result override; + auto execute(Params params, EventStream& stream, CancellationToken* cancel) -> Result override; +}; + +class SubosStart : public Capability { +public: + auto spec() const -> CapabilitySpec override; + auto execute(Params params, EventStream& stream) -> Result override; +}; + +class SubosStop : public Capability { +public: + auto spec() const -> CapabilitySpec override; + auto execute(Params params, EventStream& stream) -> Result override; +}; + +class SubosEvents : public Capability { +public: + auto spec() const -> CapabilitySpec override; + auto execute(Params params, EventStream& stream) -> Result override; +}; + // ─── Index repos ──────────────────────────────────────────── class ListRepos : public Capability { diff --git a/src/cli.cpp b/src/cli.cpp index 317763c91..01a8e4b7c 100644 --- a/src/cli.cpp +++ b/src/cli.cpp @@ -7,11 +7,13 @@ module; module xlings.cli; +import xlings.core.xim.lua_boundary; import std; import mcpplibs.cmdline; import mcpplibs.capi.lua; import mcpplibs.xpkg.executor; import xlings.core.config; +import xlings.core.home; import xlings.core.notice; import xlings.core.home_config; import xlings.libs.json; @@ -23,6 +25,9 @@ import xlings.runtime; import xlings.ui; import xlings.i18n; import xlings.platform; +import xlings.subos.broker; +import xlings.subos.policy; +import xlings.core.subos.sandbox; import xlings.capabilities; import xlings.agent; import xlings.agent.text_renderer; @@ -78,7 +83,23 @@ std::vector completion_live_values_( } else if (option == "--storage") { for (auto value : {"shared", "tmpfs", "image"}) add(value); } else if (option == "--sandbox") { - for (auto value : {"bwrap", "proot"}) add(value); + // `subos config` takes a preset; everywhere else the word after + // `--sandbox` is a backend (a preset there is `--sandbox=`). + if (request.command == "subos config") { + for (auto value : {"dev", "private", "locked"}) add(value); + } else { + for (auto value : {"bwrap", "proot", "landlock"}) add(value); + } + } else if (option == "--net") { + for (auto value : {"host", "nat", "none", "proxy"}) add(value); + } else if (option == "--fetch") { + for (auto value : {"auto", "ask", "deny"}) add(value); + } else if (option == "--index-update") { + for (auto value : {"auto", "ask", "deny"}) add(value); + } else if (option == "--observe") { + for (auto value : {"off", "basic", "standard", "full"}) add(value); + } else if (option == "--allow" || option == "--disallow") { + for (auto value : subos::policy::kGrants) add(std::string(value)); } else if (option == "--shell") { // Kept in step with what xvm::parse_shell accepts; aliases are // cheap to offer and the parser is the authority. @@ -430,8 +451,14 @@ void dispatch_data_event(const DataEvent& e) { std::vector> entries; if (json.contains("entries") && json["entries"].is_array()) { for (auto& e : json["entries"]) { + // A root says so, and what it is to this machine (part 2 §3.4). + std::string name = e.value("name", ""); + if (e.value("kind", "view") == "rootfs") { + name += e.value("host", false) ? " (root, /)" : " (root)"; + if (e.value("boot_entry", false)) name += " [boot]"; + } entries.emplace_back( - e.value("name", ""), + name, e.value("dir", ""), e.value("commands", e.value("pkgCount", 0)), e.value("packages", -1), @@ -736,6 +763,61 @@ bool parse_target_spec_(const mcpplibs::cmdline::ParsedArgs& args, } // Install packages from project .xlings.json workspace +// `install --system` (deployment M, SubOS design part 2 §10): the system +// layer is a home of its own -- root's, /xlings unless XLINGS_SYSTEM_LAYER +// names another -- and the install runs in it. Every user's profile puts its +// programs after their home's own, so a user's version wins and the system's +// is used without a copy; nobody but root can write it. +int install_into_system_layer_(const std::vector& targets, bool yes, bool reconfig) { + namespace fs = std::filesystem; + const char* named = std::getenv("XLINGS_SYSTEM_LAYER"); + const auto layer = fs::path(named && *named ? named : "/xlings"); + if (targets.empty()) { + log::error("install --system needs the packages to install"); + return 2; + } + if (!platform::is_root()) { + log::error("the system layer {} is root's: sudo xlings install --system ...", layer.string()); + return 13; + } + std::error_code ec; + fs::create_directories(layer / "bin", ec); + if (ec) { + log::error("cannot initialize the system layer {}: {}", layer.string(), ec.message()); + return 1; + } + if (!fs::exists(layer / ".xlings-home", ec)) { + std::ofstream(layer / ".xlings-home") << "{\n \"layout\": 2,\n \"mode\": \"multi\"\n}\n"; + } + // Its entry: this binary when it is a system package's (the package + // manager updates it), a copy of it otherwise. + const auto exe = platform::get_executable_path(); + const auto entry = layer / "bin" / "xlings"; + if (!fs::exists(entry, ec)) { + if (home::describe_entry(exe, layer).system) fs::create_symlink(exe, entry, ec); + else fs::copy_file(exe, entry, ec); + if (ec) { + log::error("cannot place {}: {}", entry.string(), ec.message()); + return 1; + } + } + platform::set_env_variable("XLINGS_HOME", layer.string()); + platform::unset_env_variable("XLINGS_ACTIVE_SUBOS"); + if (int rc = platform::run_argv_with_timeout(std::vector{entry.string(), "self", "init"}, + std::chrono::hours(1)); + rc != 0) + return rc; + std::vector argv{entry.string(), std::string("install")}; + if (yes) argv.push_back("-y"); + if (reconfig) argv.push_back("--reconfig"); + argv.insert(argv.end(), targets.begin(), targets.end()); + const int rc = platform::run_argv_with_timeout(argv, std::chrono::hours(6)); + if (rc == 0) + log::info("in the system layer {}: every user's shell finds it after their own " + "(their profile, from the next shell on)", layer.string()); + return rc; +} + int install_from_project_config_(EventStream& stream, bool reconfig) { namespace fs = std::filesystem; std::error_code ec; @@ -1561,7 +1643,15 @@ int dispatch_(int argc, char* argv[]) { // Scan for global flags (--verbose, -v, --quiet, -q, --agent) anywhere // in argv so they work regardless of position. - bool agent_mode = false; + // The audience is DECLARED, never inferred from a terminal (design §13): + // `--agent` for one command, XLINGS_AGENT_MODE=1 for a whole process tree + // (0 declares a human). Exported below so every xlings this one starts -- + // including the one inside a sandbox, where the variable is allow-listed + // -- keeps the same contract. + bool agent_mode = [] { + const char* v = std::getenv("XLINGS_AGENT_MODE"); + return v != nullptr && std::string_view(v) == "1"; + }(); std::string uiModeFlag; for (int i = 1; i < argc; ++i) { std::string_view a { argv[i] }; @@ -1648,6 +1738,7 @@ int dispatch_(int argc, char* argv[]) { // Unlike interface mode, we do NOT set tui_mode(true) — log output should // still reach the terminal, just without ANSI decoration. if (agent_mode) { + platform::set_env_variable("XLINGS_AGENT_MODE", "1"); stream.set_enabled(tui_listener, false); log::enable_color(false); // One switch for every writer, not just log's own prefixes. @@ -1740,6 +1831,29 @@ int dispatch_(int argc, char* argv[]) { // family is that a machine reads its output. if (cmd == "agent") return agent::run(fargc, fargv.data()); + // Inside a SubOS sandbox the home is read-only (design §8): what would + // change it goes to the broker, which decides with the instance's + // policy and runs it outside; what only the owner may do is refused + // here, with the command that does it outside. + if (subos::broker::available()) { + std::vector rest; + for (int i = 1; i < fargc; ++i) rest.emplace_back(fargv[i]); + const char* active = std::getenv("XLINGS_ACTIVE_SUBOS"); + const std::string instance = active ? active : ""; + const auto cls = subos::broker::classify(rest, instance); + if (cls.route == subos::broker::Route::Owner) { + std::string line; + for (auto& a : rest) line += " " + a; + diag::emit({ + .code = "subos.owner_only", + .summary = "E_PERMISSION: only the owner, outside the sandbox, can do this", + .actions = { { "outside the sandbox", "xlings" + line } }, + }); + return subos::broker::kExitPermission; + } + if (cls.route == subos::broker::Route::Broker) return subos::broker::forward(rest); + } + // Intercept subcommand help: xlings -h/--help bool wantsHelp = false; for (int i = 2; i < fargc; ++i) { @@ -1870,7 +1984,51 @@ int dispatch_(int argc, char* argv[]) { } } + // The agent contract (design §13.3): nothing waits for input. An + // interactive shell is the one subos entry that does by design, so an + // agent is pointed at the form that does not -- decided here, at the + // interaction surface, never inside the core. + if (cmd == "subos" && agent_mode && fargc >= 4 + && std::string_view(fargv[2]) == "use") { + bool non_interactive = false; + for (int i = 3; i < fargc; ++i) { + std::string_view a{fargv[i]}; + if (a == "--cmd" || a.starts_with("--cmd=") || a == "--shell" + || a.starts_with("--shell=") || a == "--global") + non_interactive = true; + } + if (!non_interactive) { + std::string name; + for (int i = 3; i < fargc && name.empty(); ++i) + if (fargv[i][0] != '-') name = fargv[i]; + diag::emit({ + .code = "subos.interactive_in_agent_mode", + .summary = "`subos use` without --cmd opens an interactive shell, " + "and in agent mode nothing waits for input", + .actions = { { "run a command", std::format("xlings subos exec {} -- ", + name.empty() ? "" : name) }, + { "keep a session", std::format("xlings subos start {}", name.empty() ? "" : name) } }, + }); + return 2; + } + } if (cmd == "subos") return subos::run(fargc, fargv.data(), stream); + // `self doctor --isolation`: what this host can isolate with, and the + // one root repair (design §20). Answered by the SubOS adapter. + if (cmd == "self" && fargc >= 3 && std::string_view(fargv[2]) == "doctor") { + bool isolation = false, fix = false, yes = false, json = false; + for (int i = 3; i < fargc; ++i) { + std::string_view a{fargv[i]}; + if (a == "--isolation") isolation = true; + else if (a == "--fix") fix = true; + else if (a == "--json") json = true; + } + for (int i = 1; i < argc; ++i) { + std::string_view a{argv[i]}; + if (a == "-y" || a == "--yes") yes = true; + } + if (isolation) return subos::sandbox::doctor_isolation(fix, yes, json, stream); + } if (cmd == "self") return xself::run(fargc, fargv.data(), stream); if (cmd == "profile") return run_profile_(fargc, fargv.data(), stream); if (cmd == "script") { @@ -1880,11 +2038,6 @@ int dispatch_(int argc, char* argv[]) { } namespace fs = std::filesystem; fs::path scriptFile = fargv[2]; - auto execResult = mcpplibs::xpkg::create_executor(scriptFile); - if (!execResult) { - log::error("failed to load script: {}", execResult.error()); - return 1; - } mcpplibs::xpkg::ExecutionContext ctx; ctx.platform = std::string(platform::OS_NAME); ctx.bin_dir = Config::paths().binDir; @@ -1894,6 +2047,11 @@ int dispatch_(int argc, char* argv[]) { for (int i = 3; i < fargc; ++i) { ctx.args.emplace_back(fargv[i]); } + auto execResult = xim::lua_boundary::create_executor(scriptFile, ctx); + if (!execResult) { + log::error("failed to load script: {}", execResult.error()); + return 1; + } auto result = execResult->run_script(ctx); if (!result.success) { log::error("script error: {}", result.error); @@ -1940,6 +2098,8 @@ int dispatch_(int argc, char* argv[]) { .option(cmdline::Option("global").short_name('g').help("Install to global scope (not project-local subos)")) .option(cmdline::Option("use").short_name('u').help("Activate the installed version even if another version is currently active")) .option(cmdline::Option("reconfig").help("Run the configuration step again, even for packages already configured here")) + .option(cmdline::Option("subos").takes_value().value_name("NAME").help("Install into this subos instead of the current one")) + .option(cmdline::Option("system").help("Install into the system layer (/xlings): every user of this machine gets it")) .arg("packages").help("Package names with optional version") .action(wrap_rc([&stream](const cmdline::ParsedArgs& args) -> int { apply_global_opts_(args); @@ -1967,6 +2127,21 @@ int dispatch_(int argc, char* argv[]) { } const bool reconfig = args.is_flag_set("reconfig"); + if (args.is_flag_set("system")) + return install_into_system_layer_(targets, args.is_flag_set("yes"), reconfig); + // --subos (design §9; remove already had it): act on + // that subos, set the way `subos runtime` does -- the override + // recomputes the cached paths, the variable is what the + // activation path re-reads. + if (auto named = args.value("subos")) { + const std::string name(*named); + if (name.empty() || !std::filesystem::is_directory(Config::subos_dir(name))) { + log::error("no subos named '{}' (xlings subos list)", name); + return 1; + } + platform::set_env_variable("XLINGS_ACTIVE_SUBOS", name); + (void)Config::set_active_subos_override(name); + } if (targets.empty()) return install_from_project_config_(stream, reconfig); bool yes = args.is_flag_set("yes"); diff --git a/src/cli/spec.cpp b/src/cli/spec.cpp index 989b191dc..10b08f01b 100644 --- a/src/cli/spec.cpp +++ b/src/cli/spec.cpp @@ -20,7 +20,7 @@ const CommandSpec& root() { }, .children = { {"install", "Install packages", {}, {{"packages", "Package names", false, true}}, - {{"-g, --global", "Use global scope"}, {"-u, --use", "Activate installed version"}, {"--reconfig", "Run the configuration step again, even where it already ran"}}, {}}, + {{"-g, --global", "Use global scope"}, {"-u, --use", "Activate installed version"}, {"--reconfig", "Run the configuration step again, even where it already ran"}, {"--subos ", "Install into this subos instead of the current one"}, {"--system", "Install into the system layer (/xlings): every user of this machine gets it"}}, {}}, {"remove", "Remove a package", {}, {{"package", "Package name", true}, {"version", "Optional version", false}}, {{"-g, --global", "Use global scope"}, {"--force", "Remove even if packages depend on it, the recipe is gone, or its uninstall hook fails"}, @@ -40,23 +40,105 @@ const CommandSpec& root() { {"config", "Show or modify configuration", {}, {}, {{"--lang ", "Set language"}, {"--mirror ", "Set mirror"}, {"--ui-mode ", "Set UI mode (cli/tui/auto)"}, {"--theme ", "Set colour theme (name, path, or list)"}, {"--interactive ", "Inline prompts in tui mode"}, {"--add-xpkg ", "Add package recipe"}, {"--list-xpkg", "List local recipes and how they relate to the synced index"}, {"--remove-xpkg ", "Remove one local recipe"}, {"--clear-xpkg ", "Remove local recipes (all, or stale = identical/behind the synced index)"}, {"--index-repo ", "Add index repository"}, {"--rm-index-repo ", "Remove index repository"}}, {}}, {"subos", "Manage SubOS environments", {}, {}, {}, { - {"new", "Create a SubOS", {}, {{"name", "SubOS name", true}}, {{"--storage ", "shared, tmpfs or image"}, {"--image-size ", "Image size"}, {"--from ", "Fork source"}, {"--runtime ", "Runtime binding, e.g. glibc@2.44"}}, {}}, - {"use", "Enter a SubOS", {}, {{"name", "Optional SubOS name; omit to list candidates", false}}, {{"--global", "Persist the active SubOS"}, {"--shell [KIND]", "Emit shell activation code"}, {"--sandbox [BACKEND]", "Enable sandbox (bwrap or proot on Linux)"}, {"--cmd ", "Run one command"}, {"--keep", "Keep the namespace keeper"}, {"--no-keep", "Disable the namespace keeper"}, {"--ttl ", "Keeper idle timeout"}, {"--gpu", "Expose GPU devices (bwrap only)"}}, {}}, + {"new", "Create a SubOS", {}, {{"name", "SubOS name", true}}, {{"--sandbox [PRESET]", "Declare its isolation once: dev (default), private or locked"}, {"--storage ", "shared, tmpfs or image"}, {"--image-size ", "Image size"}, {"--from ", "Fork source"}, {"--runtime ", "Runtime binding, e.g. glibc@2.44"}, {"--rootfs", "Make it a root: entered, exported or booted as /"}, {"--domain ", "Build a rootfs at this logical home prefix in an owned namespace"}, {"--carrier ", "Where it runs: local, wsl2 (Windows) or vz (macOS); default chosen by what it is"}, {"--abi ", "native (this machine's programs) or linux"}}, {}}, + {"use", "Enter a SubOS", {}, {{"name", "Optional SubOS name; omit to list candidates", false}}, {{"--global", "Persist the active SubOS"}, {"--shell [KIND]", "Emit shell activation code"}, {"--sandbox [BACKEND]", "Enable sandbox (bwrap, proot or landlock on Linux); --sandbox=dev|private|locked picks a preset"}, {"--net ", "This call only: host, nat, none or proxy (may only tighten)"}, {"--proxy ", "SOCKS5h endpoint for net=proxy"}, {"--observe ", "This call only: off, basic, standard or full"}, {"--fetch ", "This call only: auto, ask or deny (may only tighten)"}, {"--allow ", "This call only: grant from the policy's grants_allowed"}, {"--no-degrade", "Refuse to enter when anything asked for is missing"}, {"--mount ", "This call only: map a host path into the SubOS; repeatable"}, {"--cmd ", "Run one command"}, {"--keep", "Keep the session after the shell exits"}, {"--no-keep", "End the session with the shell"}, {"--ttl ", "Session idle timeout"}, {"--gpu", "Expose GPU devices (bwrap only)"}}, {}}, {"list", "List SubOS environments", {"ls"}, {}, {}, {}}, {"remove", "Remove a SubOS", {"rm"}, {{"name", "SubOS name", true}}, {}, {}}, {"info", "Show SubOS details", {"i"}, {{"name", "Optional SubOS", false}}, {}, {}}, - {"stop", "Stop a SubOS keeper", {}, {{"name", "SubOS name", true}}, {}, {}}, + {"stop", "Stop a SubOS's running session", {}, {{"name", "SubOS name", true}}, {}, {}}, + {"exec", "Run a command in a SubOS from outside it", {}, + {{"name", "SubOS name (omit with --temp)", false}, + {"command", "The command and its arguments, after --", false, true}}, + {{"--sandbox [BACKEND]", "Run in the SubOS's sandbox (bwrap, proot or landlock on Linux); --sandbox=dev|private|locked picks a preset"}, + {"--net ", "This call only: host, nat, none or proxy (may only tighten)"}, {"--proxy ", "SOCKS5h endpoint for net=proxy"}, {"--observe ", "This call only: off, basic, standard or full"}, + {"--fetch ", "This call only: auto, ask or deny (may only tighten)"}, + {"--allow ", "This call only: grant from the policy's grants_allowed"}, + {"--no-degrade", "Refuse to run when anything asked for is missing"}, + {"--publish ", "With net=nat: publish a TCP port; repeatable"}, + {"--mount ", "This call only: map a host path into the SubOS; repeatable"}, + {"--cwd ", "Working directory inside"}, + {"--env ", "Set a variable; repeatable"}, + {"--timeout ", "End the command after DURATION (90, 30s, 10m, 2h); exits 124"}, + {"--json", "Print the result as JSON on stderr when the command ends"}, + {"--temp", "Use a throwaway SubOS, removed afterwards (its audit is kept)"}, + {"--from ", "With --temp: fork it from this SubOS or package"}}, {}}, + {"start", "Start a SubOS session that runs without a terminal", {}, + {{"name", "SubOS name", true}}, + {{"--sandbox [BACKEND]", "Sandbox backend (bwrap, proot or landlock); --sandbox=dev|private|locked picks a preset"}, + {"--net ", "host, nat, none or proxy (may only tighten)"}, {"--proxy ", "SOCKS5h endpoint for net=proxy"}, {"--observe ", "This call only: off, basic, standard or full"}, + {"--allow ", "Grant from the policy's grants_allowed"}, + {"--no-degrade", "Refuse to start when anything asked for is missing"}, + {"--publish ", "With net=nat: publish a TCP port; repeatable"}, + {"--mount ", "This call only: map a host path into the SubOS; repeatable"}, + {"--ttl ", "End after DURATION idle (90, 30s, 10m, 2h); default: until stop"}}, {}}, + {"cp", "Copy files into or out of a SubOS", {}, + {{"src", "Host path, or :", true}, {"dst", "Host path, or :", true}}, {}, {}}, + {"config", "Show or change what a SubOS may do (its policy)", {}, + {{"name", "SubOS name", true}}, + {{"--sandbox ", "Start from a preset (dev, private, locked) or a policy package (ns:name[@version])"}, + {"--policy-upgrade", "Move to the newest version of the selected policy package"}, + {"--net ", "host, nat, none or proxy"}, + {"--proxy ", "SOCKS5h endpoint for net=proxy"}, + {"--fetch ", "Installing a missing package from inside: auto, ask or deny"}, + {"--index-update ", "Updating the index from inside: auto, ask or deny"}, + {"--observe ", "off, basic, standard or full"}, + {"--allow ", "Grant display, audio, camera, gpu, ssh-agent, dbus or host-loopback"}, + {"--disallow ", "Withdraw a grant"}, + {"--grants-allowed ", "Grants a single call may add"}, + {"--env-pass ", "Let this host variable in; NAME* for a prefix"}, + {"--mount ", "Map a host path into the SubOS, every time it is entered"}, + {"--unmount ", "Stop mapping it"}, + {"--no-degrade", "Refuse to enter when anything asked for is missing"}, + {"--degrade", "Enter and report what is missing"}, + {"--reset", "Remove the policy file"}, + {"--json", "Machine-readable output"}}, {}}, + {"status", "Show what a SubOS asks for and what this host gives it", {}, + {{"name", "SubOS name", false}}, {{"--json", "Machine-readable output"}}, {}}, + {"doctor", "Check each SubOS: policy, entry on this host, policy package, sessions", {}, + {{"name", "SubOS name; omit for all", false}}, + {{"--json", "Machine-readable output"}, + {"--fix", "Re-install a selected policy package whose payload is missing"}}, {}}, + {"requests", "List what a SubOS's sandbox asked for and is waiting on", {}, + {{"name", "SubOS name", true}}, {{"--json", "One JSON object per request"}}, {}}, + {"approve", "Run a request a SubOS's sandbox is waiting on", {}, + {{"name", "SubOS name", true}, {"id", "Request id", true}}, {}, {}}, + {"deny", "Refuse a request a SubOS's sandbox is waiting on", {}, + {{"name", "SubOS name", true}, {"id", "Request id", true}}, {}, {}}, + {"report", "Summarise what happened in a SubOS: sessions, programs, permissions, files", {}, + {{"name", "SubOS name", false}}, + {{"--session ", "Only this session"}, {"--json", "Machine-readable output"}}, {}}, + {"ps", "List running SubOS sessions", {}, {{"name", "Optional SubOS name; omit to list this home's sessions", false}}, {{"--json", "One JSON object per session"}}, {}}, + {"log", "Show a SubOS's audit events", {}, {{"name", "SubOS name", false}}, + {{"--kind ", "Only this kind (ops, lifecycle, perm, exec, net, fs); repeatable"}, + {"--session ", "Only this session"}, + {"-n, --lines ", "Show the last N events (default 50)"}, + {"-f, --follow", "Keep printing new events"}, + {"--json", "One JSON object per event"}}, {}}, + {"rollback", "Move a root SubOS back to an earlier generation", {}, {{"name", "SubOS name", true}}, + {{"--to ", "This generation (default: the one before)"}, {"--list", "List the generations"}}, {}}, + {"boot", "Choose the SubOS a machine boots, from the next boot on", {}, {{"name", "SubOS name; omit to show", false}}, + {{"--once", "Only the next boot (a trial)"}, {"--fallback", "Boot it when the default fails"}, + {"--mark-good", "This boot worked: keep booting it"}, + {"--now", "Switch user space to it now, where the init can (no reboot)"}}, {}}, + {"export", "Export a root SubOS as a directory, a tarball or a disk image", {}, {{"name", "SubOS name", true}}, + {{"--rootfs ", "A root directory (chroot, bwrap, nspawn)"}, + {"--tar ", "A root tarball (docker import, podman import, wsl --import)"}, + {"--disk ", "An ext4 disk image to boot"}, {"--size ", "Disk size (default 4G)"}, + {"--with-data", "Include /root, /home, /var, /srv, /opt"}}, {}}, + {"diff", "Compare the packages of two SubOS", {}, {{"a", "SubOS", true}, {"b", "SubOS", true}}, {}, {}}, + {"pack", "Pack a SubOS's declaration as a subos-type xpkg", {}, {{"name", "SubOS name", true}}, + {{"--as ", "The package it becomes"}, {"--out ", "Where the tarball goes"}}, {}}, {"runtime", "Rebind a SubOS to another runtime", {}, {{"binding", "Runtime binding, e.g. glibc@2.39", true}, {"name", "Optional SubOS; omit for the current one", false}}, {}, {}}, }}, {"self", "Manage xlings itself", {}, {}, {}, { {"install", "Install xlings", {}, {}, {}, {}}, {"uninstall", "Uninstall xlings", {}, {}, {{"-y, --yes", "Skip confirmation"}, {"--keep-data", "Keep data"}, {"--dry-run", "Preview"}}, {}}, {"init", "Initialize directories", {}, {}, {}, {}}, - {"update", "Update xlings", {}, {}, {}, {}}, + {"update", "Update xlings", {}, {}, {{"--user", "Install into this home even when xlings is a system package's"}}, {}}, {"config", "Show configuration", {}, {}, {}, {}}, {"clean", "Clean cache", {}, {}, {{"--dry-run", "Preview"}}, {}}, {"migrate", "Migrate old layout", {}, {}, {}, {}}, - {"doctor", "Verify installation", {}, {}, {{"--deep", "Audit package payloads and runtime functionality"}, {"--scope ", "Limit deep payload/runtime audit to one local package coordinate"}, {"--subos ", "Check/repair one specific subos instead of the active one"}, {"--fix", "Repair (implies --deep; walks every subos that owns a finding)"}, {"--dry-run", "Preview repairs without changing detection depth"}, {"--show-ok", "Show all findings, including non-defects"}, {"--all", "Deprecated alias for --show-ok"}, {"--reset-metadata", "Discard unreadable metadata"}}, {}}, + {"doctor", "Verify installation", {}, {}, {{"--deep", "Audit package payloads and runtime functionality"}, {"--scope ", "Limit deep payload/runtime audit to one local package coordinate"}, {"--subos ", "Check/repair one specific subos instead of the active one"}, {"--fix", "Repair (implies --deep; walks every subos that owns a finding)"}, {"--dry-run", "Preview repairs without changing detection depth"}, {"--show-ok", "Show all findings, including non-defects"}, {"--all", "Deprecated alias for --show-ok"}, {"--reset-metadata", "Discard unreadable metadata"}, {"--isolation", "Check what this host can isolate SubOS sandboxes with; with --fix, install a root-owned bwrap and its AppArmor profile"}, {"--json", "Machine-readable output (with --isolation)"}}, {}}, }}, {"script", "Run an xlings script", {}, {{"script-file", "Script path", true}, {"args", "Script arguments", false, true}}, {}, {}}, {"interface", "Use the NDJSON interface", {}, {{"capability", "Capability name", false}}, {{"--args ", "Capability arguments"}, {"--args-file ", "Read capability arguments from a file"}, {"--list", "List capabilities"}, {"--version", "Show protocol version"}}, {}}, @@ -161,6 +243,12 @@ std::expected validate_( ParsedManualArgs parsed; for (std::size_t i = 0; i < argv.size(); ++i) { const auto token = argv[i]; + // `--` ends the options: what follows is a command line of its own + // (`subos exec -- sh -c ...`), taken verbatim. + if (token == "--") { + for (++i; i < argv.size(); ++i) parsed.positional.emplace_back(argv[i]); + break; + } if (!token.starts_with('-')) { parsed.positional.emplace_back(token); continue; diff --git a/src/core/cmdprocessor.cpp b/src/core/cmdprocessor.cpp index 1cd20cb47..3d736edb7 100644 --- a/src/core/cmdprocessor.cpp +++ b/src/core/cmdprocessor.cpp @@ -3,6 +3,7 @@ module; module xlings.core.cmdprocessor; +import xlings.core.xim.lua_boundary; import std; import xlings.core.log; import xlings.libs.json; @@ -267,11 +268,6 @@ CommandProcessor create_processor() { } namespace fs = std::filesystem; fs::path scriptFile = argv[2]; - auto execResult = mcpplibs::xpkg::create_executor(scriptFile); - if (!execResult) { - log::error("failed to load script: {}", execResult.error()); - return 1; - } mcpplibs::xpkg::ExecutionContext ctx; ctx.platform = std::string(platform::OS_NAME); ctx.bin_dir = Config::paths().binDir; @@ -281,6 +277,11 @@ CommandProcessor create_processor() { for (int i = 3; i < argc; ++i) { ctx.args.emplace_back(argv[i]); } + auto execResult = xim::lua_boundary::create_executor(scriptFile, ctx); + if (!execResult) { + log::error("failed to load script: {}", execResult.error()); + return 1; + } auto result = execResult->run_script(ctx); if (!result.success) { log::error("script error: {}", result.error); diff --git a/src/core/compact/git.cppm b/src/core/compact/git.cppm index bc1e5286e..0534d8046 100644 --- a/src/core/compact/git.cppm +++ b/src/core/compact/git.cppm @@ -99,7 +99,7 @@ inline void prepend_current_bin_dir_() { // index sync works with system git, XLINGS_COMPACT_GIT_BIN overrides, and // bootstrap alike. User/CI-set GIT_SSL_CAINFO always wins. inline void ensure_ca_env_() { -#if defined(__linux__) + if constexpr (!platform::is_linux) return; if (!env_or_empty_("GIT_SSL_CAINFO").empty()) return; auto bundle = resolve_ca_bundle([](const std::string& p) { std::error_code ec; @@ -109,7 +109,6 @@ inline void ensure_ca_env_() { platform::set_env_variable("GIT_SSL_CAINFO", bundle); log::debug("compact::git: GIT_SSL_CAINFO={} (/etc/ssl/cert.pem absent)", bundle); } -#endif } // #599: bound git's network operations. diff --git a/src/core/config.cpp b/src/core/config.cpp index 30fd12b1b..fd6864637 100644 --- a/src/core/config.cpp +++ b/src/core/config.cpp @@ -8,6 +8,7 @@ import xlings.libs.json; import xlings.core.log; import xlings.platform; import xlings.core.utils; +import xlings.core.home; import xlings.libs.tinyhttps; import xlings.core.xvm.types; import xlings.core.xvm.db; @@ -578,8 +579,10 @@ Config::Config() { // Owner-anchored shim dispatch: the dispatch home was chosen // before Config construction (main.cpp → resolve_dispatch_home). paths_.homeDir = *anchored; + paths_.homeSource = home::Source::Anchored; } else if (!envHome.empty()) { paths_.homeDir = envHome; + paths_.homeSource = home::Source::Env; } else { auto exePath = platform::get_executable_path(); auto exeParent = exePath.parent_path(); @@ -612,11 +615,22 @@ Config::Config() { if (isSelfContained) { paths_.homeDir = candidate; paths_.selfContained = true; + paths_.homeSource = home::Source::SelfContained; } else { paths_.homeDir = fs::path(platform::get_home_dir()) / ".xlings"; } } + // A system install's defaults (/etc/xlings/config.json, design §4): read + // first, so this home's own file overrides every key it sets. + { + const auto sys = home::read_system_config(); + if (auto it = sys.find("mirror"); it != sys.end() && it->is_string()) + mirror_ = it->get(); + if (auto it = sys.find("lang"); it != sys.end() && it->is_string()) + lang_ = it->get(); + } + auto configPath = paths_.homeDir / ".xlings.json"; // The cheap capture, not a full parse: it stops before `versions`, // which is ~90% of the bytes on a real home and which nothing here @@ -1031,6 +1045,12 @@ void Config::reload_state() { instance_().reload_state_(); } [[nodiscard]] const Config::PathInfo& Config::paths() { return instance_().paths_; } +[[nodiscard]] const home::HomeContext& Config::home_context() { + static const home::HomeContext ctx = + home::describe(paths().homeDir, paths().homeSource); + return ctx; +} + [[nodiscard]] std::string Config::display_path( const std::filesystem::path& p) { const auto& home = paths().homeDir; @@ -1245,12 +1265,12 @@ void Config::load_ui_prefs_from_json_(const nlohmann::json& json) { auto value = repo.url; if (value.rfind("file://", 0) == 0) { value.erase(0, 7); -#ifdef _WIN32 // file:///C:/path → /C:/path after erase; strip leading '/' before drive letter - if (value.size() >= 3 && value[0] == '/' && std::isalpha(static_cast(value[1])) && value[2] == ':') { - value.erase(0, 1); + if constexpr (platform::is_windows) { + if (value.size() >= 3 && value[0] == '/' && std::isalpha(static_cast(value[1])) && value[2] == ':') { + value.erase(0, 1); + } } -#endif return fs::path(value).lexically_normal(); } if (value.find("://") != std::string::npos) { @@ -1466,14 +1486,18 @@ void Config::save_versions() { if (!projectHomeDir.empty()) fs::create_directories(projectHomeDir); } - nlohmann::json json; - if (fs::exists(configPath)) { - try { - auto content = platform::read_file_to_string(configPath.string()); - json = nlohmann::json::parse(content, nullptr, false); - if (json.is_discarded()) json = nlohmann::json::object(); - } catch (...) { json = nlohmann::json::object(); } + // Refuse rather than replace (home::read_json_for_update): this file + // also holds the mirror, the subos registry and known projects, and + // writing `versions` into a blank object over an unparseable one would + // erase all of them. The same promise the workspace writer below makes. + auto read = home::read_json_for_update(configPath); + if (!read) { + log::warn("{} -- leaving it untouched rather than overwriting it; the " + "version change is not recorded. Run `xlings self doctor`.", + read.error()); + return; } + nlohmann::json json = std::move(*read); auto& versions = useGlobal ? self.globalVersions_ : self.projectVersions_; json["versions"] = xvm::versions_to_json(versions); @@ -1487,7 +1511,7 @@ void Config::save_versions() { // database, never an authority.) json["dbIndex"] = xvm::program_index_to_json(versions); } - platform::write_string_to_file(configPath.string(), json.dump(2)); + platform::write_file_atomic(configPath.string(), json.dump(2)); if (useGlobal) { // The DB file gets its own copy of the versions map, wrapped with a @@ -1895,35 +1919,9 @@ void Config::save_workspace() { // is never followed by a spurious write failure (issue #471). fs::create_directories(subosConfigPath.parent_path()); - nlohmann::json json = nlohmann::json::object(); - if (fs::exists(subosConfigPath)) { - bool parsedOk = false; - try { - auto content = platform::read_file_to_string(subosConfigPath.string()); - auto parsed = nlohmann::json::parse(content, nullptr, false); - if (!parsed.is_discarded() && parsed.is_object()) { - json = std::move(parsed); - parsedOk = true; - } - } catch (...) { /* parsedOk stays false */ } - if (!parsedOk) { - // Refuse rather than replace: an unreadable file is not an - // empty one. Blanking it here would discard `subos_info`, envs, - // and anything else this write does not itself own -- turning - // "this subos could not be read" into "this subos is now - // empty", the one failure mode a repair cannot walk back. - // profile::save_subos_workspace already makes this same - // promise for every OTHER subos's file (doctor's cross-subos - // repairs write through it); this is THIS subos's own writer, - // reached on nearly every install/remove/use, making it too. - log::warn( - "{}: could not be parsed as JSON; leaving it untouched " - "rather than overwriting it with a blank workspace. Run " - "`xlings self doctor` to see what needs repair.", - display_path(subosConfigPath)); - return; - } - } + const auto observed = home::read_json_for_update(subosConfigPath); + if (!observed) throw std::runtime_error(observed.error() + "; workspace left untouched"); + nlohmann::json json = *observed; // All four destination paths above target subos-side files // (named project subos, anonymous project subos / state file, or @@ -1961,7 +1959,7 @@ void Config::save_workspace() { } else { json["configured"] = sws.configured; } - platform::write_string_to_file(subosConfigPath.string(), json.dump(2)); + platform::write_file_atomic(subosConfigPath.string(), json.dump(2)); } // The map `save_workspace` writes for the scope this command acts on -- the diff --git a/src/core/config.cppm b/src/core/config.cppm index 3ff17aadc..b3d0e4787 100644 --- a/src/core/config.cppm +++ b/src/core/config.cppm @@ -6,11 +6,12 @@ import xlings.libs.json; import xlings.libs.tinyhttps; import xlings.core.xvm.types; import xlings.core.xvm.db; +import xlings.core.home; namespace xlings { export struct Info { - static constexpr std::string_view VERSION = "2026.10.8.1"; + static constexpr std::string_view VERSION = "2026.10.9.1"; static constexpr std::string_view REPO = "https://github.com/openxlings/xlings"; }; @@ -124,6 +125,9 @@ public: std::filesystem::path libDir; // $subosDir/lib std::string activeSubos; // effective active subos name bool selfContained = false; + // How homeDir was chosen; the rest of "what kind of home is this" + // (mode, layout) is home_context(). + home::Source homeSource = home::Source::Default; }; // Owner-anchored shim dispatch (0.4.48; see @@ -454,6 +458,10 @@ public: [[nodiscard]] static const PathInfo& paths(); + // Which home, in which deployment mode, at which layout (design §5): + // the one answer. Read from `.xlings-home` once per process. + [[nodiscard]] static const home::HomeContext& home_context(); + // Render a path for a human: anything under the xlings home shows as // `@xlings/...` instead of its absolute form. // diff --git a/src/core/confirm.cpp b/src/core/confirm.cpp index 1c76b7040..c995bb81a 100644 --- a/src/core/confirm.cpp +++ b/src/core/confirm.cpp @@ -1,37 +1,37 @@ module xlings.core.confirm; import std; +import xlings.guard; import xlings.runtime; namespace xlings::confirm { -Asked ask(EventStream& stream, std::string id, std::string question, - bool autoYes, std::string_view yesSpelling) { - if (autoYes) { - return { .outcome = Outcome::Confirmed, - .token = UserConfirmed(std::string(yesSpelling)) }; - } +guard::Reply StreamAsker::ask(const guard::Question& q) { PromptEvent req; - req.id = std::move(id); - req.question = std::move(question); - req.options = {"y", "n"}; - req.defaultValue = "n"; + req.id = q.id; + req.question = q.question; + req.options = q.options; + req.defaultValue = q.defaultValue; req.kind = PromptEvent::Kind::Confirm; return std::visit(EventStream::on{ - [](EventStream::Chosen&& c) -> Asked { - if (c.value == "y") { - return { .outcome = Outcome::Confirmed, - .token = UserConfirmed("terminal") }; - } - return { .outcome = Outcome::Declined }; + [](EventStream::Chosen&& c) -> guard::Reply { + return { .kind = guard::Reply::Kind::Chosen, .value = std::move(c.value) }; }, - [](EventStream::Cancelled&&) -> Asked { - return { .outcome = Outcome::Declined }; + [](EventStream::Cancelled&&) -> guard::Reply { + return { .kind = guard::Reply::Kind::Cancelled }; }, - [](EventStream::NobodyToAsk&&) -> Asked { - return { .outcome = Outcome::NobodyToAsk }; + [](EventStream::NobodyToAsk&&) -> guard::Reply { + return { .kind = guard::Reply::Kind::NobodyToAsk }; }, - }, stream.prompt(std::move(req))); + }, stream_.prompt(std::move(req))); +} + +Asked ask(EventStream& stream, std::string id, std::string question, + bool autoYes, std::string_view yesSpelling) { + StreamAsker asker(stream); + return guard::ask(asker, guard::Question{ .id = std::move(id), + .question = std::move(question) }, + autoYes, yesSpelling); } } // namespace xlings::confirm diff --git a/src/core/confirm.cppm b/src/core/confirm.cppm index 21c1bb0cf..41c192ec6 100644 --- a/src/core/confirm.cppm +++ b/src/core/confirm.cppm @@ -1,42 +1,37 @@ export module xlings.core.confirm; +export import xlings.guard; import std; import xlings.runtime; // Asking a person before doing something that cannot be undone, and carrying // the fact that they said yes. // +// The token and the asking moved to xlings.guard (`modules/runtime`), shared +// with the SubOS core; this module keeps the names every caller already uses +// and adapts the EventStream -- the interaction surface the CLI, the TUI and +// the interface all implement -- to guard's `Asker` port. +// // `UserConfirmed` can only be produced by `ask()`: a terminal answer of "y", // or the auto-confirm the caller was explicitly given (`-y` on the command // line, `"yes": true` over the interface). Functions that delete user data // take one by const reference, so a code path that never asked -- a repair, an -// upgrade, a rollback -- cannot call them at all. The rule "only a deletion the -// user initiated may remove user data" is then a compile error to break, not a -// convention to remember. +// upgrade, a rollback -- cannot call them at all. export namespace xlings::confirm { -struct Asked; +using guard::UserConfirmed; +using guard::Outcome; +using guard::Asked; -class UserConfirmed { +// The EventStream as an Asker: a PromptEvent, answered by whatever responder +// the stream has (terminal, interface client) or by nobody. +class StreamAsker final : public guard::Asker { public: - // "terminal", "-y", "yes:true" -- what the destructive log records. - [[nodiscard]] std::string_view how() const { return how_; } + explicit StreamAsker(EventStream& stream) : stream_(stream) {} + guard::Reply ask(const guard::Question& q) override; private: - explicit UserConfirmed(std::string how) : how_(std::move(how)) {} - std::string how_; - friend Asked ask(EventStream&, std::string, std::string, bool, std::string_view); -}; - -enum class Outcome { - Confirmed, // answered yes, or auto-confirmed by the caller - Declined, // answered no, or cancelled - NobodyToAsk, // no terminal, no responder, and no auto-confirm given -}; - -struct Asked { - Outcome outcome { Outcome::Declined }; - std::optional token; // set exactly when Confirmed + EventStream& stream_; }; // `autoYes` is the caller's explicit auto-confirm; `yesSpelling` is how that diff --git a/src/core/destructive_log.cpp b/src/core/destructive_log.cpp index 35ee0dbc9..c72431bbd 100644 --- a/src/core/destructive_log.cpp +++ b/src/core/destructive_log.cpp @@ -2,117 +2,21 @@ module xlings.core.destructive_log; import std; import xlings.core.config; -import xlings.libs.json; +import xlings.observe; namespace xlings::destructive_log { -namespace { - -std::string& command_() { - static std::string command; - return command; -} - -std::string utc_now_() { - const auto now = std::chrono::system_clock::to_time_t( - std::chrono::system_clock::now()); - char buf[32]; - std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", std::gmtime(&now)); - return buf; +void set_command(std::string command) { + observe::destructive::set_identity(std::string(Info::VERSION), std::move(command)); } -// Who started us, when the platform can say. A deletion driven by an agent, -// a shell script or a person looks identical in argv; the parent's name is -// the cheapest fact that tells them apart. -std::string parent_name_() { -#if defined(__linux__) - // Streamed, not read by size: every /proc file reports a size of 0. - const auto slurp = [](const char* path) { - std::ifstream in(path); - return std::string(std::istreambuf_iterator(in), {}); - }; - const auto stat = slurp("/proc/self/stat"); - // pid (comm) state ppid ... -- comm may contain spaces, so find the - // closing paren from the right. - const auto close = stat.rfind(')'); - if (close == std::string::npos) return {}; - std::istringstream rest(stat.substr(close + 1)); - std::string state, ppid; - rest >> state >> ppid; - if (ppid.empty()) return {}; - auto comm = slurp(("/proc/" + ppid + "/comm").c_str()); - while (!comm.empty() && (comm.back() == '\n' || comm.back() == '\r')) comm.pop_back(); - return comm; -#else - return {}; -#endif -} - -} // namespace - -void set_command(std::string command) { command_() = std::move(command); } - std::filesystem::path log_path() { - return Config::paths().homeDir / "logs" / "destructive.ndjson"; -} - -Size measure(const std::filesystem::path& root) { - namespace fs = std::filesystem; - Size size; - std::error_code ec; - const auto st = fs::symlink_status(root, ec); - if (ec || fs::is_symlink(st)) return size; - if (fs::is_regular_file(st)) { - size.files = 1; - size.bytes = fs::file_size(root, ec); - if (ec) size.bytes = 0; - return size; - } - if (!fs::is_directory(st)) return size; - for (auto it = fs::recursive_directory_iterator( - root, fs::directory_options::skip_permission_denied, ec); - !ec && it != std::default_sentinel; it.increment(ec)) { - std::error_code entryEc; - const auto es = it->symlink_status(entryEc); - if (entryEc || fs::is_symlink(es) || !fs::is_regular_file(es)) continue; - ++size.files; - const auto bytes = it->file_size(entryEc); - if (!entryEc) size.bytes += bytes; - } - return size; -} - -std::string human_bytes(std::uintmax_t bytes) { - static constexpr std::array units{"B", "KB", "MB", "GB", "TB"}; - auto value = static_cast(bytes); - std::size_t unit = 0; - while (value >= 1024.0 && unit + 1 < units.size()) { - value /= 1024.0; - ++unit; - } - if (unit == 0) return std::format("{} B", bytes); - return std::format("{:.1f} {}", value, units[unit]); + return observe::destructive::log_path(Config::paths().homeDir); } void record(const Entry& entry) noexcept { try { - nlohmann::json line = { - {"ts", utc_now_()}, - {"xlings", std::string(Info::VERSION)}, - {"op", entry.op}, - {"path", entry.path.string()}, - {"bytes", entry.bytes}, - {"files", entry.files}, - {"confirmedBy", entry.confirmedBy}, - {"command", command_()}, - {"parent", parent_name_()}, - }; - if (!entry.detail.empty()) line["detail"] = entry.detail; - const auto path = log_path(); - std::error_code ec; - std::filesystem::create_directories(path.parent_path(), ec); - std::ofstream out(path, std::ios::app); - if (out) out << line.dump() << '\n'; + observe::destructive::record(Config::paths().homeDir, entry); } catch (...) { // See the module comment: a lost line, never a failed operation. } diff --git a/src/core/destructive_log.cppm b/src/core/destructive_log.cppm index 65499b6db..0f3e1781b 100644 --- a/src/core/destructive_log.cppm +++ b/src/core/destructive_log.cppm @@ -1,6 +1,7 @@ export module xlings.core.destructive_log; import std; +export import xlings.observe; // A record of every destructive operation, one NDJSON line each, in // `/logs/destructive.ndjson`. @@ -12,20 +13,16 @@ import std; // one ran, when, from where, or how it was confirmed -- so the loss could not // be attributed to any of them. The next one can. // +// The record itself moved to xlings.observe.destructive (`modules/runtime`), +// where the SubOS audit is written with the same journal. This module binds +// it to THIS home and keeps the names every caller uses. +// // Recording never fails the operation it describes: a log that cannot be // written is a lost log line, not a refused deletion. export namespace xlings::destructive_log { -struct Entry { - std::string op; // "subos-remove", "self-install-overwrite", "gc", ... - std::filesystem::path path; - std::uintmax_t bytes { 0 }; - std::uintmax_t files { 0 }; - // How the person running it said yes: "terminal", "-y", "yes:true" -- or - // "automatic" for derived data a repair may remove without asking. - std::string confirmedBy; - std::string detail; -}; +using Entry = observe::destructive::Entry; +using Size = observe::destructive::Size; // The command line of this process, recorded with every entry. Set once by // main(); an entry written before it is set carries an empty command. @@ -38,10 +35,13 @@ void record(const Entry& entry) noexcept; // Total bytes and regular files under `root`, symlinks not followed. Shared // so the number in a confirmation prompt and the number in the log are // computed the same way. -struct Size { std::uintmax_t bytes { 0 }; std::uintmax_t files { 0 }; }; -[[nodiscard]] Size measure(const std::filesystem::path& root); +[[nodiscard]] inline Size measure(const std::filesystem::path& root) { + return observe::destructive::measure(root); +} // "2.5 GB", "812 KB", "0 B" -- for prompts and messages. -[[nodiscard]] std::string human_bytes(std::uintmax_t bytes); +[[nodiscard]] inline std::string human_bytes(std::uintmax_t bytes) { + return observe::destructive::human_bytes(bytes); +} } // namespace xlings::destructive_log diff --git a/src/core/entry_binary.cpp b/src/core/entry_binary.cpp index f789608f8..d94b70c51 100644 --- a/src/core/entry_binary.cpp +++ b/src/core/entry_binary.cpp @@ -36,17 +36,16 @@ import xlings.core.log; import xlings.core.version_order; import xlings.platform; import xlings.core.xvm.shim_identity; +import xlings.core.xvm.lock; +import xlings.libs.json; +import xlings.libs.sha256; namespace xlings::entry_binary { fs::path path_of(const fs::path& homeDir) { -#ifdef _WIN32 - auto p = homeDir / "bin" / "xlings.exe"; - if (!fs::exists(p)) p = homeDir / "xlings.exe"; -#else - auto p = homeDir / "bin" / "xlings"; - if (!fs::exists(p)) p = homeDir / "xlings"; -#endif + const auto name = std::string("xlings") + std::string(platform::exe_suffix); + auto p = homeDir / "bin" / name; + if (!fs::exists(p)) p = homeDir / name; return p; } @@ -120,4 +119,196 @@ bool replace_with(const fs::path& payloadBinary, const fs::path& entry, return true; } +namespace { +using Json = nlohmann::json; +std::expected present_(const fs::path& path) { + std::error_code ec; + const auto status = fs::symlink_status(path, ec); + if (status.type() == fs::file_type::not_found && (!ec || ec == std::errc::no_such_file_or_directory)) return false; + if (ec) return std::unexpected(path.string() + ": cannot inspect managed entry: " + ec.message()); + return true; +} +std::expected digest_(const fs::path& path) { + std::error_code ec; + if (!fs::is_regular_file(fs::symlink_status(path, ec)) || ec) + return std::unexpected(path.string() + ": managed entry must be a readable regular file"); + const auto digest = sha256::hex_file(path); + if (!digest) return std::unexpected(path.string() + ": cannot hash managed entry"); + return *digest; +} +std::expected read_record_(const fs::path& path) { + if (auto digest = digest_(path); !digest) return std::unexpected(digest.error()); + std::ifstream in(path, std::ios::binary); + auto doc = Json::parse(in, nullptr, false); + if (doc.is_discarded() || !doc.is_object()) return std::unexpected(path.string() + ": invalid managed entry record"); + return doc; +} +std::expected publish_record_(const fs::path& path, const Json& doc, + const std::optional& previous) { + auto exists = present_(path); + if (!exists) return std::unexpected(exists.error()); + if (*exists != previous.has_value()) return std::unexpected(path.string() + ": managed entry record ownership changed"); + if (previous) { + auto current = read_record_(path); + if (!current || *current != *previous) return std::unexpected(path.string() + ": managed entry record changed"); + } + try { platform::write_file_atomic(path.string(), doc.dump(2)); } + catch (const std::exception& error) { return std::unexpected(error.what()); } + return {}; +} +bool hash_(const Json& value) { + if (!value.is_string()) return false; + const auto text = value.get(); + return text.size() == 64 && std::ranges::all_of(text, [](char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); }); +} +} + +std::expected refresh_mirror(const fs::path& selected, const ManagedMirror& mirror) { + try { + std::error_code ec; + const auto owner = fs::canonical(mirror.ownerHome, ec); + if (ec || owner != mirror.ownerHome.lexically_normal()) return std::unexpected("managed entry owner must be canonical"); + const auto home = fs::canonical(mirror.privateHome, ec); + if (ec || home != mirror.privateHome.lexically_normal() || home != owner / "domains/xlings/private") + return std::unexpected("managed entry private home escapes its owner"); + for (const auto& dir : {owner / "domains", owner / "domains/xlings", home, home / "bin"}) { + if (!fs::is_directory(fs::symlink_status(dir, ec)) || ec) + return std::unexpected(dir.string() + ": managed entry parent is redirected or unreadable"); + } + const auto marker = home / ".xlings-domain.json"; + auto domain = read_record_(marker); + if (!domain || !domain->contains("owner_home") || (*domain)["owner_home"] != owner.generic_string() || + !domain->contains("physical_home") || (*domain)["physical_home"] != home.generic_string() || + !domain->contains("private_home") || (*domain)["private_home"] != true) + return std::unexpected(marker.string() + ": missing or contradictory managed domain authority"); + const auto markerHash = digest_(marker); + if (!markerHash) return std::unexpected(markerHash.error()); + const auto source = fs::canonical(selected, ec); + if (ec) return std::unexpected(selected.string() + ": cannot resolve selected dispatcher"); + const auto sourceHash = digest_(source); + if (!sourceHash) return std::unexpected(sourceHash.error()); + xvm::ShimClassifier classifier{fs::path{}}; + const auto identity = classifier.classify(source); + if (identity.state != xvm::ShimState::Stale || !identity.handoffCapable) + return std::unexpected(source.string() + ": selected dispatcher is not a marked xlings build"); + auto lock = xvm::acquire_state_lock(home); + if (!lock) return std::unexpected(lock.error()); + const auto entry = home / "bin/xlings"; + const auto proof = home / ".xlings-domain-entry.json"; + auto entryExists = present_(entry), proofExists = present_(proof); + if (!entryExists || !proofExists) return std::unexpected(!entryExists ? entryExists.error() : proofExists.error()); + std::optional actual; + if (*entryExists) { + auto digest = digest_(entry); + if (!digest) return std::unexpected(digest.error()); + actual = *digest; + } + std::optional record; + bool recovered = false; + if (*proofExists) { + auto doc = read_record_(proof); + if (!doc) return std::unexpected(doc.error()); + record = std::move(*doc); + if ((*record).value("schema", 0) == 1) { + if (!actual || !record->contains("source") || !(*record)["source"].is_string()) + return std::unexpected(proof.string() + ": legacy entry ownership cannot be observed"); + auto originalHash = digest_(fs::path((*record)["source"].get())); + if (!originalHash || *originalHash != *actual) + return std::unexpected(entry.string() + ": legacy dispatcher differs from its recorded source; preserved"); + } else if ((*record).value("schema", 0) == 2) { + if (record->value("owner_home", "") != owner.generic_string() || + record->value("physical_home", "") != home.generic_string() || + record->value("marker_sha256", "") != *markerHash || + record->value("role", "") != "managed-mirror" || !record->contains("sha256")) + return std::unexpected(proof.string() + ": managed entry ownership changed; preserved"); + const bool pending = record->value("state", "") == "pending"; + const auto& oldHash = (*record)["sha256"]; + if ((!oldHash.is_null() && !hash_(oldHash)) || (!pending && !hash_(oldHash)) || + (!pending && record->value("state", "") != "ready") || + (pending && (!record->contains("pending_sha256") || !hash_((*record)["pending_sha256"])))) + return std::unexpected(proof.string() + ": invalid managed entry journal"); + const bool oldMatches = actual ? oldHash == *actual : oldHash.is_null(); + const bool newMatches = pending && actual && (*record)["pending_sha256"] == *actual; + if (!oldMatches && !newMatches) + return std::unexpected(entry.string() + ": dispatcher was changed outside its managed writer; preserved"); + recovered = pending; + } else return std::unexpected(proof.string() + ": unknown managed entry schema; preserved"); + } else if (actual) return std::unexpected(entry.string() + ": dispatcher ownership is unknown; preserved"); + + Json ready = record.value_or(Json::object()); + ready["schema"] = 2; + ready["owner_home"] = owner.generic_string(); + ready["physical_home"] = home.generic_string(); + ready["marker_sha256"] = *markerHash; + ready["role"] = "managed-mirror"; + ready["source"] = source.generic_string(); + ready["state"] = "ready"; + ready["sha256"] = actual ? Json(*actual) : Json{}; + ready.erase("pending_sha256"); + ready.erase("pending_staging"); + // The existing executable publisher uses this fixed sibling. A pre-existing + // unknown file cannot be pre-cleaned merely because we own the entry. + const auto staging = fs::path(entry.string() + ".xlings.new"); + auto stageExists = present_(staging); + if (!stageExists) return std::unexpected(stageExists.error()); + if (*stageExists) { + const auto stageHash = digest_(staging); + if (!record || record->value("state", "") != "pending" || + record->value("pending_staging", "") != staging.generic_string() || !stageHash || + !record->contains("pending_sha256") || (*record)["pending_sha256"] != *stageHash) + return std::unexpected(staging.string() + ": unknown entry staging file preserved"); + if (!fs::remove(staging, ec) || ec) + return std::unexpected(staging.string() + ": cannot retire proved interrupted entry staging"); + } + if (actual && *actual == *sourceHash) { + if (!record || *record != ready) { + auto saved = publish_record_(proof, ready, record); + if (!saved) return std::unexpected(saved.error()); + } + return recovered; + } + struct Snapshot { + fs::path directory; + ~Snapshot() { + if (directory.empty()) return; + std::error_code ignored; + fs::remove(directory / "source", ignored); + fs::remove(directory, ignored); + } + } snapshot; + for (int attempt = 0; attempt < 100; ++attempt) { + auto candidate = home / (".xlings-domain-source-" + std::to_string(std::random_device{}())); + if (fs::create_directory(candidate, ec)) { snapshot.directory = std::move(candidate); break; } + if (ec && ec != std::errc::file_exists) return std::unexpected("cannot reserve dispatcher source snapshot: " + ec.message()); + } + if (snapshot.directory.empty()) return std::unexpected("cannot reserve dispatcher source snapshot"); + fs::permissions(snapshot.directory, fs::perms::owner_all, fs::perm_options::replace, ec); + if (ec) return std::unexpected("cannot protect dispatcher source snapshot: " + ec.message()); + const auto pinned = snapshot.directory / "source"; + if (!fs::copy_file(source, pinned, fs::copy_options::none, ec) || ec) + return std::unexpected("cannot capture selected dispatcher: " + ec.message()); + const auto pinnedHash = digest_(pinned); + if (!pinnedHash || *pinnedHash != *sourceHash) + return std::unexpected("selected dispatcher changed during capture; old entry preserved"); + const auto pinnedIdentity = classifier.classify(pinned); + if (pinnedIdentity.state != xvm::ShimState::Stale || !pinnedIdentity.handoffCapable) + return std::unexpected("captured dispatcher is not a marked xlings build; old entry preserved"); + Json pending = ready; + pending["state"] = "pending"; + pending["pending_sha256"] = *sourceHash; + pending["pending_staging"] = staging.generic_string(); + if (auto saved = publish_record_(proof, pending, record); !saved) return std::unexpected(saved.error()); + if (!replace_with(pinned, entry, "managed domain dispatcher", version_of(pinned))) + return std::unexpected(entry.string() + ": managed dispatcher publication failed; journal retained"); + auto published = digest_(entry); + if (!published || *published != *sourceHash) + return std::unexpected(entry.string() + ": managed dispatcher changed during publication; journal retained"); + ready["sha256"] = *sourceHash; + if (auto saved = publish_record_(proof, ready, pending); !saved) return std::unexpected(saved.error()); + return true; + } catch (const std::exception& error) { + return std::unexpected(std::string("managed dispatcher refresh refused: ") + error.what()); + } +} + } diff --git a/src/core/entry_binary.cppm b/src/core/entry_binary.cppm index 1981ebfcb..db61fa202 100644 --- a/src/core/entry_binary.cppm +++ b/src/core/entry_binary.cppm @@ -66,4 +66,11 @@ std::string version_of(const fs::path& entry); bool replace_with(const fs::path& payloadBinary, const fs::path& entry, std::string_view coordinate, std::string_view toVersion); +struct ManagedMirror { + fs::path ownerHome; + fs::path privateHome; +}; +// A managed domain dispatcher follows its caller; only recorded bytes may be replaced. +std::expected refresh_mirror(const fs::path& source, const ManagedMirror& mirror); + } // namespace xlings::entry_binary diff --git a/src/core/home/home.cpp b/src/core/home/home.cpp new file mode 100644 index 000000000..1132d11f1 --- /dev/null +++ b/src/core/home/home.cpp @@ -0,0 +1,273 @@ +module xlings.core.home; + +import std; +import xlings.libs.json; +import xlings.platform; +import xlings.core.home_identity; +import xlings.core.home.domain_producer_source; + +namespace xlings::home { + +std::string_view to_string(Mode m) { + switch (m) { + case Mode::Custom: return "custom"; + case Mode::Portable: return "portable"; + case Mode::System: return "system"; + case Mode::Multi: return "multi"; + case Mode::Root: return "root"; + default: return "user"; + } +} + +std::optional mode_from_string(std::string_view s) { + for (auto m : {Mode::User, Mode::Custom, Mode::Portable, Mode::System, Mode::Multi, Mode::Root}) + if (to_string(m) == s) return m; + return std::nullopt; +} + +std::string_view to_string(Source s) { + switch (s) { + case Source::Anchored: return "anchored"; + case Source::Env: return "env"; + case Source::SelfContained: return "self-contained"; + default: return "default"; + } +} + +namespace { + +std::optional read_marker(const fs::path& home) { + std::error_code ec; + auto path = home_identity::marker_path(home); + if (!fs::is_regular_file(path, ec)) return std::nullopt; + std::ifstream in(path, std::ios::binary); + std::string text{std::istreambuf_iterator(in), {}}; + auto j = nlohmann::json::parse(text, nullptr, false); + if (j.is_discarded() || !j.is_object()) return std::nullopt; + return j; +} + +bool path_is_under(const fs::path& p, std::string_view prefix) { + auto s = p.lexically_normal().generic_string(); + return s == prefix || s.starts_with(std::string(prefix) + "/"); +} + +} // namespace + +Mode infer_mode(const fs::path& home, Source source) { + if (source == Source::SelfContained) return Mode::Portable; + auto user_default = fs::path(platform::get_home_dir()) / ".xlings"; + if (home.lexically_normal() == user_default.lexically_normal()) return Mode::User; + // Where distributions install shared software. A home there that this + // user cannot write is a system install whatever it was called. + if constexpr (platform::is_posix) { + for (auto prefix : {"/usr", "/opt", "/var/lib"}) { + if (path_is_under(home, prefix)) return Mode::System; + } + } + return Mode::Custom; +} + +Entry describe_entry(const fs::path& exe, const fs::path& home) { + Entry e{ .path = exe }; + std::error_code ec; + auto h = fs::weakly_canonical(home, ec); + if (ec) h = home; + if (exe.empty() || path_is_under(exe, h.lexically_normal().generic_string())) return e; + // Root's file where packages install, outside the home, is a package's + // whoever runs it: its package manager replaces it, and nothing else + // should. Both conditions: root's own build in a work tree is not one. + if constexpr (platform::is_posix) { + const bool packaged = std::ranges::any_of( + std::array{"/usr", "/opt", "/bin", "/sbin", "/snap"}, + [&](const char* prefix) { return path_is_under(exe, prefix); }); + const auto own = platform::file_ownership(exe); + e.system = packaged && own && own->uid == 0; + } + return e; +} + +namespace { + +std::string env_or_empty(const char* name) { + const char* v = std::getenv(name); + return v ? std::string(v) : std::string{}; +} + +fs::path program_data() { + auto v = env_or_empty("ProgramData"); + return v.empty() ? fs::path("C:/ProgramData") : fs::path(v); +} + +} // namespace + +fs::path system_config_path() { + if (auto v = env_or_empty("XLINGS_SYSTEM_CONFIG"); !v.empty()) return v; + if constexpr (platform::is_windows) return program_data() / "xlings" / "config.json"; + else return "/etc/xlings/config.json"; +} + +nlohmann::json read_system_config() { + std::error_code ec; + const auto path = system_config_path(); + if (!fs::is_regular_file(path, ec)) return nlohmann::json::object(); + std::ifstream in(path, std::ios::binary); + auto j = nlohmann::json::parse(in, nullptr, false); + return j.is_object() ? j : nlohmann::json::object(); +} + +std::expected shares_store(const fs::path& home) { + auto marker = read_json_for_update(home_identity::marker_path(home)); + if (!marker) return std::unexpected(marker.error()); + if (marker->empty()) return false; + auto mode = marker->find("mode"); + if (mode == marker->end()) return false; + if (!mode->is_string() || !mode_from_string(mode->get())) + return std::unexpected(home.string() + ": invalid declared home mode"); + const auto declared = mode->get(); + const auto layout = marker->find("layout"); + if (declared == "root") { + std::string rootLayout; + if (layout != marker->end() && layout->is_string()) rootLayout = layout->get(); + else if (layout != marker->end() && layout->is_number_integer() && *layout == 2 && + marker->contains("root_layout") && (*marker)["root_layout"].is_string()) + rootLayout = (*marker)["root_layout"].get(); // Known exported-image format before domain producers. + if (rootLayout != "multi" && rootLayout != "single") + return std::unexpected(home.string() + ": root layout must be single or multi"); + if (marker->contains("root_layout") && (*marker)["root_layout"] != rootLayout) + return std::unexpected(home.string() + ": contradictory root layout declarations"); + return rootLayout == "multi"; + } + if (layout != marker->end() && (!layout->is_number_integer() || *layout < 1 || *layout > kLayout)) + return std::unexpected(home.string() + ": unsupported home layout"); + return declared == "multi"; +} + +std::expected, std::string> read_system_layer() { + auto source = domain_producer_source::read(); + if (!source) return std::unexpected(source.error()); + if (*source) return std::optional{(**source).recordedHome}; + fs::path layer = env_or_empty("XLINGS_SYSTEM_LAYER"); + if (layer.empty()) { + if constexpr (platform::is_windows) layer = program_data() / "xlings" / "home"; + else layer = "/xlings"; + } + auto shared = shares_store(layer); + if (!shared) return std::unexpected(shared.error()); + if (!*shared) return std::optional{}; + std::error_code ec; + auto canonical = fs::canonical(layer, ec); + if (ec) return std::unexpected(layer.string() + ": " + ec.message()); + return std::optional{std::move(canonical)}; +} + +std::optional system_layer() { + auto layer = read_system_layer(); + return layer ? *layer : std::nullopt; +} + +HomeContext describe(const fs::path& home, Source source) { + HomeContext ctx{ .home = home, .source = source }; + ctx.mode = infer_mode(home, source); + if (auto marker = read_marker(home)) { + if (auto it = marker->find("mode"); it != marker->end() && it->is_string()) { + if (auto m = mode_from_string(it->get())) { + ctx.mode = *m; + ctx.modeDeclared = true; + } + } + if (auto it = marker->find("layout"); it != marker->end() && it->is_number_integer()) + ctx.layout = it->get(); + if (auto it = marker->find("layout"); ctx.mode == Mode::Root && + it != marker->end() && it->is_string()) ctx.rootLayout = it->get(); + if (ctx.mode == Mode::Root && ctx.rootLayout.empty() && marker->contains("layout") && + (*marker)["layout"].is_number_integer() && (*marker)["layout"] == 2 && + marker->contains("root_layout") && (*marker)["root_layout"].is_string()) + ctx.rootLayout = (*marker)["root_layout"].get(); + if (auto it = marker->find("id"); it != marker->end() && it->is_string()) + ctx.id = it->get(); + } + return ctx; +} + +std::expected declare(const fs::path& home, + std::optional mode, + std::optional layout) { + auto marker = read_json_for_update(home_identity::marker_path(home)); + if (!marker) return std::unexpected(marker.error()); + if (marker->empty()) return std::unexpected("no .xlings-home marker in " + home.string()); + if (marker->contains("mode") && !(*marker)["mode"].is_string()) + return std::unexpected("invalid home mode in " + home.string()); + bool changed = false; + if (marker->value("mode", std::string()) == "root") { + auto valid = shares_store(home); + if (!valid) return std::unexpected(valid.error()); + if (marker->contains("layout") && (*marker)["layout"].is_number_integer() && (*marker)["layout"] == 2) { + (*marker)["layout"] = (*marker)["root_layout"]; + changed = true; + } + } + if (mode) { + auto want = std::string(to_string(*mode)); + if (marker->value("mode", std::string()) != want) { + (*marker)["mode"] = want; + changed = true; + } + } + if (layout && marker->value("mode", std::string()) != "root") { + auto prior = marker->find("layout"); + if (prior != marker->end() && !prior->is_number_integer()) + return std::unexpected("invalid home layout in " + home.string()); + int have = marker->value("layout", 1); + if (*layout > have) { // one way + (*marker)["layout"] = *layout; + changed = true; + } + } + if (!changed) return {}; + auto path = home_identity::marker_path(home); + try { + platform::write_file_atomic(path.string(), marker->dump(2) + "\n"); + } catch (const std::exception& error) { + return std::unexpected(path.string() + ": " + error.what()); + } + return {}; +} + +std::expected read_json_for_update(const fs::path& path) { + std::error_code ec; + auto status = fs::symlink_status(path, ec); + if (status.type() == fs::file_type::not_found && + (!ec || ec == std::errc::no_such_file_or_directory)) return nlohmann::json::object(); + if (ec) return std::unexpected(path.string() + ": " + ec.message()); + if (!fs::is_regular_file(path, ec) || ec) + return std::unexpected(path.string() + ": not a readable regular JSON file"); + std::ifstream in(path, std::ios::binary); + if (!in) return std::unexpected(path.string() + ": cannot be read"); + std::string text{std::istreambuf_iterator(in), {}}; + if (in.bad()) return std::unexpected(path.string() + ": read failed"); + auto j = nlohmann::json::parse(text, nullptr, false); + if (j.is_discarded() || !j.is_object()) + return std::unexpected(path.string() + ": could not be parsed as a JSON object"); + return j; +} + +bool is_read_only_command(std::span argv) { + if (argv.empty()) return true; + const auto first = argv[0]; + static constexpr std::array reads{ + "-h", "--help", "help", "-v", "--version", "version", + "list", "ls", "info", "i", "search", "agent"}; + if (std::ranges::find(reads, first) != reads.end()) return true; + if (first == "subos" || first == "self") { + if (argv.size() < 2) return first == "subos"; + const auto sub = argv[1]; + if (first == "subos") + return sub == "list" || sub == "ls" || sub == "info" || sub == "i" + || sub == "status" || sub == "log" || sub == "ps" || sub == "report"; + return sub == "doctor" && std::ranges::find(argv, std::string_view("--fix")) == argv.end(); + } + return false; +} + +} // namespace xlings::home diff --git a/src/core/home/home.cppm b/src/core/home/home.cppm new file mode 100644 index 000000000..3cddfa0cd --- /dev/null +++ b/src/core/home/home.cppm @@ -0,0 +1,114 @@ +export module xlings.core.home; + +import std; +import xlings.libs.json; +import xlings.platform; +import xlings.core.home_identity; + +// Which home this run uses, and what kind of home it is (design §3-§6). +// +// HomeContext is the one answer to "which home, in which deployment mode, at +// which layout". The home's path is still resolved by Config (anchored shim +// home, then XLINGS_HOME, then a self-contained layout, then ~/.xlings); this +// module answers what Config did not know: the MODE the home was deployed in +// and the LAYOUT version it carries, both DECLARED in `.xlings-home`. +// +// mode user ~/.xlings of one user (the default install) +// custom any directory a user chose (XLINGS_HOME) +// portable self-contained: the binary's own directory +// system installed by a system package (read by many users) +// multi a system package manager home with per-user layers +// +// A marker written before modes existed has none; the mode is then inferred +// from where the home is, ONCE, and reported as inferred. Declaring is what +// installers and `self init` do from now on. +// +// layout 1 everything up to 2026.10.4 +// 2 adds config/subos/, logs/subos/, run/subos/, state/ -- only +// ADDS directories, which is why an older client is unaffected +// +// A home whose layout is higher than this client knows is read and never +// written (`writable() == false`): a newer client changed something this one +// cannot see, and writing would undo it without knowing. +export namespace xlings::home { + +namespace fs = std::filesystem; + +inline constexpr int kLayout = 2; + +enum class Mode { User, Custom, Portable, System, Multi, Root }; +enum class Source { Anchored, Env, SelfContained, Default }; + +std::string_view to_string(Mode m); +std::optional mode_from_string(std::string_view s); +std::string_view to_string(Source s); + +struct HomeContext { + fs::path home; + Source source { Source::Default }; + Mode mode { Mode::User }; + bool modeDeclared { false }; // from .xlings-home, not inferred + int layout { 1 }; + std::string rootLayout; // root deployments: single or multi + std::string id; // the marker's id, when there is one + + bool writable() const { return layout <= kLayout; } + bool user_owned() const { return mode == Mode::User || mode == Mode::Custom || mode == Mode::Portable; } +}; + +// Read `.xlings-home` and complete the context for a home Config resolved. +HomeContext describe(const fs::path& home, Source source); + +// Declare the mode and/or raise the layout in `.xlings-home`, keeping every +// key this client does not know. The layout only ever moves up. A home with +// no marker is left alone (home_identity writes the marker). +std::expected declare(const fs::path& home, + std::optional mode, + std::optional layout); + +// The mode a home without a declaration is taken to have. +Mode infer_mode(const fs::path& home, Source source); + +// ── What a system install provides (design §4, deployment S and M) ── +// +// S: the entry is a system package's (/usr/bin/xlings, root-owned). Each user +// still has a home of their own; the system adds root-owned components and +// a configuration file, and updates the entry through its package manager. +// M: S plus a root-owned system LAYER, a home a package manager maintains for +// every user (/xlings), declared `"mode": "multi"` in its .xlings-home. +// Borrowing reads its registration and checked runtime dependency closure. + +struct Entry { + fs::path path; // the binary this process is + bool system { false }; // root-owned, outside the home, not writable here +}; + +// The running binary as a deployment: is it the home's own, or a system +// package's that `self update` must not try to replace? +Entry describe_entry(const fs::path& exe, const fs::path& home); + +// /etc/xlings/config.json (%ProgramData%\xlings\config.json): defaults a +// home's own .xlings.json overrides. XLINGS_SYSTEM_CONFIG names another file. +fs::path system_config_path(); +// The file as an object; missing or unreadable is empty, never an error. +nlohmann::json read_system_config(); + +// The system layer when one is installed: /xlings (%ProgramData%\xlings\home) +// or XLINGS_SYSTEM_LAYER, with mode=multi or mode=root/layout=multi. +std::optional system_layer(); +std::expected, std::string> read_system_layer(); +// Whether this home publishes its store to readers outside its own scopes. +std::expected shares_store(const fs::path& home); + +// For read-only commands only: the commands an unknown-layout home allows. +bool is_read_only_command(std::span argv); + +// The writer rule (design §25 "schema"): read a JSON document in order to +// update it. A missing file is a new, empty document. A file that exists and +// does not parse as an object is an ERROR -- not an empty document -- and the +// caller must not write: replacing it would turn "could not be read" into +// "is now empty", which no repair can walk back. Writers update the object +// they read, so every key they do not know survives. +std::expected read_json_for_update(const fs::path& path); + +} // namespace xlings::home diff --git a/src/core/log.cpp b/src/core/log.cpp index 37c701575..ac03cfd44 100644 --- a/src/core/log.cpp +++ b/src/core/log.cpp @@ -83,12 +83,7 @@ std::string colored_err_(palette::Rgb c, const char* text) { std::string timestamp_() { auto now = std::chrono::system_clock::now(); auto tt = std::chrono::system_clock::to_time_t(now); - std::tm tm; -#if defined(_WIN32) - ::localtime_s(&tm, &tt); -#else - ::localtime_r(&tt, &tm); -#endif + const std::tm tm = platform::local_time(tt); char buf[32]; std::snprintf(buf, sizeof(buf), "%04d-%02d-%02d %02d:%02d:%02d", tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, diff --git a/src/core/profile.cpp b/src/core/profile.cpp index 5fcd06b08..081059d53 100644 --- a/src/core/profile.cpp +++ b/src/core/profile.cpp @@ -9,6 +9,8 @@ import std; import xlings.core.destructive_log; import xlings.core.config; import xlings.core.home_config; +import xlings.core.home; +import xlings.core.home.layers; import xlings.libs.json; import xlings.core.log; import xlings.platform; @@ -366,6 +368,15 @@ std::vector find_subos_pinning_version( } int gc(const fs::path& xlingsHome, bool dryRun) { + const auto shared = home::shares_store(xlingsHome); + if (!shared) { + std::println("[xlings:store] gc refused: {}", shared.error()); + return 1; + } + if (*shared) { + std::println("[xlings:store] shared store retained: read-only borrowers may reference its payloads"); + return 0; + } auto references = collect_subos_references_(xlingsHome); if (!references) { std::println("[xlings:store] gc refused: {}", references.error()); @@ -376,6 +387,15 @@ int gc(const fs::path& xlingsHome, bool dryRun) { const auto& referenced = *references; auto pkgDir = xlingsHome / "data" / "xpkgs"; + std::error_code inspected; + for (const auto& directory : {pkgDir.parent_path(), pkgDir}) { + const auto status = fs::symlink_status(directory, inspected); + if ((inspected && inspected != std::errc::no_such_file_or_directory) + || (fs::exists(status) && !fs::is_directory(status))) { + std::println("[xlings:store] gc refused: store ownership cannot be proved at {}", directory.string()); + return 1; + } + } if (!fs::exists(pkgDir)) { std::println("[xlings:store] xpkgs not found, nothing to gc"); return 0; @@ -385,9 +405,12 @@ int gc(const fs::path& xlingsHome, bool dryRun) { int removedCount = 0; for (auto& pkgEntry : platform::dir_entries(pkgDir)) { + if (fs::is_symlink(pkgEntry.symlink_status())) continue; if (!pkgEntry.is_directory()) continue; auto pkgName = pkgEntry.path().filename().string(); for (auto& verEntry : platform::dir_entries(pkgEntry)) { + if (fs::is_symlink(verEntry.symlink_status()) + || !home::layers::owns_payload(xlingsHome, verEntry.path())) continue; if (!verEntry.is_directory()) continue; auto ver = verEntry.path().filename().string(); auto key = pkgName + "/" + ver; diff --git a/src/core/subos/audit.cpp b/src/core/subos/audit.cpp new file mode 100644 index 000000000..ca78bba9a --- /dev/null +++ b/src/core/subos/audit.cpp @@ -0,0 +1,316 @@ +// `subos requests / approve / deny / report / ps / log`: what happened in an +// instance, read and answered from outside it (module xlings.core.subos). +module xlings.core.subos; + +import std; +import xlings.core.config; +import xlings.core.home_config; +import xlings.libs.json; +import xlings.core.log; +import xlings.platform; +import xlings.runtime; +import xlings.core.utils; +import xlings.core.xself; +import xlings.core.xvm.types; +import xlings.core.xvm.db; +import xlings.core.xim.commands; +import xlings.subos.gpu; +import xlings.subos.graphics; +import xlings.core.subos.sandbox; +import xlings.subos.manifest; +import xlings.cli.spec; +import xlings.i18n; +import xlings.core.confirm; +import xlings.core.destructive_log; +import xlings.subos.userdata; +import xlings.subos.model; +import xlings.core.subos.ports; +import xlings.subos.session; +import xlings.subos.policy; +import xlings.subos.policy_store; +import xlings.subos.broker; +import xlings.observe; +import xlings.core.home; +import xlings.libs.sha256; +import xlings.core.version_order; + +namespace xlings::subos { + +// `subos requests / approve / deny [id]`: what a sandbox asked for +// under fetch=ask (design §8.2). Only from outside -- the sandbox cannot see +// the queue, and approval never travels through a channel it can forge. +int run_requests_(std::string_view sub, int argc, char* argv[], EventStream& stream, + const std::function& usageError) { + std::vector pos; + bool json = false; + for (int i = 3; i < argc; ++i) { + std::string_view a = argv[i]; + if (a == "--json") json = true; + else if (!a.empty() && a[0] != '-') pos.emplace_back(a); + else { usageError(std::format("unknown option for `xlings subos {}`: {}", sub, a)); return 1; } + } + const std::size_t want = sub == "requests" ? 1 : 2; + if (pos.size() != want) { + usageError(sub == "requests" ? "usage: xlings subos requests " + : std::format("usage: xlings subos {} ", sub)); + return 1; + } + if (utils::get_env_or_default("XLINGS_SUBOS_MODE") == "sandbox") { + stream.emit(ErrorEvent{ .code = ErrorCode::Permission, + .message = "E_PERMISSION: requests are answered by the owner, outside the sandbox", + .recoverable = false }); + return 13; + } + const auto home = home_view(); + const auto& name = pos[0]; + if (sub == "requests") { + auto reqs = subos::broker::pending(home, name); + if (json) { + for (auto& r : reqs) + std::println(std::cout, "{}", nlohmann::json{{"id", r.id}, {"argv", r.argv}, {"created", r.created}, + {"reason", r.reason}}.dump()); + return 0; + } + if (reqs.empty()) { log::info("'{}' has no pending requests", name); return 0; } + for (auto& r : reqs) { + std::string line; + for (auto& a : r.argv) line += " " + a; + std::println(std::cout, "{} {} xlings{} ({})", r.id, r.created, line, r.reason); + } + return 0; + } + auto r = subos::broker::take(home, name, pos[1]); + if (!r) { + stream.emit(ErrorEvent{ .code = ErrorCode::NotFound, + .message = std::format("no pending request {} for '{}'", pos[1], name), + .recoverable = false, .hint = "xlings subos requests " + name }); + return 1; + } + const auto logfile = home.logs_dir(name) / "events.ndjson"; + if (sub == "deny") { + observe::append(logfile, observe::Event{ .kind = observe::Kind::Perm, + .fields = {{"event", "request-denied"}, {"instance", name}, {"request", r->id}, + {"program", r->argv.empty() ? "" : r->argv[0]}}}); + log::info("denied {}", r->id); + return 0; + } + // Approved: run it for that instance, as the broker would have. + observe::append(logfile, observe::Event{ .kind = observe::Kind::Perm, + .fields = {{"event", "request-approved"}, {"instance", name}, {"request", r->id}, + {"program", r->argv.empty() ? "" : r->argv[0]}}}); + std::vector full{ platform::get_executable_path().string() }; + full.insert(full.end(), r->argv.begin(), r->argv.end()); + if (std::ranges::find(full, std::string("-y")) == full.end()) full.push_back("-y"); + platform::set_env_variable("XLINGS_ACTIVE_SUBOS", name); + const int rc = platform::run_argv(full); + observe::append(logfile, observe::Event{ .kind = observe::Kind::Perm, + .fields = {{"event", "request-done"}, {"instance", name}, {"request", r->id}, {"exit", rc}}}); + return rc; +} + +// `subos report [--session ID] [--json]`: the audit, summarised +// (design §22) -- what an agent did in an instance, in one screen. +int run_report_(int argc, char* argv[], EventStream& stream, + const std::function& usageError) { + std::string name, only; + bool json = false; + for (int i = 3; i < argc; ++i) { + std::string_view a = argv[i]; + if (a == "--json") json = true; + else if (a == "--session" && i + 1 < argc) only = argv[++i]; + else if (!a.empty() && a[0] != '-' && name.empty()) name = std::string(a); + else { usageError("unknown option for `xlings subos report`: " + std::string(a)); return 1; } + } + if (name.empty()) { + int rc = 0; + name = pick_subos_or_fail_("report", stream, usageError, &rc); + if (name.empty()) return rc; + } + struct Session { + std::string id, started, backend; + long long ms = -1; + std::optional exit; + bool trace = false; + std::map programs; + int execs = 0, commands = 0; + std::map perm; + std::vector denied, changed; + }; + std::map sessions; + std::vector order, policy_changes; + for (auto& e : observe::read(home_view().logs_dir(name) / "events.ndjson")) { + const auto ev = e.value("event", ""); + if (ev == "policy-change" || ev == "policy-reset") { + policy_changes.push_back(e.value("ts", "") + " " + ev); + continue; + } + const auto id = e.value("session", ""); + if (id.empty() || (!only.empty() && id != only)) continue; + if (!sessions.contains(id)) order.push_back(id); + auto& s = sessions[id]; + s.id = id; + const auto kind = e.value("kind", ""); + if (ev == "session-start") { + s.started = e.value("ts", ""); + s.backend = e.value("backend", ""); + s.trace = e.value("exec_trace", false); + } else if (ev == "session-end") { + s.ms = e.value("ms", -1LL); + s.exit = e.value("exit", 0); + } else if (kind == "exec") { + ++s.execs; + ++s.programs[e.value("path", "?")]; + } else if (kind == "ops" && ev == "exec") { + ++s.commands; + } else if (kind == "perm" && ev == "decision") { + auto action = e.value("action", ""); + ++s.perm[action]; + if (action == "deny") s.denied.push_back(e.value("program", "") + ": " + e.value("reason", "")); + } else if (kind == "fs") { + for (auto& f : e.value("files", nlohmann::json::array())) + s.changed.push_back(e.value("mount", "") + "/" + f.get()); + } + } + if (json) { + nlohmann::json out{{"instance", name}, {"sessions", nlohmann::json::array()}, + {"policy_changes", policy_changes}}; + for (auto& id : order) { + auto& s = sessions[id]; + nlohmann::json top = nlohmann::json::object(); + for (auto& [p, n] : s.programs) top[p] = n; + out["sessions"].push_back({{"id", s.id}, {"started", s.started}, {"backend", s.backend}, + {"ms", s.ms}, {"exit", s.exit ? nlohmann::json(*s.exit) : nlohmann::json(nullptr)}, + {"exec_traced", s.trace}, {"executions", s.execs}, {"programs", top}, + {"commands", s.commands}, {"permissions", s.perm}, + {"denied", s.denied}, {"changed", s.changed}}); + } + std::println(std::cout, "{}", out.dump()); + return 0; + } + std::println(std::cout, "subos {}: {} session(s)", name, order.size()); + for (auto& id : order) { + auto& s = sessions[id]; + std::println(std::cout, "\n session {} {} {} {}", s.id, s.started, s.backend, + s.exit ? std::format("exit {} after {} ms", *s.exit, s.ms) : std::string("running")); + std::println(std::cout, " commands joined: {}", s.commands); + if (s.trace) { + std::vector> top; + for (auto& [p, n] : s.programs) top.emplace_back(n, p); + std::ranges::sort(top, std::greater<>{}); + std::println(std::cout, " programs executed: {}", s.execs); + for (std::size_t i = 0; i < std::min(10, top.size()); ++i) + std::println(std::cout, " {:>5} {}", top[i].first, top[i].second); + } else { + std::println(std::cout, " programs executed: not traced (observe < full)"); + } + if (!s.perm.empty()) { + std::string line; + for (auto& [a, n] : s.perm) line += std::format(" {}={}", a, n); + std::println(std::cout, " permission decisions:{}", line); + for (auto& d : s.denied) std::println(std::cout, " denied {}", d); + } + if (!s.changed.empty()) { + std::println(std::cout, " files changed in rw mounts: {}", s.changed.size()); + for (std::size_t i = 0; i < std::min(20, s.changed.size()); ++i) + std::println(std::cout, " {}", s.changed[i]); + } + } + for (auto& c : policy_changes) std::println(std::cout, "\n policy {}", c); + return 0; +} + +// `subos ps`: the running sessions (design §22). +int run_ps_(int argc, char* argv[], EventStream& stream) { + bool json = false; + for (int i = 3; i < argc; ++i) if (std::string_view(argv[i]) == "--json") json = true; + auto sessions = session::list(home_view()); + if (json) { + for (auto& i : sessions) std::println(std::cout, "{}", session::to_json(i).dump()); + return 0; + } + if (sessions.empty()) { + log::info("no running sessions"); + return 0; + } + nlohmann::json table; + table["headers"] = {"SUBOS", "SESSION", "BACKEND", "STARTED", "PID", "MODE"}; + table["rows"] = nlohmann::json::array(); + for (auto& i : sessions) { + table["rows"].push_back({i.instance, i.id, i.backend, i.started, + std::to_string(i.supervisor_pid), + i.detached ? std::format("detached, ttl {}s", i.ttl) + : std::string("attached")}); + } + stream.emit(DataEvent{"table", table.dump()}); + return 0; +} + +// `subos log `: the instance's audit, read from outside the sandbox +// where the supervisor wrote it (design §22). +int run_log_(int argc, char* argv[], EventStream& stream, + const std::function& usageError) { + std::string name; + std::set kinds; + std::string sessionId; + std::size_t limit = 50; + bool json = false, follow = false; + for (int i = 3; i < argc; ++i) { + std::string a = argv[i]; + if (a == "--json") json = true; + else if (a == "-f" || a == "--follow") follow = true; + else if (a == "--kind" && i + 1 < argc) kinds.insert(argv[++i]); + else if (a == "--session" && i + 1 < argc) sessionId = argv[++i]; + else if ((a == "-n" || a == "--lines") && i + 1 < argc) { + try { limit = static_cast(std::stoul(argv[++i])); } + catch (...) { usageError("-n expects a number"); return 1; } + } + else if (!a.empty() && a[0] != '-' && name.empty()) name = std::move(a); + else { usageError("unknown option for `xlings subos log`: " + a); return 1; } + } + if (name.empty()) { + int rc = 0; + name = pick_subos_or_fail_("log", stream, usageError, &rc); + if (name.empty()) return rc; + } + const auto file = home_view().logs_dir(name) / "events.ndjson"; + auto keep = [&](const nlohmann::json& e) { + if (!kinds.empty() && !kinds.contains(e.value("kind", ""))) return false; + if (!sessionId.empty() && e.value("session", "") != sessionId) return false; + return true; + }; + auto print = [&](const nlohmann::json& e) { + if (json) { std::println(std::cout, "{}", e.dump()); return; } + std::string detail; + for (std::string k : {"program", "path", "exit", "signal", "backend", "error", "count", "ms"}) { + if (!e.contains(k)) continue; + detail += std::format(" {}={}", k, e[k].is_string() ? e[k].get() : e[k].dump()); + } + std::println(std::cout, "{} {:<9} {:<14} {}{}", e.value("ts", ""), e.value("kind", ""), + e.value("event", ""), e.value("session", ""), detail); + }; + auto events = observe::read(file); + std::vector shown; + for (auto& e : events) if (keep(e)) shown.push_back(std::move(e)); + const auto from = shown.size() > limit ? shown.size() - limit : 0; + for (auto i = from; i < shown.size(); ++i) print(shown[i]); + if (!follow) return 0; + // -f: keep reading what the supervisor appends. + std::error_code ec; + auto offset = fs::exists(file, ec) ? fs::file_size(file, ec) : 0; + while (true) { + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + auto size = fs::exists(file, ec) ? fs::file_size(file, ec) : 0; + if (size < offset) offset = 0; // rotated + if (size == offset) continue; + std::ifstream in(file, std::ios::binary); + in.seekg(static_cast(offset)); + std::string line; + while (std::getline(in, line)) { + auto e = nlohmann::json::parse(line, nullptr, false); + if (!e.is_discarded() && e.is_object() && keep(e)) print(e); + } + offset = size; + } +} + +} // namespace xlings::subos diff --git a/src/core/subos/carrier_image.cpp b/src/core/subos/carrier_image.cpp new file mode 100644 index 000000000..96cb2730e --- /dev/null +++ b/src/core/subos/carrier_image.cpp @@ -0,0 +1,97 @@ +module xlings.core.subos.carrier_image; + +import std; +import xlings.carrier; +import xlings.core.config; +import xlings.core.log; +import xlings.core.xim.catalog; +import xlings.core.xim.commands; +import xlings.core.xim.downloader; +import xlings.core.xim.extract; +import xlings.core.xim.installer; +import xlings.core.xim.libxpkg.types.type; +import xlings.platform; +import xlings.platform.target; +import xlings.subos.home_view; + +namespace xlings::subos_carrier { + +namespace { + +std::optional find_binary(const fs::path& root) { + std::error_code ec; + for (auto it = fs::recursive_directory_iterator(root, ec); !ec && it != fs::recursive_directory_iterator(); + it.increment(ec)) { + const auto& p = it->path(); + if (p.filename() == "xlings" && p.parent_path().filename() == "bin" && it->is_regular_file(ec)) return p; + } + return std::nullopt; +} + +std::expected download_linux_build(const fs::path& home) { + const std::string version(Info::VERSION); + const auto cache = home / "carriers" / "linux-xlings" / version; + if (auto found = find_binary(cache)) return *found; + + auto& catalog = xim::get_catalog(xim::CatalogAccess::InstallReady); + auto match = catalog.resolve_target("xim:xlings@" + version, "linux"); + if (!match) { + // A build not published yet (a candidate, a dev build): the latest + // Linux release the index has. The guest is a package manager; the + // one here talks to it through the NDJSON interface, which only adds. + match = catalog.resolve_target("xim:xlings", "linux"); + if (!match) return std::unexpected("the index has no xim:xlings for linux: " + match.error()); + log::warn("xlings {} is not in the index; the carrier gets xlings {} (the latest published)", + version, match->version); + } + auto pkg = catalog.load_package(*match); + if (!pkg) return std::unexpected(pkg.error()); + const auto arch = std::string(platform::build_arch()); + auto resource = xim::detail_::resolve_download_resource_(pkg->xpm, "xlings", match->version, "linux", arch, + Config::mirror()); + if (!resource) return std::unexpected("xim:xlings@" + match->version + " linux/" + arch + ": " + resource.error()); + const auto downloads = home / "carriers" / "downloads"; + std::error_code ec; + fs::create_directories(downloads, ec); + xim::DownloadTask task{.name = "xlings-linux", .url = resource->url, .sha256 = resource->sha256, + .destDir = downloads}; + log::info("fetching the Linux build of xlings {} for the carrier...", version); + auto got = xim::download_one(task); + if (!got.success) return std::unexpected("download failed: " + got.error); + fs::create_directories(cache, ec); + if (auto extracted = xim::extract_archive(got.localFile, cache); !extracted) + return std::unexpected(extracted.error()); + if (auto found = find_binary(cache)) return *found; + return std::unexpected("the linux artifact of xim:xlings@" + version + " holds no bin/xlings"); +} + +std::expected image(const subos::HomeView& home, std::span files) { + auto guest = linux_xlings(home.home); + if (!guest) return std::unexpected(guest.error()); + std::vector entries; + for (const auto& f : files) + entries.push_back({.path = f.path, .content = f.content, .mode = f.mode, .link = f.link, + .directory = f.directory}); + entries.push_back({.path = "xlings/bin/xlings", .from = *guest, .mode = 0755}); + const auto out = home.home / "carriers" / "image.tar.gz"; + std::error_code ec; + fs::create_directories(out.parent_path(), ec); + if (auto written = xim::write_tar_gz_entries(out, entries); !written) return std::unexpected(written.error()); + return out; +} + +} // namespace + +std::expected linux_xlings(const fs::path& home) { + if (const char* named = std::getenv("XLINGS_CARRIER_GUEST_XLINGS"); named && *named) { + std::error_code ec; + if (!fs::is_regular_file(named, ec)) return std::unexpected(std::string(named) + " is not a file"); + return fs::path(named); + } + if constexpr (platform::is_linux) return platform::get_executable_path(); + return download_linux_build(home); +} + +void install() { carrier::set_image_source(image); } + +} // namespace xlings::subos_carrier diff --git a/src/core/subos/carrier_image.cppm b/src/core/subos/carrier_image.cppm new file mode 100644 index 000000000..0015978ab --- /dev/null +++ b/src/core/subos/carrier_image.cppm @@ -0,0 +1,22 @@ +export module xlings.core.subos.carrier_image; + +import std; + +// The guest image a carrier imports (SubOS design part 3 §5.3), made by the +// xlings core for xlings.carrier: the Linux build of this release, and the +// files the carrier declares, written as one root-owned tarball. +export namespace xlings::subos_carrier { + +namespace fs = std::filesystem; + +// Hand xlings.carrier the ports only the core can fill (the image source). +void install(); + +// The Linux build of this release, as a file on this machine: +// XLINGS_CARRIER_GUEST_XLINGS a file named outright (offline, tests); +// a Linux host this binary; +// elsewhere `xim:xlings` at this version, the index's +// linux artifact, downloaded and verified. +std::expected linux_xlings(const fs::path& home); + +} // namespace xlings::subos_carrier diff --git a/src/core/subos.cpp b/src/core/subos/cmd.cpp similarity index 72% rename from src/core/subos.cpp rename to src/core/subos/cmd.cpp index 90a3ca36e..564b6d2b1 100644 --- a/src/core/subos.cpp +++ b/src/core/subos/cmd.cpp @@ -1,61 +1,45 @@ -module; - -// System headers used by use_spawn_shell only. `import std;` doesn't -// pull these in, and we want execl/errno (POSIX) or CreateProcess -// (Win32) without #include in the named-module purview (which the -// standard forbids for headers that aren't importable units). -#include - -// stdout / stderr as FILE*, for std::println. -// -// EVERY std::println here names its stream, and that is load-bearing rather -// than tidy: without one, clang deduces the format string itself as the first -// ARGUMENT and instantiates formatter>, which is -// deleted. gcc accepts the same code. Whether it trips is decided by what -// else the translation unit imports, so it cannot be predicted from this -// file -- adding one unrelated import to this TU is exactly what surfaced it. -#if defined(_WIN32) -#define WIN32_LEAN_AND_MEAN -// windows.h defines `min`/`max` as function-like macros, which turns any -// `std::min({a, b, c})` in this module into "too many arguments provided to -// function-like macro invocation" -- an error that names the call site and -// says nothing about where the macro came from. Every other module here that -// includes windows.h already defines this (platform.cppm, platform/windows.cppm, -// platform/target.cppm); this one was the exception, and it cost four -// consecutive red Windows runs. -#define NOMINMAX -#include -#else -#include -#include -#include -#include -#endif - module xlings.core.subos; +import xlings.subos.roles; +import xlings.core.subos.root; import std; import xlings.core.config; import xlings.core.home_config; import xlings.libs.json; import xlings.core.log; import xlings.platform; +import xlings.subos.tools; +import xlings.subos.ports; import xlings.runtime; import xlings.core.utils; import xlings.core.xself; import xlings.core.xvm.types; import xlings.core.xvm.db; import xlings.core.xim.commands; -import xlings.core.subos.keeper; -import xlings.core.subos.gpu; -import xlings.core.subos.graphics; +import xlings.subos.gpu; +import xlings.subos.graphics; import xlings.core.subos.sandbox; -import xlings.core.subos.manifest; +import xlings.subos.manifest; import xlings.cli.spec; import xlings.i18n; import xlings.core.confirm; import xlings.core.destructive_log; -import xlings.core.subos.userdata; +import xlings.subos.userdata; +import xlings.carrier; +import xlings.core.subos.carrier_image; +import xlings.subos.caps; +import xlings.subos.model; +import xlings.core.subos.ports; +import xlings.subos.session; +import xlings.subos.policy; +import xlings.subos.policy_store; +import xlings.subos.broker; +import xlings.observe; +import xlings.core.home; +import xlings.core.home.domain_producer; +import xlings.core.home.domain_producer_source; +import xlings.libs.sha256; +import xlings.core.version_order; namespace xlings::subos { @@ -164,111 +148,29 @@ std::vector list_all() { return candidate_view().candidates; } -std::string lowercase_name_(std::string_view name) { - std::string lowered(name); - for (auto& ch : lowered) { - if (ch >= 'A' && ch <= 'Z') ch = static_cast(ch - 'A' + 'a'); - } - return lowered; -} - -std::size_t edit_distance_(std::string_view lhs, std::string_view rhs) { - std::vector prev(rhs.size() + 1); - std::vector next(rhs.size() + 1); - std::iota(prev.begin(), prev.end(), std::size_t{0}); - for (std::size_t i = 0; i < lhs.size(); ++i) { - next[0] = i + 1; - for (std::size_t j = 0; j < rhs.size(); ++j) { - const auto replace = prev[j] + (lhs[i] == rhs[j] ? 0u : 1u); - next[j + 1] = std::min({prev[j + 1] + 1, next[j] + 1, replace}); - } - std::swap(prev, next); - } - return prev.back(); -} - -std::vector suggestions_( - std::string_view query, - std::span candidates) { - struct Scored { - int substringRank; - std::size_t distance; - SubosInfo candidate; - }; - const auto loweredQuery = lowercase_name_(query); - std::vector scored; - scored.reserve(candidates.size()); - for (const auto& candidate : candidates) { - const auto lowered = lowercase_name_(candidate.name); - const bool related = lowered.contains(loweredQuery) - || loweredQuery.contains(lowered); - scored.push_back({related ? 0 : 1, - edit_distance_(loweredQuery, lowered), candidate}); - } - std::ranges::sort(scored, [](const auto& lhs, const auto& rhs) { - return std::tie(lhs.substringRank, lhs.distance, lhs.candidate.name) - < std::tie(rhs.substringRank, rhs.distance, rhs.candidate.name); - }); - std::vector result; - for (std::size_t i = 0; i < std::min(3, scored.size()); ++i) { - result.push_back(std::move(scored[i].candidate)); - } - return result; -} - +// The rules live in the SubOS core (xlings.subos.model) and work on names; +// this maps their answer back to the instances it is about. CandidateResolution_ resolve_candidate_(std::string_view query) { auto all = candidate_view(false).candidates; - if (query.empty()) { - return {.reason = "missing_name", .candidates = std::move(all)}; - } - - if (auto exact = std::ranges::find(all, query, &SubosInfo::name); - exact != all.end()) { - return {.selected = exact->name, - .reason = "exact", - .candidates = {*exact}}; - } - - const auto loweredQuery = lowercase_name_(query); - std::vector matches; - for (const auto& candidate : all) { - if (lowercase_name_(candidate.name) == loweredQuery) { - matches.push_back(candidate); - } - } - if (matches.size() == 1) { - return {.selected = matches.front().name, - .reason = "case_insensitive_exact", - .candidates = std::move(matches), - .autoSelected = true}; - } - if (matches.size() > 1) { - return {.reason = "ambiguous", .candidates = std::move(matches)}; + std::vector names; + names.reserve(all.size()); + for (const auto& c : all) names.push_back(c.name); + + auto r = model::resolve_name(query, names); + CandidateResolution_ out{ .selected = std::move(r.selected), + .reason = std::move(r.reason), + .autoSelected = r.autoSelected }; + for (const auto& name : r.matches) { + if (auto it = std::ranges::find(all, name, &SubosInfo::name); it != all.end()) + out.candidates.push_back(*it); } - - for (const auto& candidate : all) { - if (lowercase_name_(candidate.name).starts_with(loweredQuery)) { - matches.push_back(candidate); - } - } - if (matches.size() == 1) { - return {.selected = matches.front().name, - .reason = "unique_prefix", - .candidates = std::move(matches), - .autoSelected = true}; - } - if (matches.size() > 1) { - return {.reason = "ambiguous", .candidates = std::move(matches)}; - } - - return {.reason = "not_found", - .candidates = suggestions_(query, all)}; + return out; } void emit_candidates_(EventStream& stream, const CandidateResolution_& resolution, std::string_view query, - std::string_view hint = {}) { + std::string_view hint) { nlohmann::json candidates = nlohmann::json::array(); for (const auto& candidate : resolution.candidates) { candidates.push_back({ @@ -422,8 +324,14 @@ void update_current_symlink_(EventStream& stream, // "the file is there" does not mean "the subos describes itself". bool ensure_subos_info_(const fs::path& dir, manifest::Intent intent, std::string_view requested) { - auto json = read_config_json_(dir / ".xlings.json"); - if (!json.is_object()) json = nlohmann::json::object(); + // An unparseable manifest is not an empty one: rebuilding it from {} would + // write a blank workspace over the subos's real one (home::read_json_for_update). + auto read = home::read_json_for_update(dir / ".xlings.json"); + if (!read) { + log::warn("{} -- not rewriting it", read.error()); + return false; + } + auto json = std::move(*read); if (!json.contains("workspace")) json["workspace"] = nlohmann::json::object(); // Only replace a block that is absent or unusable. Rewriting a valid one @@ -554,6 +462,15 @@ bool reject_empty_subos_name_(std::string_view what, const std::string& name, return true; } +bool reject_unrouted_domain_(const std::string& name, std::string_view operation, EventStream& stream) { + auto scope = xlings::home::domain_producer::read_scope(Config::paths().homeDir, name); + if (scope && !*scope) return false; + stream.emit(ErrorEvent{ .code = ErrorCode::InvalidInput, + .message = scope ? std::format("{} of a private prefix-domain instance is not supported yet", operation) + : scope.error(), .recoverable = false }); + return true; +} + int create(const std::string& name, const fs::path& customDir, sandbox::StorageMode storage, const std::string& imageSize, const std::string& runtime, @@ -569,6 +486,7 @@ int create(const std::string& name, const fs::path& customDir, auto& p = Config::paths(); if (reject_empty_subos_name_("create a subos", name, stream)) return 1; + if (reject_unrouted_domain_(name, "replacement", stream)) return 1; if (name == "current") { stream.emit(ErrorEvent{ @@ -972,19 +890,22 @@ int copy_tree_(const fs::path& src, const fs::path& dst, // Use the system cp with reflink/clonefile flags; falls back to // full copy when the FS doesn't support it. Skip the bin/ subtree // here — shims are regenerated below. - std::string copy_cmd; -#if defined(__linux__) - // cp -a preserves mode/ownership/timestamps; --reflink=auto uses - // COW where available (btrfs/xfs) and full copy otherwise. - copy_cmd = std::format( - "cp -a --reflink=auto '{}/.' '{}/'", src.string(), dst.string()); -#elif defined(__APPLE__) - // APFS clonefile via /bin/cp -c - copy_cmd = std::format("cp -ac '{}/.' '{}/'", src.string(), dst.string()); -#endif - - if (!copy_cmd.empty()) { - auto rc = std::system(copy_cmd.c_str()); + // argv, not a shell line: a path with a quote in it is a path. + std::vector copy_argv; + if (auto cp = subos::tools::first("cp", subos::home_view(), subos::Ports{})) { + const auto from = (src / ".").string(), to = (dst / "").string(); + if constexpr (platform::is_linux) { + // cp -a preserves mode/ownership/timestamps; --reflink=auto uses + // COW where available (btrfs/xfs) and full copy otherwise. + copy_argv = {cp->bin.string(), "-a", "--reflink=auto", from, to}; + } else if constexpr (platform::is_macos) { + // APFS clonefile via /bin/cp -c + copy_argv = {cp->bin.string(), "-ac", from, to}; + } + } + + if (!copy_argv.empty()) { + auto rc = platform::run_argv(copy_argv); if (rc != 0) { log::warn("cp -a/--reflink failed (rc={}), falling back to " "std::filesystem::copy", rc); @@ -1045,6 +966,65 @@ fs::path locate_base_pkg_(const PkgRef& ref) { } +fs::path resolve_base_package_(const std::string& fromSpec, EventStream& stream) { + auto& p = Config::paths(); + // ── pkg-spec path: locate or install the base xpkg ──────────── + auto ref = new_from_detail_::parse_pkg_spec_(fromSpec); + auto baseDir = new_from_detail_::locate_base_pkg_(ref); + + if (!baseDir.empty()) { + auto borrowed = xlings::home::domain_producer_source::borrowed_mount(baseDir); + if (!borrowed) { + stream.emit(ErrorEvent{ + .code = ErrorCode::InvalidInput, + .message = borrowed.error(), + .recoverable = false, + }); + return {}; + } + if (*borrowed) { + const std::vector targets{fromSpec}; + if (xim::cmd_install(targets, /*yes=*/true, /*noDeps=*/false, stream) != 0) + return {}; + baseDir = new_from_detail_::locate_base_pkg_(ref); + } + } + + if (baseDir.empty()) { + // Auto-install (E5a): invoke `xlings install ` so the + // base lands at xpkgs/-x-//. We use the host + // xlings binary (same process binary) so the install runs + // with the same context (XLINGS_HOME, mirror config, etc.). + log::info("base subos pkg '{}' not installed; auto-installing...", + fromSpec); + auto xlings_bin = xself::xlings_binary_in_home(p.homeDir); + if (xlings_bin.empty()) xlings_bin = platform::get_executable_path(); + + auto rc = platform::run_argv({xlings_bin.string(), "install", "-y", fromSpec}); + if (rc != 0) { + stream.emit(ErrorEvent{ + .code = ErrorCode::Internal, + .message = "auto-install of base '" + fromSpec + + "' failed", + .recoverable = true, + .hint = "run manually: xlings install " + fromSpec, + }); + return {}; + } + baseDir = new_from_detail_::locate_base_pkg_(ref); + } + + if (baseDir.empty()) { + stream.emit(ErrorEvent{ + .code = ErrorCode::NotFound, + .message = "couldn't locate base pkg payload for '" + fromSpec + + "' after install", + .recoverable = false, + }); + } + return baseDir; +} + int new_from(const std::string& name, const fs::path& customDir, sandbox::StorageMode storage, const std::string& imageSize, const std::string& fromSpec, const std::string& runtime, @@ -1054,46 +1034,11 @@ int new_from(const std::string& name, const fs::path& customDir, fs::path baseDir; if (new_from_detail_::is_pkg_spec_(fromSpec)) { - // ── pkg-spec path: locate or install the base xpkg ──────────── - auto ref = new_from_detail_::parse_pkg_spec_(fromSpec); - baseDir = new_from_detail_::locate_base_pkg_(ref); - - if (baseDir.empty()) { - // Auto-install (E5a): invoke `xlings install ` so the - // base lands at xpkgs/-x-//. We use the host - // xlings binary (same process binary) so the install runs - // with the same context (XLINGS_HOME, mirror config, etc.). - log::info("base subos pkg '{}' not installed; auto-installing...", - fromSpec); - auto xlings_bin = xself::xlings_binary_in_home(p.homeDir); - - auto cmd = std::format("{} install -y {}", - xlings_bin.string(), fromSpec); - auto rc = std::system(cmd.c_str()); - if (rc != 0) { - stream.emit(ErrorEvent{ - .code = ErrorCode::Internal, - .message = "auto-install of base '" + fromSpec - + "' failed", - .recoverable = true, - .hint = "run manually: xlings install " + fromSpec, - }); - return 1; - } - baseDir = new_from_detail_::locate_base_pkg_(ref); - } - - if (baseDir.empty()) { - stream.emit(ErrorEvent{ - .code = ErrorCode::NotFound, - .message = "couldn't locate base pkg payload for '" + fromSpec - + "' after install", - .recoverable = false, - }); - return 1; - } + baseDir = resolve_base_package_(fromSpec, stream); + if (baseDir.empty()) return 1; } else { // ── local fork path: source is an existing subos by name ────── + if (reject_unrouted_domain_(fromSpec, "forking", stream)) return 1; baseDir = p.homeDir / "subos" / fromSpec; if (!fs::is_directory(baseDir)) { stream.emit(ErrorEvent{ @@ -1212,6 +1157,7 @@ int new_from(const std::string& name, const fs::path& customDir, // the back-compat single-arg `use()` both route here. int use_global(const std::string& name, EventStream& stream) { if (auto rc = use_detail_::validate_subos_(name, stream); rc != 0) return rc; + if (reject_unrouted_domain_(name, "global activation", stream)) return 1; auto& p = Config::paths(); // The window here is short, but a full-document rewrite is a full-document @@ -1297,7 +1243,7 @@ void warn_storage_dormant_on_shell_(const std::string& name) { // on every shell-level entry for image/tmpfs subos, becoming noise // once the user already knows the layout. Revisit with a clearer // one-time / opt-in form before re-enabling. - // std::println(stderr, + // std::println(std::cerr, // "[xlings] storage={} is sandbox-only; entering " // "shell-level (use --sandbox to activate)", // sandbox::storage_to_string_(storage)); @@ -1329,11 +1275,11 @@ int use_emit_shell(const std::string& name, use_detail_::report_injected_env_(name, envVars); if (is_fish) { - std::println(stdout, R"(set -gx XLINGS_ACTIVE_SUBOS "{}";)", name); - std::println(stdout, R"(set -gx XLINGS_BIN "{}";)", bin_dir.string()); + std::println(std::cout, R"(set -gx XLINGS_ACTIVE_SUBOS "{}";)", name); + std::println(std::cout, R"(set -gx XLINGS_BIN "{}";)", bin_dir.string()); // Strip any old subos bin segments from PATH, then prepend the new // bin. fish's $PATH is a list, so we use string match -v. - std::println(stdout, R"(set -gx PATH "{}" (string match -v -r "^{}/subos/[^/]+/bin$" -- $PATH);)", + std::println(std::cout, R"(set -gx PATH "{}" (string match -v -r "^{}/subos/[^/]+/bin$" -- $PATH);)", bin_dir.string(), p.homeDir.string()); for (const auto& v : envVars) { if (v.unresolved) continue; @@ -1341,34 +1287,34 @@ int use_emit_shell(const std::string& name, // plain R"(...)" literal early -- and the truncation compiles, // because what is left is still a valid string. if (v.op == manifest::OP_PREPEND) { - std::println(stdout, + std::println(std::cout, R"SH(if set -q {0}; set -gx {0} "{1}:${0}"; else; set -gx {0} "{1}"; end;)SH", v.var, v.value); } else { - std::println(stdout, R"SH(if not set -q {0}; set -gx {0} "{1}"; end;)SH", + std::println(std::cout, R"SH(if not set -q {0}; set -gx {0} "{1}"; end;)SH", v.var, v.value); } } return 0; } if (is_pwsh) { - std::println(stdout, R"($env:XLINGS_ACTIVE_SUBOS = '{}')", name); - std::println(stdout, R"($env:XLINGS_BIN = '{}')", bin_dir.string()); - std::println(stdout, R"($env:Path = '{}' + ';' + (($env:Path -split ';') -notmatch '^{}\\subos\\[^\\]+\\bin$' -join ';'))", + std::println(std::cout, R"($env:XLINGS_ACTIVE_SUBOS = '{}')", name); + std::println(std::cout, R"($env:XLINGS_BIN = '{}')", bin_dir.string()); + std::println(std::cout, R"($env:Path = '{}' + ';' + (($env:Path -split ';') -notmatch '^{}\\subos\\[^\\]+\\bin$' -join ';'))", bin_dir.string(), p.homeDir.string()); for (const auto& v : envVars) { if (v.unresolved) continue; // ';' rather than ':' -- these are path lists, and on Windows the // separator is the one the platform's own tools split on. if (v.op == manifest::OP_PREPEND) { - std::println(stdout, + std::println(std::cout, R"($env:{0} = if ($env:{0}) {{ '{1}' + ';' + $env:{0} }} else {{ '{1}' }})", v.var, v.value); } else { // `$null -eq`, not `-not`: PowerShell's `-not` is true for an // empty string too, which would overwrite a value the user // deliberately set to "". - std::println(stdout, R"(if ($null -eq $env:{0}) {{ $env:{0} = '{1}' }})", + std::println(std::cout, R"(if ($null -eq $env:{0}) {{ $env:{0} = '{1}' }})", v.var, v.value); } } @@ -1378,27 +1324,44 @@ int use_emit_shell(const std::string& name, auto orig_path = utils::get_env_or_default("PATH"); auto new_path = use_detail_::rebuild_path_for_subos_( orig_path, p.homeDir, bin_dir); - std::println(stdout, R"(export XLINGS_ACTIVE_SUBOS="{}";)", name); - std::println(stdout, R"(export XLINGS_BIN="{}";)", bin_dir.string()); - std::println(stdout, R"(export PATH="{}";)", new_path); + std::println(std::cout, R"(export XLINGS_ACTIVE_SUBOS="{}";)", name); + std::println(std::cout, R"(export XLINGS_BIN="{}";)", bin_dir.string()); + std::println(std::cout, R"(export PATH="{}";)", new_path); for (const auto& v : envVars) { if (v.unresolved) continue; if (v.op == manifest::OP_PREPEND) { // ${VAR:+:$VAR} appends the separator only when VAR is non-empty, // so an unset variable does not become a trailing ':' -- which an // empty PATH-list element reads as "the current directory". - std::println(stdout, R"(export {0}="{1}${{{0}:+:${0}}}";)", v.var, v.value); + std::println(std::cout, R"(export {0}="{1}${{{0}:+:${0}}}";)", v.var, v.value); } else { // `${VAR=v}`, NOT `${VAR:=v}`. The colon form also assigns when VAR // is set-but-empty, which would overwrite a value the user chose. - std::println(stdout, R"(: "${{{0}={1}}}"; export {0};)", v.var, v.value); + std::println(std::cout, R"(: "${{{0}={1}}}"; export {0};)", v.var, v.value); } } return 0; } -int use_spawn_shell(const std::string& name, EventStream& stream, bool sandbox, const std::string& sandbox_backend, bool gpu, const std::string& cmd) +// The subos's DECLARED variables, as they stand after apply_subos_env_, for +// a sandbox's environment. The sandbox passes an allow-list, and a variable +// the instance itself declares (LIBGL_DRIVERS_PATH for its GL stack, #352) +// is the instance's, not the host's: it enters by name. +std::map declared_env_(const std::string& name) { + std::map env; + for (const auto& v : use_detail_::subos_env_for_(name)) { + if (v.unresolved) continue; + if (auto value = utils::get_env_or_default(v.var); !value.empty()) env[v.var] = value; + } + return env; +} + +int use_spawn_shell(const std::string& name, EventStream& stream, bool sandbox, const std::string& sandbox_backend, bool gpu, const std::string& cmd, + std::optional preset, const policy::Overrides& overrides) { + // A declared instance is entered under its policy however it is entered + // (design §10: the secure default belongs to the instance, not the flag). + if (!sandbox && (preset || enters_sandboxed_(name))) sandbox = true; // V5: --sandbox [backend] is a `use`-time modifier. Dispatch to the // sandbox path when set; auto-detect backend (bwrap preferred, proot // fallback) or use the explicitly requested one. @@ -1422,7 +1385,9 @@ int use_spawn_shell(const std::string& name, EventStream& stream, bool sandbox, if (auto rc = use_detail_::validate_subos_(name, stream); rc != 0) return rc; use_detail_::apply_subos_env_(name); warn_sandbox_without_gpu_(name, gpu, stream); - return sandbox::enter(name, stream, sandbox_backend, gpu, cmd); + return sandbox::enter(name, stream, sandbox::EnterOptions{ + .backend = sandbox_backend, .gpu = gpu, .cmd = cmd, .env = declared_env_(name), + .preset = preset, .overrides = overrides }); } warn_storage_dormant_on_shell_(name); @@ -1542,6 +1507,47 @@ int remove(const std::string& name, bool yes, std::string_view yesSpelling, return 1; } + auto scope = xlings::home::domain_producer::read_scope(p.homeDir, name); + if (!scope) { + stream.emit(ErrorEvent{ .code = ErrorCode::InvalidInput, .message = scope.error(), .recoverable = false }); + return 1; + } + if (*scope) { + const auto& actual = **scope; + if (!role_allows_at_(actual.domain.physicalHome, roles::Op::Remove, name, stream)) return 1; + auto preflight = xlings::home::domain_producer::check_control_removal(actual, name); + if (!preflight) { + stream.emit(ErrorEvent{ .code = ErrorCode::InvalidInput, .message = preflight.error(), .recoverable = false }); + return 1; + } + const auto what = userdata::describe(userdata::census(actual.producerInstance)); + auto asked = confirm::ask(stream, "subos_remove", + std::format("remove subos '{}'? This deletes {} ({}) and cannot be undone", name, + actual.producerInstance.string(), what), yes, yesSpelling); + if (asked.outcome == confirm::Outcome::Declined) return 0; + if (asked.outcome == confirm::Outcome::NobodyToAsk) { + stream.emit(ErrorEvent{ .code = ErrorCode::InvalidInput, + .message = "removing '" + name + "' requires confirmation; nothing was removed", .recoverable = true, + .hint = userdata::needs_confirmation_hint(yesSpelling, "remove_subos") }); + return 2; + } + const std::vector arguments{"subos", "remove", name, "-y"}; + auto removed = xlings::home::domain_producer::run(actual.domain, arguments, std::nullopt, true); + if (!removed) { + stream.emit(ErrorEvent{ .code = ErrorCode::Internal, .message = removed.error(), .recoverable = true }); + return 1; + } + if (*removed != 0) return *removed; + auto cleaned = xlings::home::domain_producer::remove_control(actual, name); + if (!cleaned) { + stream.emit(ErrorEvent{ .code = ErrorCode::Internal, .message = cleaned.error(), .recoverable = true }); + return 1; + } + stream.emit(DataEvent{"subos_removed", nlohmann::json{{"name", name}}.dump()}); + return 0; + } + + if (!role_allows_(roles::Op::Remove, name, stream)) return 1; auto dir = Config::subos_dir(name); if (fs::exists(dir)) { // A SubOS's home is where its user works -- an agent's clone, a @@ -1576,9 +1582,8 @@ int remove(const std::string& name, bool yes, std::string_view yesSpelling, // a half-cleaned tree behind, or (b) silently recurse into the // live mount and erase the image's contents before EBUSY surfaces. // Detect and umount first. -#if defined(__linux__) auto mountpoint = dir / ".mountpoint"; - if (fs::exists(mountpoint) + if (platform::is_linux && fs::exists(mountpoint) && sandbox::is_mounted_(mountpoint)) { if (sandbox::unmount_image_(mountpoint) != 0) { @@ -1596,8 +1601,7 @@ int remove(const std::string& name, bool yes, std::string_view yesSpelling, return 1; } } -#endif - if (auto removed = userdata::delete_subos(dir, "subos-remove", *asked.token); + if (auto removed = userdata::delete_subos(home_view(), dir, "subos-remove", *asked.token); !removed) { stream.emit(ErrorEvent{ .code = ErrorCode::Permission, @@ -1642,6 +1646,86 @@ std::optional info(const std::string& name) { counts.commands, counts.packages}; } +// ── carriers (design part 3 §5) ────────────────────────────────────── +// +// Where a SubOS runs is recorded beside its kind, in config/subos// +// instance.json: `carrier` (local | wsl2 | vz) and `abi` (native | linux). +// Absent means local and native -- every SubOS made before part 3. A reader +// that knows only `kind` ignores both. + +struct CarrierBinding_ { + std::string carrier { "local" }; + std::string abi { "native" }; +}; + +// Absent is local/native; present and unreadable is an error -- a SubOS whose +// record cannot be read might run elsewhere, and acting on it here could +// remove its name while its content lives on (could not read is not empty). +std::expected carrier_of_(const std::string& name) { + CarrierBinding_ b; + const auto file = home_view().instance_file(name); + std::error_code ec; + if (!fs::exists(file, ec)) { + if (ec) return std::unexpected(file.string() + ": " + ec.message()); + return b; + } + std::ifstream in(file); + auto j = in ? nlohmann::json::parse(in, nullptr, false) : nlohmann::json(nlohmann::json::value_t::discarded); + if (!j.is_object()) return std::unexpected(file.string() + ": unreadable; where this SubOS runs is not known"); + if (auto it = j.find("carrier"); it != j.end() && it->is_string()) b.carrier = it->get(); + if (auto it = j.find("abi"); it != j.end() && it->is_string()) b.abi = it->get(); + return b; +} + +std::expected record_carrier_(const std::string& name, const CarrierBinding_& b) { + const auto file = home_view().instance_file(name); + nlohmann::json j = nlohmann::json::object(); + if (std::ifstream in(file); in) { + j = nlohmann::json::parse(in, nullptr, false); + if (!j.is_object()) return std::unexpected(file.string() + ": not a JSON object; nothing recorded"); + } + j["carrier"] = b.carrier; + j["abi"] = b.abi; + try { + std::error_code ec; + fs::create_directories(file.parent_path(), ec); + platform::write_file_atomic(file.string(), j.dump(2) + "\n"); + } catch (const std::exception& e) { + return std::unexpected(file.string() + ": " + e.what()); + } + return {}; +} + +carrier::Probe probe_carrier_(std::string_view name) { + if (const auto* c = carrier::find(name)) return c->probe(home_view()); + return carrier::Probe{.supported = false, + .reason = std::format("the {} carrier is not in this build", name), + .route = "xlings self update"}; +} + +// The SubOS is somewhere else: its own xlings answers this command there, +// with this terminal attached, and its exit code is ours. +int forward_to_carrier_(const CarrierBinding_& b, std::vector args, EventStream& stream) { + const auto* c = carrier::find(b.carrier); + const auto probed = probe_carrier_(b.carrier); + if (!c || !probed.supported) { + stream.emit(ErrorEvent{.code = ErrorCode::NotFound, + .message = std::format("this SubOS runs on the {} carrier: {}", b.carrier, probed.reason), + .recoverable = true, .hint = probed.route}); + return 125; + } + subos_carrier::install(); + auto at = c->ensure(home_view()); + if (!at) { + stream.emit(ErrorEvent{.code = ErrorCode::Internal, .message = at.error(), .recoverable = true}); + return 125; + } + std::map env; + for (const auto* k : {"XLINGS_AGENT_MODE", "XLINGS_NON_INTERACTIVE", "XLINGS_TRACE", "NO_COLOR", "TERM", "LANG"}) + if (const char* v = std::getenv(k)) env[k] = v; + return carrier::terminal(*at, args, env); +} + int run_list_(EventStream& stream) { auto all = candidate_view().candidates; std::vector> entries; @@ -1651,10 +1735,25 @@ int run_list_(EventStream& stream) { } nlohmann::json entriesJson = nlohmann::json::array(); for (auto& [n, d, commands, packages, active] : entries) { + const auto declared_kind = subos_root::read_kind(Config::paths().homeDir, n); + const auto declared_role = subos_root::read_role(Config::paths().homeDir, n); + if (!declared_kind || !declared_role) { + stream.emit(ErrorEvent{ .code = ErrorCode::InvalidInput, + .message = !declared_kind ? declared_kind.error() : declared_role.error(), + .recoverable = false }); + return 1; + } + const auto role = *declared_role; + const auto where = carrier_of_(n).value_or(CarrierBinding_{"unknown", "unknown"}); entriesJson.push_back({{"name", n}, {"dir", d}, {"commands", commands}, {"packages", packages}, - {"active", active}}); + {"active", active}, + {"kind", std::string(roles::to_string(*declared_kind))}, + {"host", role.host}, {"boot_entry", role.boot_entry}, + {"carrier", where.carrier}, {"abi", where.abi}, + {"view", role.host ? "machine" + : *declared_kind == roles::Kind::Rootfs ? "root" : "overlay"}}); } nlohmann::json payload; payload["entries"] = std::move(entriesJson); @@ -1662,6 +1761,8 @@ int run_list_(EventStream& stream) { return 0; } +int list(EventStream& stream) { return run_list_(stream); } + // The graphics section of `xlings subos info`. // // This is the ONLY channel the wiring verdict has. It was designed as the @@ -1753,6 +1854,10 @@ nlohmann::json graphics_fields_(const fs::path& subosDir) { int run_info_(const std::string& name, EventStream& stream) { auto& p = Config::paths(); auto target = name.empty() ? p.activeSubos : name; + if (!name.empty()) { + const std::vector domainArgs{"subos", "info", name}; + if (auto result = run_domain_operation_(name, domainArgs, stream)) return *result; + } auto si = info(target); if (!si) { stream.emit(ErrorEvent{ @@ -1817,6 +1922,37 @@ std::string pick_subos_or_fail_(std::string_view verb, EventStream& stream, return {}; } +// The declaration a new instance starts with: the preset `new --sandbox` +// named, else -- forked from a local instance -- that instance's policy, so +// a copy is isolated as its source was. Written outside the instance and +// audited, as `subos config` does. +int declare_isolation_at_creation_(const std::string& name, std::optional preset, + const std::string& from, EventStream& stream) { + const auto home = home_view(); + std::optional pol; + std::string origin; + if (preset) { + pol = policy::preset(*preset); + origin = "subos new --sandbox"; + } else if (!from.empty() && from.find_first_of(":@") == std::string::npos + && policy_store::has_file(home, from)) { + auto src = policy_store::read(home, from); + if (src && *src) { pol = **src; origin = "subos new --from " + from; } + } + if (!pol) return 0; + if (auto w = policy_store::write(home, name, *pol); !w) { + stream.emit(ErrorEvent{ .code = ErrorCode::Internal, .message = w.error(), .recoverable = true }); + return 1; + } + observe::append(home.logs_dir(name) / "events.ndjson", observe::Event{ + .kind = observe::Kind::Lifecycle, + .fields = {{"event", "policy-change"}, {"instance", name}, {"by", origin}, + {"diff", policy::diff(policy::legacy(), *pol)}}}); + log::info("'{}' is declared {}: `xlings subos use {}` and `subos exec` enter it that way", + name, policy::to_string(pol->preset), name); + return 0; +} + int run(int argc, char* argv[], EventStream& stream) { // Drop the options root publishes as valid on every command before any // subcommand's argv loop sees them. `subos new` and `subos use` end their @@ -1857,6 +1993,42 @@ int run(int argc, char* argv[], EventStream& stream) { }); }; + // A SubOS on another carrier: its xlings answers there (part 3 §5). + static const std::set> kNamed{ + "use", "exec", "start", "stop", "remove", "info", "config", "status", "doctor", "cp", "log", + "report", "requests", "approve", "deny", "rollback", "boot", "export", "diff", "pack"}; + if (kNamed.contains(sub)) { + for (int i = 3; i < argc; ++i) { + const std::string a = argv[i]; + if (a == "--") break; + if (a.empty() || a[0] == '-' || !fs::exists(Config::subos_dir(a))) continue; + const auto read = carrier_of_(a); + if (!read) { + stream.emit(ErrorEvent{.code = ErrorCode::InvalidInput, .message = read.error(), .recoverable = true, + .hint = "xlings self doctor"}); + return 1; + } + const auto& where = *read; + if (where.carrier == "local") break; + std::vector args{"subos", sub}; + for (int k = 3; k < argc; ++k) args.emplace_back(argv[k]); + if (yesGiven) args.push_back("-y"); + const int rc = forward_to_carrier_(where, args, stream); + if (sub == "remove" && rc == 0) { + // There it is gone; here only its name and record remain. + std::error_code ec; + fs::remove(home_view().instance_file(a), ec); + fs::remove(home_view().config_dir(a), ec); + fs::remove(Config::subos_dir(a), ec); + (void)update_home_config(Config::paths().homeDir, [&](nlohmann::json& json) { + if (!json.contains("subos") || !json["subos"].is_object()) return false; + return json["subos"].erase(a) > 0; + }); + } + return rc; + } + } + if (sub == "new") { if (argc < 4) { usageError("missing for: xlings subos new"); return 1; } // Parse: xlings subos new [--storage ] [--image-size ] [--from ] @@ -1873,9 +2045,53 @@ int run(int argc, char* argv[], EventStream& stream) { // invalidate every payload already installed. Absent → the built-in // default, so existing invocations keep working unchanged. std::string runtime; + // --sandbox[=dev|private|locked]: how isolated the instance is, declared + // once here -- every later `use` / `exec` enters it that way without + // being told (design §7). Bare `--sandbox` is dev. + std::optional declared; + // --rootfs: the instance can be presented as `/` (design part 2 §3.1). + bool rootfs = false; + std::string domain; + // --carrier / --abi (design part 3 §5.6): where it runs. Default: + // this machine's kernel unless what it is needs another one. + std::string requestedCarrier, abi = "native"; + std::vector forwardArgs; // what the carrier's xlings is asked for (int i = 3; i < argc; ++i) { std::string a = argv[i]; - if (a == "--runtime" && i + 1 < argc) { + if ((a == "--carrier" || a == "--abi") && i + 1 < argc) { + (a == "--carrier" ? requestedCarrier : abi) = argv[++i]; + continue; + } + if (a.starts_with("--carrier=")) { requestedCarrier = a.substr(10); continue; } + if (a.starts_with("--abi=")) { abi = a.substr(6); continue; } + forwardArgs.push_back(a); + if (a == "--rootfs") { rootfs = true; continue; } + if (a == "--domain") { + if (i + 1 >= argc || !domain.empty()) { usageError("--domain expects one absolute home path"); return 1; } + domain = argv[++i]; + if (domain.empty() || domain[0] != '/') { usageError("--domain expects an absolute home path"); return 1; } + continue; + } + if (a == "--sandbox" || a.starts_with("--sandbox=")) { + std::string v = a == "--sandbox" ? std::string("dev") : a.substr(10); + if (a == "--sandbox" && i + 1 < argc) { + const std::string_view next = argv[i + 1]; + if (policy::preset_from_string(next)) v = argv[++i]; + else if (next == "bwrap" || next == "proot" || next == "landlock") v = argv[++i]; + } + if (v == "bwrap" || v == "proot" || v == "landlock") { + usageError(std::format("--sandbox {}: a backend is chosen when the instance is " + "entered (`subos use --sandbox {}`); `new` declares " + "dev, private or locked", v, v)); + return 1; + } + declared = policy::preset_from_string(v); + if (!declared || *declared == policy::Preset::Legacy) { + usageError("--sandbox expects dev, private or locked"); + return 1; + } + } + else if (a == "--runtime" && i + 1 < argc) { runtime = argv[++i]; } else if (a.rfind("--runtime=", 0) == 0) { @@ -1913,10 +2129,91 @@ int run(int argc, char* argv[], EventStream& stream) { usageError("missing for: xlings subos new"); return 1; } - if (!fromSpec.empty()) { - return new_from(name, {}, storage, imageSize, fromSpec, runtime, yesGiven, stream); + if (abi != "native" && abi != "linux") { usageError("--abi expects native or linux"); return 1; } + const auto platformName = std::string(caps::platform_name()); + if (abi == "linux" && platformName == "linux") abi = "native"; + const auto choice = carrier::choose(platformName, + carrier::Want{.requested = requestedCarrier, .abi = abi, .root = rootfs}, probe_carrier_); + if (!choice) { + stream.emit(ErrorEvent{.code = ErrorCode::NotFound, .message = choice.error().reason, + .recoverable = true, .hint = choice.error().route}); + return 125; + } + if (choice->carrier != "local") { + if (fs::exists(Config::subos_dir(name))) { usageError("subos '" + name + "' already exists"); return 1; } + std::vector args{"subos", "new"}; + args.insert(args.end(), forwardArgs.begin(), forwardArgs.end()); + if (yesGiven) args.push_back("-y"); + const CarrierBinding_ binding{choice->carrier, abi}; + log::info("'{}' runs on the {} carrier ({})", name, choice->carrier, choice->why); + const int rc = forward_to_carrier_(binding, args, stream); + if (rc != 0) return rc; + // Here: the name, and where it lives. Its content is there. + std::error_code ec; + fs::create_directories(Config::subos_dir(name), ec); + if (auto recorded = record_carrier_(name, binding); !recorded) { + stream.emit(ErrorEvent{.code = ErrorCode::Internal, .message = recorded.error(), .recoverable = true}); + return 1; + } + auto registered = update_home_config(Config::paths().homeDir, [&](nlohmann::json& json) { + if (!json.contains("subos") || !json["subos"].is_object()) json["subos"] = nlohmann::json::object(); + json["subos"][name] = nlohmann::json{{"dir", ""}}; + return true; + }); + if (!registered) { + stream.emit(ErrorEvent{.code = ErrorCode::Internal, .message = registered.error(), .recoverable = true}); + return 1; + } + return 0; + } + if (!fromSpec.empty() && fromSpec.find_first_of(":@") == std::string::npos) { + auto source = xlings::home::domain_producer::read_scope(Config::paths().homeDir, fromSpec); + if (!source) { usageError(source.error()); return 1; } + if (*source) { + const auto& sourceDomain = (**source).domain; + if (!domain.empty() && fs::path(domain).lexically_normal() != sourceDomain.logicalHome) { + usageError("a different prefix domain requires rebuilding from package coordinates; an instance's absolute payload paths cannot be copied across prefixes"); + return 1; + } + domain = sourceDomain.logicalHome.string(); + const auto fresh = preflight_domain_at_creation_(name, true, domain, {}); + if (!fresh) { usageError(fresh.error()); return 1; } + std::vector arguments{"subos", "new"}; + for (int i = 3; i < argc; ++i) { + if (std::string_view(argv[i]) == "--domain") { ++i; continue; } + arguments.emplace_back(argv[i]); + } + if (!rootfs) arguments.push_back("--rootfs"); + if (yesGiven) arguments.push_back("-y"); + auto forked = produce_domain_at_creation_(name, true, domain, arguments, stream); + if (!forked) { usageError("private domain fork did not select its namespace producer"); return 1; } + return *forked; + } + } + const auto domain_ready = preflight_domain_at_creation_(name, rootfs, domain, fromSpec); + if (!domain_ready) { usageError(domain_ready.error()); return 1; } + if (!domain.empty()) { + std::vector producerArgs{"subos", "new"}; + for (int i = 3; i < argc; ++i) { + if (std::string_view(argv[i]) == "--domain") { ++i; continue; } + producerArgs.emplace_back(argv[i]); + } + if (auto result = produce_domain_at_creation_(name, rootfs, domain, producerArgs, stream)) + return *result; + } + const int rc = !fromSpec.empty() + ? new_from(name, {}, storage, imageSize, fromSpec, runtime, yesGiven, stream) + : create(name, {}, storage, imageSize, runtime, yesGiven, "-y", stream); + if (rc != 0) return rc; + if (auto r = declare_isolation_at_creation_(name, declared, fromSpec, stream); r != 0) return r; + if (auto r = declare_root_at_creation_(name, rootfs, fromSpec, stream, domain); r != 0) return r; + if (!requestedCarrier.empty() || abi != "native") { + if (auto recorded = record_carrier_(name, {"local", abi}); !recorded) { + stream.emit(ErrorEvent{.code = ErrorCode::Internal, .message = recorded.error(), .recoverable = true}); + return 1; + } } - return create(name, {}, storage, imageSize, runtime, yesGiven, "-y", stream); + return 0; } if (sub == "use") { // Flags supported: @@ -1938,18 +2235,16 @@ int run(int argc, char* argv[], EventStream& stream) { std::string shell_kind = "sh"; bool sandbox = false; std::string sandbox_backend; // "" = auto, "bwrap", "proot" + IsolationArgs iso; // --sandbox=, --net, --allow, ... // M3: --cmd runs a single command non-interactively // and exits with the command's exit code. Works in both shell- // level and sandbox modes. Internally routed to `sh -c ` // (POSIX) or `pwsh -Command ` / `cmd /c ` (Windows). std::string cmd; - // M5: explicit keeper policy overrides (D9). The runtime auto- - // default (storage=image|tmpfs + sandbox + Linux → keeper on, - // TTL=5min) is encoded in keeper::should_auto_keeper. These - // flags let the user override per call: - // --no-keep force disable (one-shot, even if auto would) - // --keep never-expiring (use until `subos stop`) - // --ttl custom idle TTL + // The session's lifetime (design §16, the keeper's replacement): + // --no-keep the session ends with the shell + // --keep it stays until `subos stop` + // --ttl it ends after this long idle bool no_keep = false; bool keep_forever = false; int ttl_sec = 0; // 0 = use default @@ -1959,6 +2254,7 @@ int run(int argc, char* argv[], EventStream& stream) { // already passes /dev and /sys through wholesale). // Ref: .agents/docs/2026-05-22-subos-sandbox-gpu-passthrough.md bool gpu = false; + std::vector> normalizedMounts; for (int i = 3; i < argc; ++i) { std::string a = argv[i]; if (a == "--global") { mode = "global"; } @@ -1972,16 +2268,21 @@ int run(int argc, char* argv[], EventStream& stream) { mode = "shell"; shell_kind = a.substr(8); } - else if (a == "--sandbox") { - sandbox = true; - // Optional backend argument: --sandbox bwrap | --sandbox proot - if (i + 1 < argc) { - std::string next = argv[i + 1]; - if (next == "bwrap" || next == "proot") { - sandbox_backend = next; - ++i; - } + else if (std::string err; + a.starts_with("--sandbox") || a.starts_with("--net") || a.starts_with("--proxy") || a.starts_with("--observe") || a.starts_with("--allow") + || a.starts_with("--fetch") || a.starts_with("--publish") || a.starts_with("--mount") + || a == "--no-degrade") { + auto r = parse_isolation_flag_(a, i, argc, argv, iso, err); + if (r < 0) { usageError(err); return 1; } + if (r == 0) { usageError("unknown option for `xlings subos use`: " + a); return 1; } + if (a == "--mount" || a.starts_with("--mount=")) { + const auto& mount = iso.overrides.mounts.back(); + auto spec = mount.src + (mount.dst.empty() ? "" : ":" + mount.dst); + if (mount.mode_given) spec += mount.rw ? ":rw" : ":ro"; + normalizedMounts.emplace_back(i, a == "--mount" ? spec : "--mount=" + spec); } + sandbox = sandbox || iso.sandbox; + if (!iso.backend.empty()) sandbox_backend = iso.backend; } else if (a == "--cmd" && i + 1 < argc) { cmd = argv[++i]; @@ -1989,11 +2290,7 @@ int run(int argc, char* argv[], EventStream& stream) { else if (a.rfind("--cmd=", 0) == 0) { cmd = a.substr(6); } - // M5 keeper policy flags. The runtime spawning of the keeper - // is wired separately (see keeper.cppm); these flags are - // accepted on the CLI and threaded through for forward - // compatibility. Auto-default (D9) is governed by - // should_auto_keeper() at runtime. + // The session's lifetime (see the declarations above). else if (a == "--no-keep") { no_keep = true; } @@ -2060,6 +2357,11 @@ int run(int argc, char* argv[], EventStream& stream) { if (resolved.selected.empty()) return resolved.exitCode; name = std::move(resolved.selected); + std::vector domainArgs{"subos", "use"}; + domainArgs.insert(domainArgs.end(), argv + 3, argv + argc); + for (const auto& [index, spec] : normalizedMounts) domainArgs[index - 1] = spec; + if (auto result = run_domain_operation_(name, domainArgs, stream)) return *result; + if (mode == "global") { if (!cmd.empty()) { usageError("--cmd is incompatible with --global " @@ -2076,7 +2378,25 @@ int run(int argc, char* argv[], EventStream& stream) { } return use_emit_shell(name, shell_kind, stream); } - return use_spawn_shell(name, stream, sandbox, sandbox_backend, gpu, cmd); + // --keep / --ttl: the session outlives this shell (design §16, the + // keeper's replacement). Start it detached, then join it like any + // later command would. + // Wherever there is a session host (Linux, macOS); on Windows --keep / + // --ttl change nothing and the shell below is the whole entry. + if (!platform::is_windows && sandbox && !no_keep && (keep_forever || ttl_sec > 0) + && !session::find(home_view(), name)) { + if (auto rc = use_detail_::validate_subos_(name, stream); rc != 0) return rc; + use_detail_::apply_subos_env_(name); + auto rc = sandbox::enter(name, stream, sandbox::EnterOptions{ + .backend = sandbox_backend, .gpu = gpu, .env = declared_env_(name), + .ttl = keep_forever ? 0 : ttl_sec, .detached = true, + // The join below announces the entry; once is enough. + .announce = false, + .preset = iso.preset, .overrides = iso.overrides }); + if (rc != 0) return rc; + } + return use_spawn_shell(name, stream, sandbox, sandbox_backend, gpu, cmd, + iso.preset, iso.overrides); } if (sub == "list") return run_list_(stream); if (sub == "remove") { @@ -2090,16 +2410,96 @@ int run(int argc, char* argv[], EventStream& stream) { } if (sub == "info") return run_info_(argc > 3 ? argv[3] : "", stream); if (sub == "stop") { - // M4/D9: stop the auto-keeper for a sandboxed subos. - // Safe to invoke even when no keeper is running — it's a no-op. + // Ends the instance's session (design §12.1). Safe when none runs. + // The pre-session keeper's files are cleared too, for homes that + // still carry them. std::string target = argc > 3 ? argv[3] : std::string{}; if (target.empty()) { int rc = 0; target = pick_subos_or_fail_("stop", stream, usageError, &rc); if (target.empty()) return rc; } - return keeper::stop_keeper(target); + std::vector domainArgs{"subos", "stop", target}; + if (argc > 4) domainArgs.insert(domainArgs.end(), argv + 4, argv + argc); + if (auto result = run_domain_operation_(target, domainArgs, stream)) return *result; + const bool had = session::find(home_view(), target).has_value(); + if (had && !session::stop(home_view(), target)) { + stream.emit(ErrorEvent{ + .code = ErrorCode::Internal, + .message = "the session of '" + target + "' did not stop", + .recoverable = true, + }); + return 1; + } + stop_legacy_keeper_(target); + if (had) log::info("stopped the session of '{}'", target); + else log::info("'{}' has no running session", target); + return 0; + } + if (sub == "ps") { + std::string name; + bool json = false, valid = true; + for (int i = 3; i < argc; ++i) { + const std::string_view arg = argv[i]; + if (arg == "--json") json = true; + else if (!arg.empty() && arg[0] != '-' && name.empty()) name = arg; + else valid = false; + } + if (name.empty()) return run_ps_(argc, argv, stream); + if (!valid) { usageError("usage: xlings subos ps [name] [--json]"); return 1; } + auto resolved = resolve_use_name_(name, stream); + if (resolved.selected.empty()) return resolved.exitCode; + name = resolved.selected; + std::vector domainArgs{"subos", "ps", name}; + if (json) domainArgs.push_back("--json"); + if (auto result = run_domain_operation_(name, domainArgs, stream)) return *result; + const auto live = session::find(home_view(), name); + if (json) { + if (live) std::println(std::cout, "{}", session::to_json(*live).dump()); + } else if (!live) log::info("'{}' has no running session", name); + else { + nlohmann::json table{{"headers", {"SUBOS", "SESSION", "BACKEND", "STARTED", "PID", "MODE"}}, + {"rows", nlohmann::json::array({{live->instance, live->id, live->backend, live->started, + std::to_string(live->supervisor_pid), live->detached ? std::format("detached, ttl {}s", live->ttl) : "attached"}})}}; + stream.emit(DataEvent{"table", table.dump()}); + } + return 0; + } + if (sub == "exec") return run_exec_(argc, argv, stream); + if (sub == "report") return run_report_(argc, argv, stream, usageError); + if (sub == "requests" || sub == "approve" || sub == "deny") + return run_requests_(sub, argc, argv, stream, usageError); + if (sub == "config") return run_config_(argc, argv, stream, usageError); + if (sub == "status") return run_status_(argc, argv, stream, usageError); + if (sub == "doctor") return run_doctor_(argc, argv, stream, usageError); + if (sub == "start") return run_start_(argc, argv, stream, usageError); + if (sub == "cp") return run_cp_(argc, argv, stream, usageError); + if (sub == "log") { + std::string name; + bool valid = true; + for (int i = 3; i < argc; ++i) { + const std::string_view arg = argv[i]; + if (arg == "--json" || arg == "-f" || arg == "--follow") continue; + if (arg == "--kind" || arg == "--session" || arg == "-n" || arg == "--lines") { + if (++i >= argc) { valid = false; break; } + if (arg == "-n" || arg == "--lines") { + try { (void)std::stoul(argv[i]); } catch (...) { valid = false; break; } + } + } else if (!arg.empty() && arg[0] != '-' && name.empty()) name = arg; + else { valid = false; break; } + } + if (valid && !name.empty()) { + std::vector domainArgs{"subos", "log"}; + domainArgs.insert(domainArgs.end(), argv + 3, argv + argc); + if (auto result = run_domain_operation_(name, domainArgs, stream)) return *result; + } + return run_log_(argc, argv, stream, usageError); } + if (sub == "rollback") return run_rollback_(argc, argv, stream, usageError); + if (sub == "boot") return run_boot_(argc, argv, stream, usageError); + if (sub == "export") return run_export_(argc, argv, stream, usageError); + if (sub == "diff") return run_diff_(argc, argv, stream, usageError); + if (sub == "pack") return run_pack_(argc, argv, stream, usageError); // xlings subos runtime [name] // @@ -2138,6 +2538,9 @@ int run(int argc, char* argv[], EventStream& stream) { target = pick_subos_or_fail_("runtime", stream, usageError, &rc); if (target.empty()) return rc; } + std::vector domainArgs{"subos", "runtime", binding, target}; + if (argc > 5) domainArgs.insert(domainArgs.end(), argv + 5, argv + argc); + if (auto result = run_domain_operation_(target, domainArgs, stream)) return *result; const auto dir = Config::subos_dir(target); auto doc = manifest::read_document(dir); if (!doc) { diff --git a/src/core/subos.cppm b/src/core/subos/cmd.cppm similarity index 78% rename from src/core/subos.cppm rename to src/core/subos/cmd.cppm index 0036d8552..c841f5eb0 100644 --- a/src/core/subos.cppm +++ b/src/core/subos/cmd.cppm @@ -1,28 +1,3 @@ -module; - -// System headers used by use_spawn_shell only. `import std;` doesn't -// pull these in, and we want execl/errno (POSIX) or CreateProcess -// (Win32) without #include in the named-module purview (which the -// standard forbids for headers that aren't importable units). -#include // stderr (used by std::println(stderr, ...)) -#if defined(_WIN32) -#define WIN32_LEAN_AND_MEAN -// windows.h defines `min`/`max` as function-like macros, which turns any -// `std::min({a, b, c})` in this module into "too many arguments provided to -// function-like macro invocation" -- an error that names the call site and -// says nothing about where the macro came from. Every other module here that -// includes windows.h already defines this (platform.cppm, platform/windows.cppm, -// platform/target.cppm); this one was the exception, and it cost four -// consecutive red Windows runs. -#define NOMINMAX -#include -#else -#include -#include -#include -#include -#endif - export module xlings.core.subos; import std; @@ -34,11 +9,12 @@ import xlings.runtime; import xlings.core.utils; import xlings.core.xself; import xlings.core.xim.commands; // auto_install_backend_ needs cmd_install -import xlings.core.subos.keeper; -import xlings.core.subos.gpu; -import xlings.core.subos.graphics; +import xlings.subos.gpu; +import xlings.subos.graphics; import xlings.core.subos.sandbox; -import xlings.core.subos.manifest; +import xlings.subos.manifest; +import xlings.subos.policy; +import xlings.subos.roles; // Leaf module (std + json only). Same source for "is this a global option" // that the CLI validator uses, so the two cannot disagree about `--yes`. import xlings.cli.spec; @@ -70,6 +46,11 @@ export struct SubosCandidateView { export SubosCandidateView candidate_view(bool includeToolCount = true); +// The `subos_list` data event: every SubOS with its kind, its role, where it +// runs (carrier, abi) and how it is seen (view). One emitter for the CLI and +// the NDJSON interface. +export int list(EventStream& stream); + export std::vector list_all(); struct CandidateResolution_ { @@ -217,11 +198,7 @@ inline int validate_subos_(const std::string& name, EventStream& stream) { inline std::string rebuild_path_for_subos_(const std::string& orig_path, const fs::path& home_dir, const fs::path& new_bin) { -#if defined(_WIN32) - constexpr char SEP = ';'; -#else - constexpr char SEP = ':'; -#endif + constexpr char SEP = platform::PATH_SEPARATOR; auto subos_root = (home_dir / "subos").string(); std::string out; out.reserve(orig_path.size() + new_bin.string().size() + 1); @@ -377,7 +354,7 @@ inline void drop_loader_coupled_dirs_(std::vector& vars) { v.value += k; } for (const auto& d : dropped) { - std::println(stderr, + std::println(std::cerr, "[xlings] {} — dropped {} (declared by {}): it holds a libc, " "and this variable is inherited by host binaries running under " "the host loader", @@ -422,14 +399,14 @@ inline void report_injected_env_(const std::string& subosName, for (const auto& v : vars) providers.insert(v.providers.begin(), v.providers.end()); - std::println(stderr, "[xlings] subos {}: {} env var(s) from {} package(s)", + std::println(std::cerr, "[xlings] subos {}: {} env var(s) from {} package(s)", subosName, vars.size(), providers.size()); for (const auto& v : vars) { if (v.unresolved) { - std::println(stderr, "[xlings] {} — unresolved path, skipped " + std::println(std::cerr, "[xlings] {} — unresolved path, skipped " "(run `xlings self doctor`)", v.var); } else if (v.conflicted) { - std::println(stderr, "[xlings] {} — declared by {} packages, " + std::println(std::cerr, "[xlings] {} — declared by {} packages, " "conflicting", v.var, v.providers.size()); } } @@ -496,7 +473,9 @@ int use_spawn_shell(const std::string& name, EventStream& stream, bool sandbox = false, const std::string& sandbox_backend = "", bool gpu = false, - const std::string& cmd = ""); + const std::string& cmd = "", + std::optional preset = std::nullopt, + const policy::Overrides& overrides = {}); // Back-compat single-arg entry point: keeps existing callers (anyone who // imports xlings::subos::use directly) on the legacy global behavior. @@ -516,6 +495,83 @@ export int remove(const std::string& name, bool yes, std::string_view yesSpellin export std::optional info(const std::string& name); +// ── Shared by this module's implementation units ───────────────────── +// +// src/core/subos/cmd.cpp lifecycle (new / use / list / info / remove) and `run` +// src/core/subos/run.cpp exec, start, cp, and the isolation flags +// src/core/subos/configure.cpp config, status, doctor, policy packages +// src/core/subos/audit.cpp requests / approve / deny, report, ps, log +// src/core/subos/root/root_cmd.cpp new --rootfs, rollback, boot, export, diff, pack +// +// Not exported: they are the module's own. + +CandidateResolution_ resolve_candidate_(std::string_view query); +void emit_candidates_(EventStream& stream, const CandidateResolution_& resolution, + std::string_view query, std::string_view hint = {}); +UseNameResolution_ resolve_use_name_(std::string_view query, EventStream& stream); +std::string pick_subos_or_fail_(std::string_view verb, EventStream& stream, + const std::function& report, int* rc); +// The instance's declared environment (its packages' env), for an entry. +std::map declared_env_(const std::string& name); + +// The isolation flags every entry shares (design §10): `--sandbox`, +// `--sandbox=`, `--sandbox[=| ]`, +// and the tighten-only overrides `--net`, `--fetch`, `--allow`, `--no-degrade`. +struct IsolationArgs { + bool sandbox { false }; + std::string backend; + std::optional preset; + policy::Overrides overrides; + std::vector publish; // --publish HOST:SANDBOX +}; +// 1 = consumed, 0 = not ours, -1 = malformed (`err` says why). +int parse_isolation_flag_(std::string_view a, int& i, int argc, char* argv[], + IsolationArgs& x, std::string& err); +// "K=V" -> {K, V}; nullopt without '='. +std::optional> split_env_(std::string_view kv); +void stop_legacy_keeper_(const std::string& name); +int declare_isolation_at_creation_(const std::string& name, std::optional preset, + const std::string& from, EventStream& stream); + +// A declared instance -- a policy, or a root -- is entered sandboxed however +// it is entered (design §10; part 2 §3.1). +bool enters_sandboxed_(const std::string& name); +// The role table (part 2 §8.3), asked by every command that could hurt the +// running system: false after emitting the refusal and what to do instead. +bool role_allows_(roles::Op op, const std::string& name, EventStream& stream); +bool role_allows_at_(const fs::path& home, roles::Op op, const std::string& name, EventStream& stream); +// A subos-type xpkg's payload (its template), installed when missing; empty +// after emitting why it could not be. +fs::path resolve_base_package_(const std::string& spec, EventStream& stream); +// `subos new --rootfs`, and a fork of a root (part 2 §11). +std::expected preflight_domain_at_creation_(std::string_view name, bool rootfs, + std::string_view domain, std::string_view fromSpec); +std::optional produce_domain_at_creation_(const std::string& name, bool rootfs, + std::string_view domain, std::span arguments, EventStream& stream); +std::optional run_domain_operation_(const std::string& name, + std::span arguments, EventStream& stream); +int declare_root_at_creation_(const std::string& name, bool rootfs, const std::string& from, + EventStream& stream, std::string_view domain = {}); + +using UsageError = std::function; +int run_exec_(int argc, char* argv[], EventStream& stream); +int run_start_(int argc, char* argv[], EventStream& stream, const UsageError& usageError); +int run_cp_(int argc, char* argv[], EventStream& stream, const UsageError& usageError); +int run_config_(int argc, char* argv[], EventStream& stream, const UsageError& usageError); +int run_doctor_(int argc, char* argv[], EventStream& stream, const UsageError& usageError); +int run_status_(int argc, char* argv[], EventStream& stream, const UsageError& usageError); +int run_requests_(std::string_view sub, int argc, char* argv[], EventStream& stream, + const UsageError& usageError); +int run_report_(int argc, char* argv[], EventStream& stream, const UsageError& usageError); +int run_ps_(int argc, char* argv[], EventStream& stream); +int run_log_(int argc, char* argv[], EventStream& stream, const UsageError& usageError); +// src/core/subos/root/root_cmd.cpp: the root projection's commands. +int run_rollback_(int argc, char* argv[], EventStream& stream, const UsageError& usageError); +int run_boot_(int argc, char* argv[], EventStream& stream, const UsageError& usageError); +int run_export_(int argc, char* argv[], EventStream& stream, const UsageError& usageError); +int run_diff_(int argc, char* argv[], EventStream& stream, const UsageError& usageError); +int run_pack_(int argc, char* argv[], EventStream& stream, const UsageError& usageError); + export int run(int argc, char* argv[], EventStream& stream); } // namespace xlings::subos diff --git a/src/core/subos/configure.cpp b/src/core/subos/configure.cpp new file mode 100644 index 000000000..8515e1597 --- /dev/null +++ b/src/core/subos/configure.cpp @@ -0,0 +1,624 @@ +// `subos config / status / doctor`: what an instance may do, what this host +// gives it, and whether it is healthy (module xlings.core.subos). +module xlings.core.subos; + +import xlings.subos.roles; +import xlings.subos.rootfs; +import xlings.core.subos.root; +import std; +import xlings.core.config; +import xlings.core.home_config; +import xlings.libs.json; +import xlings.core.log; +import xlings.platform; +import xlings.runtime; +import xlings.core.utils; +import xlings.core.xself; +import xlings.core.xvm.types; +import xlings.core.xvm.db; +import xlings.core.xim.commands; +import xlings.subos.gpu; +import xlings.subos.graphics; +import xlings.core.subos.sandbox; +import xlings.subos.manifest; +import xlings.cli.spec; +import xlings.i18n; +import xlings.core.confirm; +import xlings.core.destructive_log; +import xlings.subos.userdata; +import xlings.subos.model; +import xlings.core.subos.ports; +import xlings.subos.session; +import xlings.subos.policy; +import xlings.subos.network; +import xlings.subos.policy_store; +import xlings.subos.broker; +import xlings.observe; +import xlings.core.home; +import xlings.libs.sha256; +import xlings.core.version_order; + +namespace xlings::subos { + +// A policy package (design §7.3): `ns:name[@version]`, an xpkg whose payload +// carries policy.json. Installed when absent (always, for an upgrade), then +// read from the newest matching version and locked by sha256. On a system +// install, /etc/xlings/config.json's `subos_policy_sources` (globs) limits +// which packages an owner may select. +std::expected> +select_policy_package_(const std::string& ref, bool upgrade) { + const auto sys = home::read_system_config(); + if (auto it = sys.find("subos_policy_sources"); it != sys.end() && it->is_array()) { + bool allowed = false; + for (auto& g : *it) + if (g.is_string() && policy::glob_match(g.get(), ref)) allowed = true; + if (!allowed) + return std::unexpected(std::pair{13, std::format( + "E_PERMISSION: {} is not among the policy packages {} allows ({})", + ref, home::system_config_path().string(), it->dump())}); + } + const auto colon = ref.find(':'); + const auto at = ref.find('@', colon); + const auto ns = ref.substr(0, colon); + const auto name = ref.substr(colon + 1, at == std::string::npos ? std::string::npos : at - colon - 1); + const auto want = at == std::string::npos ? std::string{} : ref.substr(at + 1); + // Each part becomes a path component under data/xpkgs: names only. + auto plain = [](std::string_view v) { + return v.find('/') == std::string_view::npos && v.find('\\') == std::string_view::npos + && v != "." && v != ".." && v.find("..") == std::string_view::npos; + }; + if (ns.empty() || name.empty() || !plain(ns) || !plain(name) || !plain(want)) + return std::unexpected(std::pair{2, std::format("'{}': a policy package is ns:name[@version]", ref)}); + const auto root = Config::paths().homeDir / "data" / "xpkgs" / (ns + "-x-" + name); + + auto newest = [&]() -> std::optional { + std::optional best; + std::error_code ec; + if (!fs::is_directory(root, ec)) return best; + for (auto& e : platform::dir_entries(root)) { + const auto v = e.path().filename().string(); + if (!want.empty() && v != want && !v.starts_with(want + ".")) continue; + if (!fs::is_regular_file(e.path() / "policy.json", ec)) continue; + if (!best || version_order::compare(v, best->filename().string()) > 0) best = e.path(); + } + return best; + }; + auto dir = newest(); + if (!dir || upgrade) { + log::info("installing the policy package {}...", ref); + const auto rc = platform::run_argv({platform::get_executable_path().string(), + "install", ref, "-y"}); + if (rc != 0) + return std::unexpected(std::pair{rc, std::format("could not install {} (exit {})", ref, rc)}); + dir = newest(); + } + if (!dir) + return std::unexpected(std::pair{2, std::format( + "{} has no policy.json in its payload: not a subos-policy package", ref)}); + const auto file = *dir / "policy.json"; + const auto sha = sha256::hex_file(file); + std::ifstream in(file, std::ios::binary); + auto doc = nlohmann::json::parse(in, nullptr, false); + if (!sha || doc.is_discarded()) + return std::unexpected(std::pair{2, std::format("{}: not readable JSON", file.string())}); + const auto from = std::format("{}:{}@{}", ns, name, dir->filename().string()); + auto p = policy::from_package(doc, ref, {from, *sha}); + if (!p) return std::unexpected(std::pair{2, std::format("{}: {}", from, p.error())}); + if (auto supported = policy::check_client(*p, Info::VERSION); !supported) + return std::unexpected(std::pair{2, std::format("{}: {}", from, supported.error())}); + return std::move(*p); +} + +// `subos config [changes]`: the owner's declaration of what the +// instance may do (design §7). Written outside the instance; every change is +// audited with its diff. No change prints the policy in force. +int run_config_(int argc, char* argv[], EventStream& stream, + const std::function& usageError) { + std::string name; + int nameIndex = 0; + std::vector> normalizedMounts; + bool json = false, reset = false; + std::optional preset; + std::optional package; // --sandbox ns:name[@version] + bool upgrade = false; // --policy-upgrade + std::optional net; + std::optional proxy; + std::optional fetch, index_update; + std::optional observe; + std::optional no_degrade; + std::set allow, disallow, env_pass; + std::optional> grants_allowed; + std::vector mounts; + std::set unmounts; + bool changed = false; + auto value_of = [&](int& i, std::string_view a, std::string_view flag) -> std::optional { + if (a.starts_with(std::string(flag) + "=")) return std::string(a.substr(flag.size() + 1)); + if (a == flag && i + 1 < argc) return std::string(argv[++i]); + return std::nullopt; + }; + auto split = [](std::string_view list) { + std::set out; + while (!list.empty()) { + auto c = list.find(','); + if (auto item = list.substr(0, c); !item.empty()) out.insert(std::string(item)); + if (c == std::string_view::npos) break; + list.remove_prefix(c + 1); + } + return out; + }; + for (int i = 3; i < argc; ++i) { + std::string_view a = argv[i]; + std::optional v; + if (a == "--json") json = true; + else if (a == "--reset") { reset = true; changed = true; } + else if ((v = value_of(i, a, "--sandbox"))) { + if (policy::is_package_ref(*v)) package = *v; + else { + preset = policy::preset_from_string(*v); + if (!preset || *preset == policy::Preset::Legacy) { + usageError("--sandbox expects dev, private, locked or a policy package (ns:name[@version])"); + return 1; + } + } + changed = true; + } + else if (a == "--policy-upgrade") { upgrade = true; changed = true; } + else if ((v = value_of(i, a, "--net"))) { + net = policy::net_from_string(*v); + if (!net) { usageError("--net expects host, nat, none or proxy"); return 1; } + changed = true; + } + else if ((v = value_of(i, a, "--proxy"))) { + const auto endpoint = network::parse_proxy(*v); + if (!endpoint) { usageError(endpoint.error()); return 1; } + proxy = *v; + net = policy::Net::Proxy; + changed = true; + } + else if ((v = value_of(i, a, "--fetch"))) { + fetch = policy::fetch_from_string(*v); + if (!fetch) { usageError("--fetch expects auto, ask or deny"); return 1; } + changed = true; + } + else if ((v = value_of(i, a, "--index-update"))) { + index_update = policy::fetch_from_string(*v); + if (!index_update) { usageError("--index-update expects auto, ask or deny"); return 1; } + changed = true; + } + else if ((v = value_of(i, a, "--observe"))) { + observe = policy::observe_from_string(*v); + if (!observe) { usageError("--observe expects off, basic, standard or full"); return 1; } + changed = true; + } + else if ((v = value_of(i, a, "--allow"))) { auto g = split(*v); allow.insert(g.begin(), g.end()); changed = true; } + else if ((v = value_of(i, a, "--disallow"))) { auto g = split(*v); disallow.insert(g.begin(), g.end()); changed = true; } + else if ((v = value_of(i, a, "--grants-allowed"))) { grants_allowed = split(*v); changed = true; } + else if ((v = value_of(i, a, "--env-pass"))) { auto e = split(*v); env_pass.insert(e.begin(), e.end()); changed = true; } + else if ((v = value_of(i, a, "--mount"))) { + std::error_code ec; + auto m = policy::parse_mount(*v, utils::get_env_or_default("HOME"), + fs::current_path(ec).generic_string()); + if (!m) { usageError(m.error()); return 1; } + auto spec = m->src + (m->dst.empty() ? "" : ":" + m->dst); + if (m->mode_given) spec += m->rw ? ":rw" : ":ro"; + normalizedMounts.emplace_back(i, a == "--mount" ? spec : "--mount=" + spec); + mounts.push_back(std::move(*m)); + changed = true; + } + else if ((v = value_of(i, a, "--unmount"))) { unmounts.insert(*v); changed = true; } + else if (a == "--no-degrade") { no_degrade = true; changed = true; } + else if (a == "--degrade") { no_degrade = false; changed = true; } + else if (!a.empty() && a[0] != '-' && name.empty()) { name = std::string(a); nameIndex = i; } + else { usageError("unknown option for `xlings subos config`: " + std::string(a)); return 1; } + } + if (name.empty()) { usageError("missing for: xlings subos config"); return 1; } + auto resolved = resolve_use_name_(name, stream); + if (resolved.selected.empty()) return resolved.exitCode; + name = resolved.selected; + std::vector domainArgs{"subos", "config"}; + domainArgs.insert(domainArgs.end(), argv + 3, argv + argc); + domainArgs[nameIndex - 1] = name; + for (const auto& [index, spec] : normalizedMounts) domainArgs[index - 1] = spec; + if (auto result = run_domain_operation_(name, domainArgs, stream)) return *result; + const auto home = home_view(); + + auto current = policy_store::read(home, name, Info::VERSION); + if (!current) { + stream.emit(ErrorEvent{ .code = ErrorCode::InvalidInput, .message = current.error(), + .recoverable = true, .hint = "fix the file, or: xlings subos config " + name + " --reset" }); + if (!reset) return 1; + } + const policy::Policy before = current && *current ? **current : policy::legacy(); + + if (!changed) { + auto doc = policy::to_json(before); + doc["source"] = current && *current ? "file" : "default"; + std::println(std::cout, "{}", json ? doc.dump() : doc.dump(2)); + return 0; + } + + // The host is what grants isolation, not something isolated (part 2 §8.3). + if (!role_allows_(roles::Op::Policy, name, stream)) return 1; + + // Only the owner, outside the sandbox (design §8.1): the instance does not + // get to rewrite what it is allowed. + const bool inside = utils::get_env_or_default("XLINGS_SUBOS_MODE") == "sandbox"; + auto decision = policy::decide(before, {.kind = "policy_change", .from_inside = inside, .instance = name}); + if (decision.action != policy::Action::Allow) { + stream.emit(ErrorEvent{ .code = ErrorCode::Permission, + .message = "E_PERMISSION: " + decision.reason, .recoverable = false, + .hint = "outside the sandbox: " + decision.owner_command }); + return 13; + } + + if (reset) { + std::error_code ec; + fs::remove(home.policy_file(name), ec); + observe::append(home.logs_dir(name) / "events.ndjson", observe::Event{ + .kind = observe::Kind::Lifecycle, + .fields = {{"event", "policy-reset"}, {"instance", name}}}); + log::info("'{}' has no policy file now (an undeclared instance)", name); + return 0; + } + + if (upgrade && !package) { + if (!before.package) { + usageError(std::format("'{}' does not use a policy package", name)); + return 1; + } + package = before.extends; + } + std::optional selected; + if (package) { + auto p = select_policy_package_(*package, upgrade); + if (!p) { + stream.emit(ErrorEvent{ .code = p.error().first == 13 ? ErrorCode::Permission : ErrorCode::InvalidInput, + .message = p.error().second, .recoverable = false }); + return p.error().first; + } + selected = std::move(*p); + // What the owner is choosing, against the preset it builds on: a + // package can loosen as well as tighten, and that is shown, not hidden. + if (!json) { + const auto base = policy::preset(selected->preset); + log::info("{} ({}), compared with the built-in {}:", *package, + selected->package->from, policy::to_string(selected->preset)); + for (auto& c : policy::diff(base, *selected)) + if (!c.starts_with("/extends") && !c.starts_with("/resolved")) log::info(" {}", c); + } + } + + policy::Policy after = selected ? *selected : preset ? policy::preset(*preset) : before; + if (!selected && !preset && before.preset == policy::Preset::Legacy) { + // The first declaration of an undeclared instance starts from dev. + after = policy::preset(policy::Preset::Dev); + } + if (net) after.net = *net; + if (proxy) after.proxy = *proxy; + if (fetch) after.fetch = *fetch; + if (index_update) after.index_update = *index_update; + if (observe) after.observe = *observe; + if (no_degrade) after.no_degrade = *no_degrade; + if (grants_allowed) { + after.grants_allowed.clear(); + for (auto& g : *grants_allowed) after.grants_allowed.insert(g); + } + for (auto& g : allow) { + if (std::ranges::find(policy::kGrants, std::string_view(g)) == policy::kGrants.end()) { + usageError("--allow " + g + ": not a grant"); return 1; + } + after.grants.insert(g); + } + for (auto& g : disallow) { after.grants.erase(g); after.grants_allowed.erase(g); } + for (auto& e : env_pass) + if (std::ranges::find(after.env_pass, e) == after.env_pass.end()) after.env_pass.push_back(e); + std::erase_if(after.mounts, [&](const policy::Mount& m) { + return unmounts.contains(m.src) || unmounts.contains(m.dst); + }); + for (auto m : mounts) { + if (!m.mode_given && after.mounts_ro_default) m.rw = false; + std::erase_if(after.mounts, [&](const policy::Mount& x) { return x.src == m.src; }); + after.mounts.push_back(std::move(m)); + } + + if (auto why = policy::not_enforced(after)) { usageError(*why); return 1; } + if (auto w = policy_store::write(home, name, after); !w) { + stream.emit(ErrorEvent{ .code = ErrorCode::Internal, .message = w.error(), .recoverable = true }); + return 1; + } + auto changes = policy::diff(before, after); + observe::append(home.logs_dir(name) / "events.ndjson", observe::Event{ + .kind = observe::Kind::Lifecycle, + .fields = {{"event", "policy-change"}, {"instance", name}, {"diff", changes}}}); + if (json) { + std::println(std::cout, "{}", nlohmann::json{{"instance", name}, {"diff", changes}, + {"policy", policy::to_json(after)}}.dump()); + } else { + log::info("'{}' policy ({}):", name, policy::to_string(after.preset)); + for (auto& c : changes) log::info(" {}", c); + if (session::find(home, name)) + log::info("the running session keeps its isolation until `xlings subos stop {}`", name); + } + return 0; +} + +// `subos doctor [] [--json] [--fix]` (design §14, C25): is each +// instance able to do what it declares, on this host? Findings, not repairs: +// `--fix` only re-installs a selected policy package's missing payload +// (derived data). A record of a session whose supervisor died is cleaned by +// reading it -- that is what every reader of it does -- and is reported. +int run_doctor_(int argc, char* argv[], EventStream& stream, + const std::function& usageError) { + std::string only; + int nameIndex = 0; + bool json = false, fix = false; + for (int i = 3; i < argc; ++i) { + std::string_view a = argv[i]; + if (a == "--json") json = true; + else if (a == "--fix") fix = true; + else if (!a.empty() && a[0] != '-' && only.empty()) { only = std::string(a); nameIndex = i; } + else { usageError("unknown option for `xlings subos doctor`: " + std::string(a)); return 1; } + } + std::vector names; + if (!only.empty()) { + auto resolved = resolve_use_name_(only, stream); + if (resolved.selected.empty()) return resolved.exitCode; + std::vector domainArgs{"subos", "doctor"}; + domainArgs.insert(domainArgs.end(), argv + 3, argv + argc); + domainArgs[nameIndex - 1] = resolved.selected; + if (auto result = run_domain_operation_(resolved.selected, domainArgs, stream)) return *result; + names.push_back(resolved.selected); + } else { + for (auto& n : Config::list_subos_names()) if (n != "current") names.push_back(n); + } + const auto home = home_view(); + nlohmann::json report{{"instances", nlohmann::json::array()}}; + int errors = 0; + for (const auto& name : names) { + nlohmann::json findings = nlohmann::json::array(); + auto add = [&](std::string check, std::string level, std::string detail, std::string fix_hint = {}) { + if (level == "error") ++errors; + findings.push_back({{"check", std::move(check)}, {"level", std::move(level)}, + {"detail", std::move(detail)}, {"fix", std::move(fix_hint)}}); + }; + // The manifest: what the instance holds. Unreadable is not empty. + { + const auto manifest = Config::paths().homeDir / "subos" / name / ".xlings.json"; + std::error_code ec; + if (fs::exists(manifest, ec)) { + std::ifstream in(manifest, std::ios::binary); + if (nlohmann::json::parse(in, nullptr, false).is_discarded()) + add("manifest", "error", manifest.string() + " does not parse", + "restore it; nothing rewrites a file it cannot read"); + } + } + // The policy: declared, readable, enforceable by this version. + policy::Policy pol = policy::legacy(); + auto file = policy_store::read(home, name); + bool supported = true; + if (!file) { + add("policy", "error", file.error(), "xlings subos config " + name + " --reset"); + } else if (*file) { + pol = **file; + if (auto compatible = policy::check_client(pol, Info::VERSION); !compatible) { + supported = false; + add("client", "error", compatible.error(), "xlings self update"); + } + add("policy", "ok", std::format("{} ({})", home.policy_file(name).string(), policy::to_string(pol.preset))); + } else { + add("policy", "ok", "none declared: the instance enters as it always has"); + } + // Can it enter here, as declared? Not at all while the policy + // cannot be read: entry refuses rather than guess (fail closed). + EventStream quiet; + const auto eff = sandbox::preview(name, pol, quiet); + if (!file || !supported) { + add("enters", "error", "refused until the policy is readable and the client meets min_client", ""); + } else if (eff.value("enters", false)) { + std::string degraded; + for (auto& d : eff["spec"]["degraded"]) + degraded += (degraded.empty() ? "" : "; ") + d.value("dimension", "") + ": " + d.value("reason", ""); + // The probe is the entry itself (design §18): the same spec, the + // same supervisor, with `true` for the command. + std::string cmd = platform::shell_quote(platform::get_executable_path().string()); + for (const auto* a : {"subos", "exec", name.c_str(), "--sandbox", "--"}) + cmd += " " + platform::shell_quote(a); + cmd += platform::is_windows ? " cmd /c exit 0" : " true"; + auto [status, output] = platform::run_command_capture(cmd + " 2>&1"); + if (status != 0) { + while (!output.empty() && (output.back() == '\n' || output.back() == '\r')) output.pop_back(); + add("enters", "error", std::format("backend {} -- entering failed: {}", + eff["spec"].value("backend", "?"), output.empty() ? std::format("status {}", status) : output), + "xlings self doctor --isolation"); + } else { + add("enters", degraded.empty() ? "ok" : "warn", + std::format("backend {}, entered{}", eff["spec"].value("backend", "?"), + degraded.empty() ? std::string{} : " -- not in effect: " + degraded)); + } + } else { + std::string why, fixes; + for (auto& m : eff["missing"]) { + why += (why.empty() ? "" : "; ") + m.value("dimension", "") + ": " + m.value("reason", ""); + if (auto f = m.value("fix", ""); !f.empty() && fixes.find(f) == std::string::npos) + fixes += (fixes.empty() ? "" : "; ") + f; + } + add("enters", "warn", "cannot enter on this host: " + why, fixes); + } + // A selected policy package: still the payload it was locked to? + if (pol.package) { + const auto& from = pol.package->from; + const auto colon = from.find(':'), at = from.rfind('@'); + const auto dir = Config::paths().homeDir / "data" / "xpkgs" + / (from.substr(0, colon) + "-x-" + from.substr(colon + 1, at - colon - 1)) / from.substr(at + 1); + auto sha = sha256::hex_file(dir / "policy.json"); + if (!sha && fix) { + (void)platform::run_argv({platform::get_executable_path().string(), "install", from, "-y"}); + sha = sha256::hex_file(dir / "policy.json"); + } + if (!sha) + add("package", "warn", from + " is not installed (the instance keeps its copy of the policy)", + "xlings subos doctor " + name + " --fix"); + else if (*sha != pol.package->sha256) + add("package", "warn", from + " changed since it was selected (sha256 " + sha->substr(0, 12) + "...)", + "xlings subos config " + name + " --policy-upgrade"); + else + add("package", "ok", from + " (sha256 matches)"); + } + // Sessions: a record whose supervisor is gone is removed by reading it. + { + std::error_code ec; + const bool recorded = fs::exists(home.run_dir(name) / "session.json", ec); + auto live = session::find(home, name); + if (live) add("session", "ok", "running (" + live->id + ")"); + else if (recorded) add("session", "ok", "a record of a session that had ended was removed"); + } + report["instances"].push_back({{"instance", name}, {"findings", findings}}); + } + { + EventStream quiet; + report["gates"] = sandbox::preview(names.empty() ? std::string("default") : names.front(), + policy::legacy(), quiet)["gates"]; + } + report["errors"] = errors; + if (json) { + std::println(std::cout, "{}", report.dump()); + return errors ? 1 : 0; + } + std::println(std::cout, "this host:"); + for (auto& g : report["gates"]) + std::println(std::cout, " {:<14} {:<9} {}", g.value("gate", ""), + g.value("supported", false) ? g.value("enforced", "") : "no", g.value("reason", "")); + for (auto& inst : report["instances"]) { + std::println(std::cout, "subos {}", inst.value("instance", "")); + for (auto& f : inst["findings"]) { + const auto level = f.value("level", ""); + const char* mark = level == "ok" ? "✓" : level == "warn" ? "!" : "✗"; + std::println(std::cout, " {} {:<9} {}{}", mark, f.value("check", ""), f.value("detail", ""), + f.value("fix", "").empty() ? "" : "\n -> " + f.value("fix", "")); + } + } + return errors ? 1 : 0; +} + +// `subos status `: what the instance asks for, what this host gives it, +// and why not when it does not (design §14). +int run_status_(int argc, char* argv[], EventStream& stream, + const std::function& usageError) { + std::string name; + bool json = false; + for (int i = 3; i < argc; ++i) { + std::string_view a = argv[i]; + if (a == "--json") json = true; + else if (!a.empty() && a[0] != '-' && name.empty()) name = std::string(a); + else { usageError("unknown option for `xlings subos status`: " + std::string(a)); return 1; } + } + if (name.empty()) { + int rc = 0; + name = pick_subos_or_fail_("status", stream, usageError, &rc); + if (name.empty()) return rc; + } + auto resolved = resolve_use_name_(name, stream); + if (resolved.selected.empty()) return resolved.exitCode; + name = resolved.selected; + std::vector domainArgs{"subos", "status", name}; + if (json) domainArgs.push_back("--json"); + if (auto result = run_domain_operation_(name, domainArgs, stream)) return *result; + const auto home = home_view(); + auto file = policy_store::read(home, name, Info::VERSION); + nlohmann::json out{{"instance", name}}; + policy::Policy pol = policy::legacy(); + if (!file) { + out["policy_error"] = file.error(); + } else if (*file) { + pol = **file; + out["policy_source"] = "file"; + } else { + out["policy_source"] = "default"; + } + out["requested"] = policy::to_json(pol); + EventStream quiet; + if (!file) { + out["effective"] = {{"enters", false}, {"missing", nlohmann::json::array({ + {{"dimension", "policy"}, {"reason", file.error()}, {"fix", "xlings self update"}}})}}; + } else { + out["effective"] = sandbox::preview(name, pol, quiet); + } + if (auto live = session::find(home, name)) out["session"] = session::to_json(*live); + // What it is to this machine (part 2 §3.4): a view, or a root -- its + // generation, what it would boot, whether it is the host. + { + const auto declared_kind = subos_root::read_kind(home.home, name); + const auto declared_role = subos_root::read_role(home.home, name); + if (!declared_kind || !declared_role) { + stream.emit(ErrorEvent{ .code = ErrorCode::InvalidInput, + .message = !declared_kind ? declared_kind.error() : declared_role.error(), + .recoverable = false }); + return 1; + } + const auto kind = *declared_kind; + const auto role = *declared_role; + nlohmann::json root{{"kind", std::string(roles::to_string(kind))}, {"host", role.host}, + {"boot_entry", role.boot_entry}}; + if (kind == roles::Kind::Rootfs) { + const auto dir = home.instance(name); + if (auto g = rootfs::current(dir)) { + root["generation"] = *g; + if (auto info = rootfs::info(dir, *g)) { + root["links"] = info->links; + root["conflicts"] = nlohmann::json::array(); + for (auto& c : info->conflicts) + root["conflicts"].push_back({{"path", c.rel}, {"kept", c.kept}, {"dropped", c.dropped}}); + } + } + root["generations"] = rootfs::generations(dir); + root["tree"] = subos_root::tree_of(home.home, name).string(); + } + out["root"] = std::move(root); + } + if (json) { + std::println(std::cout, "{}", out.dump()); + return 0; + } + const auto& eff = out["effective"]; + std::println(std::cout, "subos {} ({})", name, out.value("policy_source", "invalid policy")); + if (const auto& r = out["root"]; r.value("kind", "view") == "rootfs") { + std::println(std::cout, " root generation {} of {}{}{} ({})", r.value("generation", 0), + r["generations"].size(), r.value("host", false) ? ", the running host" : "", + r.value("boot_entry", false) ? ", a boot entry" : "", r.value("tree", "")); + for (auto& c : r.value("conflicts", nlohmann::json::array())) + std::println(std::cout, " ! /{}: {} kept, {} dropped", c.value("path", ""), c.value("kept", ""), + c.value("dropped", "")); + } + if (out.contains("policy_error")) std::println(std::cout, " policy: {}", out["policy_error"].get()); + std::println(std::cout, " requested preset={} net={} fetch={} observe={} identity={}", + policy::to_string(pol.preset), policy::to_string(pol.net), + policy::to_string(pol.fetch), policy::to_string(pol.observe), + pol.identity == policy::Identity::Neutral ? "neutral" : "host"); + if (eff.value("enters", false)) { + const auto& sp = eff["spec"]; + std::println(std::cout, " effective backend={} pid={} net={} hostname={}", sp.value("backend", "?"), + sp["unshare"].value("pid", false) ? "private" : "host", + sp["unshare"].value("net", false) ? "private" : "host", + sp.value("hostname", "host")); + for (auto& d : sp["degraded"]) + std::println(std::cout, " ! {} not in effect: {}", d.value("dimension", ""), d.value("reason", "")); + } else { + std::println(std::cout, " cannot enter on this host:"); + for (auto& m : eff["missing"]) + std::println(std::cout, " \u2717 {}: {}{}", m.value("dimension", ""), m.value("reason", ""), + m.value("fix", "").empty() ? "" : " (" + m.value("fix", "") + ")"); + } + std::println(std::cout, " platform:"); + for (auto& g : eff["gates"]) { + std::println(std::cout, " {:<14} {:<9} {}{}", g.value("gate", ""), + g.value("supported", false) ? g.value("enforced", "") : "no", + g.value("reason", ""), + g.value("route", "").empty() || g.value("supported", false) ? "" : " -> " + g.value("route", "")); + } + if (out.contains("session")) + std::println(std::cout, " session {} ({})", out["session"].value("id", ""), + out["session"].value("detached", false) ? "detached" : "attached"); + return 0; +} + +} // namespace xlings::subos diff --git a/src/core/subos/domain/domain_producer.cpp b/src/core/subos/domain/domain_producer.cpp new file mode 100644 index 000000000..a0f49349e --- /dev/null +++ b/src/core/subos/domain/domain_producer.cpp @@ -0,0 +1,371 @@ +module xlings.core.home.domain_producer; +import std; +import xlings.core.home; +import xlings.core.entry_binary; +import xlings.core.xself.init; +import xlings.core.home_config; +import xlings.core.home.prefix_domain; +import xlings.core.home.domain_producer_source; +import xlings.libs.json; +import xlings.platform; +import xlings.subos.home_view; +import xlings.subos.rootfs; +import xlings.subos.caps; +import xlings.subos.ports; + +namespace xlings::home::domain_producer { +namespace { +using Json = nlohmann::json; +std::expected name_(std::string_view name) { + if (name == "current") return std::unexpected("'current' is a reserved subos name"); + if (name.empty() || name == "." || name == ".." || + name.find_first_of("/\\") != std::string_view::npos) + return std::unexpected(std::format("invalid subos name: '{}'", name)); + return {}; +} +std::expected present_(const fs::path& path) { + std::error_code ec; + auto status = fs::symlink_status(path, ec); + if (status.type() == fs::file_type::not_found && (!ec || ec == std::errc::no_such_file_or_directory)) return false; + if (ec) return std::unexpected(path.string() + ": cannot inspect domain state: " + ec.message()); + return true; +} +std::expected directory_(const fs::path& path) { + std::error_code ec; + const auto status = fs::symlink_status(path, ec); + if (ec || !fs::is_directory(status)) return std::unexpected(path.string() + ": domain path is not a real directory"); + platform::read_symlink(path, ec); + if (!ec) return std::unexpected(path.string() + ": domain path redirects through a link"); + return {}; +} +std::expected mkdir_(const fs::path& path) { + auto exists = present_(path); + if (!exists) return std::unexpected(exists.error()); + if (*exists) return directory_(path); + std::error_code ec; + if (!fs::create_directory(path, ec) || ec) return std::unexpected(path.string() + ": domain directory reservation conflict"); + return {}; +} +std::expected write_(const fs::path& path, const Json& doc) { + std::error_code ec; + fs::path stage; + for (int attempt = 0; attempt < 100; ++attempt) { + auto candidate = path.parent_path() / (".xlings-domain-write-" + std::to_string(std::random_device{}())); + if (fs::create_directory(candidate, ec)) { stage = std::move(candidate); break; } + if (ec && ec != std::errc::file_exists) return std::unexpected(path.string() + ": cannot reserve metadata staging"); + } + if (stage.empty()) return std::unexpected(path.string() + ": cannot reserve metadata staging"); + struct Cleanup { + fs::path path; + ~Cleanup() { std::error_code ignored; fs::remove(path / "document", ignored); fs::remove(path, ignored); } + } cleanup{stage}; + try { platform::write_file_atomic((stage / "document").string(), doc.dump(2)); } + catch (const std::exception& error) { return std::unexpected(path.string() + ": " + error.what()); } + return platform::rename_no_replace(stage / "document", path); +} +std::expected regular_(const fs::path& path) { + std::error_code ec; + if (!fs::is_regular_file(fs::symlink_status(path, ec)) || ec) + return std::unexpected(path.string() + ": domain state must be a regular file"); + return {}; +} +std::expected committed_(const fs::path& instance) { + const auto file = instance / ".xlings.json"; + if (auto regular = regular_(file); !regular) return regular; + auto workspace = read_json_for_update(file); + if (!workspace) return std::unexpected(workspace.error()); + if (!workspace->contains("workspace") || !(*workspace)["workspace"].is_object()) + return std::unexpected(file.string() + ": producer workspace is missing or invalid"); + auto generation = subos::rootfs::current(instance); + if (!generation) return std::unexpected(instance.string() + ": producer has no committed root generation"); + return subos::rootfs::validate_generation(instance, *generation); +} +} + +std::expected, std::string> read_scope(const fs::path& ownerHome, std::string_view name) { + if (auto valid = name_(name); !valid) return std::unexpected(valid.error()); + const auto control = ownerHome / "subos" / name; + auto exists = present_(control); + if (!exists) return std::unexpected(exists.error()); + if (!*exists) return std::optional{}; + if (auto real = directory_(control); !real) return std::unexpected(real.error()); + const auto file = subos::HomeView{ownerHome}.instance_file(name); + auto metadata = read_json_for_update(file); + if (!metadata) return std::unexpected(metadata.error()); + if (!metadata->contains("prefix_domain")) return std::optional{}; + auto selected = prefix_domain::parse((*metadata)["prefix_domain"].dump(), ownerHome); + if (!selected) return std::unexpected(selected.error()); + if (!selected->privateHome) return std::optional{}; + const auto producer = selected->physicalHome / "subos" / name; + if (auto real = directory_(producer); !real) return std::unexpected(real.error()); + const auto producerFile = subos::HomeView{selected->physicalHome}.instance_file(name); + if (auto regular = regular_(producerFile); !regular) return std::unexpected(regular.error()); + auto actual = read_json_for_update(producerFile); + if (!actual) return std::unexpected(actual.error()); + if (!actual->contains("kind") || (*actual)["kind"] != "rootfs") + return std::unexpected(producerFile.string() + ": prefix-domain producer is not a rootfs instance"); + if (auto committed = committed_(producer); !committed) return std::unexpected(committed.error()); + return std::optional{Scope{*selected, control, producer, selected->logicalHome / "subos" / name}}; +} + +std::expected refresh_entry(const Domain& domain, const fs::path& entry) { + auto checked = prefix_domain::parse(prefix_domain::serialize(domain), domain.ownerHome); + if (!checked) return std::unexpected(checked.error()); + if (!domain.privateHome) return std::unexpected("managed dispatcher requires a private domain"); + auto refreshed = entry_binary::refresh_mirror(entry, {domain.ownerHome, domain.physicalHome}); + if (!refreshed) return std::unexpected(refreshed.error()); + const auto shims = xself::repoint_stale_shims(domain.physicalHome); + if (shims.refused || !shims.failed.empty()) + return std::unexpected("private dispatcher is current but stale shims could not be repaired; retry the owner command"); + return {}; +} + +std::expected prepare(const Domain& domain, const fs::path& entry) { + if (!domain.privateHome) return std::unexpected("namespace producer requires an owner-private domain"); + if (auto reserved = prefix_domain::prepare_private(domain); !reserved) return reserved; + for (const auto* relative : {"bin", "subos", "data", "tmp"}) + if (auto dir = mkdir_(domain.physicalHome / relative); !dir) return dir; + const auto configFile = domain.physicalHome / ".xlings.json"; + auto configExists = present_(configFile); + if (!configExists) return std::unexpected(configExists.error()); + if (!*configExists) { + auto original = read_json_for_update(domain.ownerHome / ".xlings.json"); + if (!original) return std::unexpected(original.error()); + Json config = Json::object(); + for (const auto* key : {"mirror", "xim", "index_repos", "resource_servers", "language"}) + if (original->contains(key)) config[key] = (*original)[key]; + config["activeSubos"] = "default"; + config["projectScope"] = false; + if (auto written = write_(configFile, config); !written) return written; + } else { + if (auto regular = regular_(configFile); !regular) return regular; + auto config = read_json_for_update(configFile); + if (!config) return std::unexpected(config.error()); + } + return refresh_entry(domain, entry); +} + +std::expected, std::string> command(const Domain& domain, + std::span arguments, std::optional output, + const domain_producer_source::Facade* source, bool runtime) { + if constexpr (!platform::is_linux) return std::unexpected("prefix-domain production requires Linux user namespaces"); + auto checked = prefix_domain::parse(prefix_domain::serialize(domain), domain.ownerHome); + if (!checked) return std::unexpected(checked.error()); + if (!domain.privateHome) return std::unexpected("namespace producer requires an owner-private domain"); + if (domain.systemSource && (!source || source->mapping.physicalHome != *domain.systemSource || + source->mapping.recordedHome != fs::path(domain_producer_source::SOURCE_ALIAS) || + source->mapping.logicalHome != domain.logicalHome)) + return std::unexpected("system-domain command requires its checked read-only source facade"); + if (!domain.systemSource && source) return std::unexpected("undeclared system source facade"); + const auto producer = domain.physicalHome / "bin/xlings"; + if (auto regular = regular_(producer); !regular) return std::unexpected(regular.error()); + fs::path bwrap; + std::error_code ec; + if (const auto* explicitBackend = std::getenv("XDEV_BWRAP"); explicitBackend && *explicitBackend) { + subos::caps::Backend selected{.name = "bwrap", .bin = explicitBackend, .source = "explicit"}; + subos::caps::probe_bwrap(selected); + if (!selected.usable) return std::unexpected("explicit domain backend failed: " + selected.probe_output); + bwrap = selected.bin; + } else if (auto selected = subos::caps::locate_bwrap(subos::HomeView{domain.ownerHome}, subos::Ports{})) { + if (!selected->usable) return std::unexpected("domain backend probe failed: " + selected->probe_output); + bwrap = selected->bin; + } + if (bwrap.empty()) return std::unexpected("prefix-domain producer requires bwrap; no host-prefix fallback"); + std::vector argv{bwrap.string(), "--die-with-parent", "--new-session", "--unshare-user"}; + if (!runtime) argv.push_back("--unshare-pid"); + // Management maps the same host owner to a non-root namespace ID. Both + // construction workers and root views must create child user namespaces: + // mapping parent UID 0 needs creator CAP_SETFCAP since Linux 5.12. The + // actual root-view provider still maps its child to UID 0. + argv.insert(argv.end(), {"--uid", "1000", "--gid", "1000"}); + // bwrap's initial root is namespace-owned tmpfs. Mount each host entry + // read-only so a missing /xlings mountpoint never requires a host mkdir. + const auto bind_host_entry = [&](const fs::path& path) -> std::expected { + auto status = fs::symlink_status(path, ec); + if (ec) return std::unexpected(path.string() + ": cannot inspect host build root: " + ec.message()); + if (fs::is_symlink(status)) { + const auto target = fs::read_symlink(path, ec); + if (ec) return std::unexpected(path.string() + ": cannot read host build-root link: " + ec.message()); + argv.insert(argv.end(), {"--symlink", target.string(), path.string()}); + } else if (fs::is_directory(status) || fs::is_regular_file(status)) { + argv.insert(argv.end(), {"--ro-bind", path.string(), path.string()}); + } + // Host sockets/devices are not installation tools. /dev and /proc + // below are namespace-specific rather than host device bindings. + return {}; + }; + for (fs::directory_iterator it("/", ec), end; !ec && it != end; it.increment(ec)) { + const auto path = it->path(); + if (path == domain.logicalHome || path == "/run" || path == "/proc" || path == "/dev") continue; + if (auto bound = bind_host_entry(path); !bound) return std::unexpected(bound.error()); + } + if (ec) return std::unexpected("cannot enumerate host build root: " + ec.message()); + argv.insert(argv.end(), {"--tmpfs", "/run"}); + for (fs::directory_iterator it("/run", ec), end; !ec && it != end; it.increment(ec)) { + const auto path = it->path(); + if (path == "/run/xlings-domain-output" || path == "/run/xlings-system-source" || path == "/run/xlings-domain-context") continue; + if (auto bound = bind_host_entry(path); !bound) return std::unexpected(bound.error()); + } + if (ec && ec != std::errc::no_such_file_or_directory) + return std::unexpected("cannot enumerate host build /run: " + ec.message()); + argv.insert(argv.end(), {"--bind", domain.physicalHome.string(), domain.logicalHome.string(), + "--dev", "/dev", "--proc", "/proc", "--clearenv", + "--setenv", "XLINGS_HOME", domain.logicalHome.string(), "--setenv", "HOME", domain.logicalHome.string(), + "--setenv", "TMPDIR", (domain.logicalHome / "tmp").string(), "--setenv", "PATH", + (domain.logicalHome / "subos/default/bin").string() + ":" + + (domain.logicalHome / "bin").string() + ":/usr/sbin:/usr/bin:/sbin:/bin", + "--chdir", domain.logicalHome.string()}); + for (const auto* key : {"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", + "http_proxy", "https_proxy", "all_proxy", "no_proxy"}) { + if (const auto* value = std::getenv(key); value && *value) + argv.insert(argv.end(), {"--setenv", key, value}); + } + if (source) { + argv.insert(argv.end(), {"--ro-bind", source->mapping.physicalHome.string(), source->mapping.recordedHome.string()}); + for (const auto& binding : source->metadata) + argv.insert(argv.end(), {"--ro-bind", binding.source.string(), binding.destination.string()}); + for (const auto& binding : source->payloads) + argv.insert(argv.end(), {"--ro-bind", binding.source.string(), binding.destination.string()}); + argv.insert(argv.end(), {"--ro-bind", source->context.string(), std::string(domain_producer_source::CONTEXT_FILE), + "--setenv", "XLINGS_SYSTEM_LAYER", source->mapping.recordedHome.string()}); + } + if (output) { + if (auto directory = directory_(output->source); !directory) return std::unexpected(directory.error()); + if (output->guest != fs::path("/run/xlings-domain-output")) return std::unexpected("invalid domain output binding"); + argv.insert(argv.end(), {"--bind", output->source.string(), output->guest.string()}); + } + argv.insert(argv.end(), {"--remount-ro", "/run", "--remount-ro", "/", "--", (domain.logicalHome / "bin/xlings").string()}); + argv.insert(argv.end(), arguments.begin(), arguments.end()); + return argv; +} +std::expected run(const Domain& domain, std::span arguments, + std::optional output, bool runtime) { + if (auto refreshed = refresh_entry(domain, platform::get_executable_path()); !refreshed) + return std::unexpected(refreshed.error()); + struct Stage { + fs::path path; + ~Stage() { + if (!path.empty()) { + std::error_code ignored; + fs::remove_all(path, ignored); // subos-remove-all-ok: exclusively reserved source-facade staging outside the private home + } + } + } stage; + std::optional facade; + if (domain.systemSource) { + std::error_code ec; + for (int attempt = 0; attempt < 100; ++attempt) { + const auto candidate = domain.physicalHome.parent_path() / (".source-view-" + std::to_string(std::random_device{}())); + if (fs::create_directory(candidate, ec)) { stage.path = candidate; break; } + if (ec && ec != std::errc::file_exists) return std::unexpected("cannot reserve source facade: " + ec.message()); + } + if (stage.path.empty()) return std::unexpected("cannot reserve source facade staging"); + auto prepared = domain_producer_source::prepare(domain, stage.path); + if (!prepared) return std::unexpected(prepared.error()); + facade.emplace(std::move(*prepared)); + } + auto argv = command(domain, arguments, output, facade ? &*facade : nullptr, runtime); + if (!argv) return std::unexpected(argv.error()); + return platform::run_argv_with_timeout(*argv, std::chrono::minutes(30)); +} + +std::expected check_control_removal(const Scope& scope, std::string_view name) { + if (auto valid = name_(name); !valid) return valid; + const subos::HomeView owner{scope.domain.ownerHome}; + if (scope.controlInstance != owner.instance(name) || + scope.producerInstance != scope.domain.physicalHome / "subos" / name) + return std::unexpected("domain removal scope does not match its owner"); + for (const auto& path : {owner.subos_root(), scope.controlInstance, scope.domain.ownerHome / "config", + scope.domain.ownerHome / "config/subos", owner.config_dir(name)}) + if (auto real = directory_(path); !real) return real; + std::error_code ec; + if (!fs::is_empty(scope.controlInstance, ec) || ec) + return std::unexpected(scope.controlInstance.string() + ": control contains unowned data; nothing removed"); + const auto file = owner.instance_file(name); + if (auto regular = regular_(file); !regular) return regular; + auto descriptor = read_json_for_update(file); + if (!descriptor) return std::unexpected(descriptor.error()); + const Json expected{{"kind", "rootfs"}, {"prefix_domain", Json::parse(prefix_domain::serialize(scope.domain))}}; + if (*descriptor != expected) + return std::unexpected(file.string() + ": control descriptor changed; nothing removed"); + if (auto regular = regular_(scope.domain.ownerHome / ".xlings.json"); !regular) return regular; + auto registry = read_json_for_update(scope.domain.ownerHome / ".xlings.json"); + if (!registry) return std::unexpected(registry.error()); + const auto key = std::string(name); + if (!registry->contains("subos") || !(*registry)["subos"].is_object() || + !(*registry)["subos"].contains(key) || (*registry)["subos"][key] != Json{{"dir", ""}}) + return std::unexpected("domain control registry does not prove ownership"); + return {}; +} + +std::expected remove_control(const Scope& scope, std::string_view name) { + auto actual = present_(scope.producerInstance); + if (!actual) return std::unexpected(actual.error()); + if (*actual) return std::unexpected("domain producer instance still exists; its control is retained"); + if (auto checked = check_control_removal(scope, name); !checked) return checked; + const subos::HomeView owner{scope.domain.ownerHome}; + std::string conflict; + auto committed = update_home_config(scope.domain.ownerHome, [&](Json& registry) { + const auto key = std::string(name); + if (!registry.contains("subos") || !registry["subos"].is_object() || + !registry["subos"].contains(key) || registry["subos"][key] != Json{{"dir", ""}}) { + conflict = "domain control registry changed; control retained"; return false; + } + registry["subos"].erase(key); + return true; + }); + if (!committed) return std::unexpected(committed.error()); + if (!conflict.empty()) return std::unexpected(conflict); + std::error_code ec; + if (!fs::remove(scope.controlInstance, ec) || ec) + return std::unexpected("cannot remove empty domain control: " + ec.message()); + if (!fs::remove(owner.instance_file(name), ec) || ec) + return std::unexpected("cannot remove proved domain descriptor: " + ec.message()); + // An unknown sibling is retained; only this empty derived directory can disappear. + fs::remove(owner.config_dir(name), ec); + return {}; +} + +std::expected publish_scope(const Domain& domain, std::string_view name) { + if (auto valid = name_(name); !valid) return valid; + const auto actual = domain.physicalHome / "subos" / name; + if (auto directory = directory_(actual); !directory) return directory; + auto actualMetadata = read_json_for_update(subos::HomeView{domain.physicalHome}.instance_file(name)); + if (!actualMetadata) return std::unexpected(actualMetadata.error()); + if (!actualMetadata->contains("kind") || (*actualMetadata)["kind"] != "rootfs") + return std::unexpected("namespace producer did not publish a rootfs instance"); + if (auto committed = committed_(actual); !committed) return committed; + auto registry = read_json_for_update(domain.ownerHome / ".xlings.json"); + if (!registry) return std::unexpected(registry.error()); + if (registry->contains("subos") && (!(*registry)["subos"].is_object() || (*registry)["subos"].contains(std::string(name)))) + return std::unexpected("prefix-domain control registry is invalid or already claims this name"); + if (auto dir = mkdir_(domain.ownerHome / "subos"); !dir) return dir; + const auto control = domain.ownerHome / "subos" / name; + std::error_code ec; + if (!fs::create_directory(control, ec) || ec) + return std::unexpected(control.string() + ": control instance conflict; produced domain retained"); + for (const auto& path : {domain.ownerHome / "config", domain.ownerHome / "config/subos", + subos::HomeView{domain.ownerHome}.config_dir(name)}) + if (auto dir = mkdir_(path); !dir) return dir; + const auto file = subos::HomeView{domain.ownerHome}.instance_file(name); + auto exists = present_(file); + if (!exists || *exists) return std::unexpected(file.string() + ": control metadata conflict; existing data preserved"); + Json metadata{{"kind", "rootfs"}, {"prefix_domain", Json::parse(prefix_domain::serialize(domain))}}; + if (auto written = write_(file, metadata); !written) return written; + std::string conflict; + auto saved = update_home_config(domain.ownerHome, [&](Json& document) { + if (document.contains("subos") && !document["subos"].is_object()) { + conflict = "prefix-domain control registry became invalid"; return false; + } + if (!document.contains("subos")) document["subos"] = Json::object(); + if (document["subos"].contains(std::string(name))) { + conflict = "prefix-domain control registry name conflict"; return false; + } + document["subos"][std::string(name)] = {{"dir", ""}}; + return true; + }); + if (!saved) return std::unexpected("cannot record prefix-domain control: " + saved.error()); + if (!conflict.empty()) return std::unexpected(conflict); + return {}; +} +} diff --git a/src/core/subos/domain/domain_producer.cppm b/src/core/subos/domain/domain_producer.cppm new file mode 100644 index 000000000..783200eb2 --- /dev/null +++ b/src/core/subos/domain/domain_producer.cppm @@ -0,0 +1,31 @@ +export module xlings.core.home.domain_producer; +import std; +import xlings.core.home.prefix_domain; +export import xlings.core.home.domain_producer_source; + +export namespace xlings::home::domain_producer { +namespace fs = std::filesystem; +using prefix_domain::Domain; +struct Scope { + Domain domain; + fs::path controlInstance; + fs::path producerInstance; + fs::path logicalInstance; +}; +// Missing descriptor is a native scope; a present invalid descriptor is an error. +std::expected, std::string> read_scope(const fs::path& ownerHome, + std::string_view name); +std::expected refresh_entry(const Domain& domain, const fs::path& entry); +std::expected prepare(const Domain& domain, const fs::path& entry); +struct OutputBinding { fs::path source; fs::path guest; }; +// Management maps the owner to a non-root namespace ID, including during +// construction; the native root provider creates the actual UID-0 root view. +std::expected, std::string> command(const Domain& domain, + std::span arguments, std::optional output = std::nullopt, + const domain_producer_source::Facade* source = nullptr, bool runtime = false); +std::expected run(const Domain& domain, std::span arguments, + std::optional output = std::nullopt, bool runtime = false); +std::expected check_control_removal(const Scope& scope, std::string_view name); +std::expected remove_control(const Scope& scope, std::string_view name); +std::expected publish_scope(const Domain& domain, std::string_view name); +} diff --git a/src/core/subos/domain/domain_producer_source.cpp b/src/core/subos/domain/domain_producer_source.cpp new file mode 100644 index 000000000..1ee6f654e --- /dev/null +++ b/src/core/subos/domain/domain_producer_source.cpp @@ -0,0 +1,331 @@ +module xlings.core.home.domain_producer_source; +import std; +import xlings.core.home; +import xlings.core.home.layers; +import xlings.core.home.evidence; +import xlings.core.home_config; +import xlings.core.xvm.db; +import xlings.core.xvm.owner; +import xlings.libs.json; +import xlings.platform; + +namespace xlings::home::domain_producer_source { +namespace { +using Json = nlohmann::json; +bool within_(const fs::path& root, const fs::path& path) { + auto relative = path.lexically_relative(root); + return root == path || (!relative.empty() && !relative.is_absolute() && *relative.begin() != ".."); +} +std::expected readonly_(const fs::path& path) { + auto flags = platform::read_only_mount(path); + if (!flags || !*flags) return std::unexpected(flags ? path.string() + ": source authority is writable" : flags.error()); + return {}; +} +std::expected identity_(const fs::path& path, std::uint64_t device, std::uint64_t index) { + auto identity = platform::file_identity(path); + if (!identity || identity->device != device || identity->index != index) + return std::unexpected(path.string() + ": source binding does not match its physical authority"); + return readonly_(path); +} +std::expected write_(const fs::path& file, const Json& document) { + std::error_code ec; + auto status = fs::symlink_status(file, ec); + if (status.type() != fs::file_type::not_found || (ec && ec != std::errc::no_such_file_or_directory)) + return std::unexpected(file.string() + ": metadata facade output conflict"); + try { platform::write_file_atomic(file.string(), document.dump(2)); return {}; } + catch (const std::exception& error) { return std::unexpected(file.string() + ": " + error.what()); } +} +std::expected paths_(Json& data, const SourceMapping& mapping) { + for (const auto* key : {"path", "includedir", "libdir", "fileSrc"}) { + if (!data.contains(key) || !data[key].is_string()) continue; + const auto text = data[key].get(); + if (text.empty()) continue; + const fs::path expanded(xvm::expand_path(text, mapping.physicalHome.string())); + if (!expanded.is_absolute()) continue; // fileSrc may be relative to its registered payload. + auto mapped = map_path(mapping, expanded, mapping.recordedHome); + if (!mapped) return std::unexpected(mapped.error()); + data[key] = mapped->generic_string(); + } + if (data.contains("bindingHeaders")) { + if (!data["bindingHeaders"].is_array()) return std::unexpected("invalid source bindingHeaders metadata"); + for (auto& header : data["bindingHeaders"]) { + if (!header.is_object() || !header.contains("sourceDir") || !header["sourceDir"].is_string()) + return std::unexpected("invalid source header path metadata"); + const fs::path expanded(xvm::expand_path(header["sourceDir"].get(), mapping.physicalHome.string())); + auto mapped = map_path(mapping, expanded, mapping.recordedHome); + if (!mapped) return std::unexpected(mapped.error()); + header["sourceDir"] = mapped->generic_string(); + } + } + return {}; +} +std::expected reserve_slot_(const prefix_domain::Domain& domain, + const fs::path& relative, Json& slots) { + std::error_code ec; + const auto local = domain.physicalHome / relative; + const auto status = fs::symlink_status(local, ec); + if (ec && ec != std::errc::no_such_file_or_directory) + return std::unexpected(local.string() + ": cannot inspect private source slot"); + if (status.type() != fs::file_type::not_found) { + if (!fs::is_directory(status)) return false; + const auto key = relative.generic_string(); + if (!slots["slots"].contains(key)) return false; + const auto observed = platform::file_identity(local); + const auto& proof = slots["slots"][key]; + if (!observed || !proof.is_object() || !proof.contains("device") || !proof["device"].is_number_unsigned() || + !proof.contains("index") || !proof["index"].is_number_unsigned() || proof["device"] != observed->device || + proof["index"] != observed->index) return false; + fs::directory_iterator it(local, ec); + if (ec) return std::unexpected(local.string() + ": cannot observe source-slot emptiness"); + return it == fs::directory_iterator(); + } + auto path = domain.physicalHome; + for (const auto& component : relative) { + path /= component; + const auto status = fs::symlink_status(path, ec); + if (status.type() == fs::file_type::not_found && (!ec || ec == std::errc::no_such_file_or_directory)) { + if (!fs::create_directory(path, ec) || ec) return std::unexpected(path.string() + ": source-slot reservation conflict"); + } else if (ec || !fs::is_directory(status)) return std::unexpected(path.string() + ": source-slot parent redirects or is not a directory"); + } + const auto identity = platform::file_identity(local); + if (!identity) return std::unexpected(local.string() + ": cannot observe reserved source-slot identity"); + slots["slots"][relative.generic_string()] = {{"device", identity->device}, {"index", identity->index}}; + return true; +} +} + +std::expected map_path(const SourceMapping& mapping, const fs::path& path, + const fs::path& destinationHome) { + if (!path.is_absolute() || path.lexically_normal() != path) + return std::unexpected(path.string() + ": source path is not absolute and normalized"); + for (const auto& root : {mapping.recordedHome, mapping.physicalHome, mapping.logicalHome}) + if (within_(root, path)) return destinationHome / path.lexically_relative(root); + return std::unexpected(path.string() + ": path does not belong to the checked system source"); +} + +std::expected prepare(const prefix_domain::Domain& domain, const fs::path& stage) { + if (!domain.systemSource) return std::unexpected("source facade requires a declared system source"); + auto selected = prefix_domain::parse(prefix_domain::serialize(domain), domain.ownerHome); + if (!selected) return std::unexpected(selected.error()); + std::error_code ec; + if (!fs::is_directory(fs::symlink_status(stage, ec)) || ec) + return std::unexpected(stage.string() + ": source facade needs a reserved empty real directory"); + const fs::directory_iterator stageContents(stage, ec); + if (ec || stageContents != fs::directory_iterator()) + return std::unexpected(stage.string() + ": source facade needs an observed empty directory"); + auto snapshot = layers::read_snapshot(*domain.systemSource); + if (!snapshot) return std::unexpected(snapshot.error()); + std::set payloads; + std::map> selectedVersions; + for (const auto& [target, keys] : snapshot->workspace.installed) + for (const auto& key : keys) { + auto plan = layers::plan_borrow(*snapshot, {}, target, key); + if (!plan) return std::unexpected(plan.error()); + payloads.insert(plan->payloads.begin(), plan->payloads.end()); + for (const auto& [member, info] : plan->registrations) + for (const auto& [version, data] : info.versions) selectedVersions[member].insert(version); + } + if (payloads.size() > 4096) return std::unexpected("published source closure exceeds 4096 payloads"); + const auto slotsFile = domain.ownerHome / "domains/xlings/source-slots.json"; + const auto slotsStatus = fs::symlink_status(slotsFile, ec); + const bool newSlots = slotsStatus.type() == fs::file_type::not_found && (!ec || ec == std::errc::no_such_file_or_directory); + if (!newSlots && (ec || !fs::is_regular_file(slotsStatus))) + return std::unexpected(slotsFile.string() + ": source-slot ownership must be a regular file"); + auto slots = read_json_for_update(slotsFile); + if (!slots) return std::unexpected(slots.error()); + if (newSlots) *slots = {{"schema", 1}, {"physical_home", domain.physicalHome.generic_string()}, {"slots", Json::object()}}; + else if (!slots->contains("schema") || (*slots)["schema"] != 1 || !slots->contains("physical_home") || + (*slots)["physical_home"] != domain.physicalHome.generic_string() || + !slots->contains("slots") || !(*slots)["slots"].is_object()) + return std::unexpected(slotsFile.string() + ": source-slot ownership is corrupt; existing data preserved"); + Facade result; + result.mapping.physicalHome = *domain.systemSource; + result.mapping.recordedHome = SOURCE_ALIAS; + result.mapping.logicalHome = domain.logicalHome; + const auto markerIdentity = platform::file_identity(*domain.systemSource / ".xlings-home"); + if (!fs::is_regular_file(fs::symlink_status(*domain.systemSource / ".xlings-home", ec)) || ec) + return std::unexpected("system source marker must be a regular ownership file"); + if (!markerIdentity) return std::unexpected("system source marker identity cannot be observed"); + auto primary = read_json_for_update(*domain.systemSource / ".xlings.json"); + if (!primary) return std::unexpected(primary.error()); + auto versions = load_versions_json(*domain.systemSource); + if (!versions) return std::unexpected("system source versions could not be observed"); + Json formalVersions = Json::object(); + for (auto target = versions->begin(); target != versions->end(); ++target) { + const auto selected = selectedVersions.find(target.key()); + if (selected == selectedVersions.end()) continue; + auto table = target.value(); + Json formal = Json::object(); + for (auto version = table["versions"].begin(); version != table["versions"].end(); ++version) { + if (!selected->second.contains(version.key())) continue; + auto data = version.value(); + if (auto mapped = paths_(data, result.mapping); !mapped) return std::unexpected(mapped.error()); + formal[version.key()] = std::move(data); + } + table["versions"] = std::move(formal); + formalVersions[target.key()] = std::move(table); + } + *versions = std::move(formalVersions); + (*primary)["versions"] = *versions; + primary->erase("dbIndex"); + const auto primaryFile = stage / "primary.json"; + if (auto written = write_(primaryFile, *primary); !written) return std::unexpected(written.error()); + result.metadata.push_back({primaryFile, fs::path(SOURCE_ALIAS) / ".xlings.json"}); + const auto sourceCache = versions_db_path(*domain.systemSource); + const auto cacheStatus = fs::symlink_status(sourceCache, ec); + if (ec && ec != std::errc::no_such_file_or_directory) return std::unexpected("cannot inspect source versions cache"); + if (cacheStatus.type() != fs::file_type::not_found) { + if (!fs::is_regular_file(cacheStatus)) return std::unexpected("source versions cache is not a regular file"); + const auto cacheFacade = stage / "versions-cache.json"; + if (auto written = write_(cacheFacade, Json::object()); !written) return std::unexpected(written.error()); + result.metadata.push_back({cacheFacade, fs::path(SOURCE_ALIAS) / sourceCache.lexically_relative(*domain.systemSource)}); + } + + Json context{{"schema", 1}, {"physical_home", result.mapping.physicalHome.generic_string()}, + {"recorded_home", result.mapping.recordedHome.generic_string()}, {"logical_home", result.mapping.logicalHome.generic_string()}, + {"marker_device", markerIdentity->device}, {"marker_index", markerIdentity->index}, {"payloads", Json::array()}}; + std::size_t serial = 0; + for (const auto& root : payloads) { + const auto identity = platform::file_identity(root); + if (!identity) return std::unexpected(root.string() + ": cannot observe source payload identity"); + const auto relative = root.lexically_relative(result.mapping.physicalHome); + const auto logical = result.mapping.logicalHome / relative; + // A private-owned payload wins. An empty mountpoint is reusable only + // when a prior source-slot manifest proves its directory file object. + auto reserved = reserve_slot_(domain, relative, *slots); + if (!reserved) return std::unexpected(reserved.error()); + const bool bind = *reserved; + if (bind) result.payloads.push_back({root, logical}); + result.mapping.payloads.push_back({relative, identity->device, identity->index, bind}); + context["payloads"].push_back({{"relative", relative.generic_string()}, {"device", identity->device}, + {"index", identity->index}, {"logical_bound", bind}}); + auto owned = evidence::physical_store_root(result.mapping.physicalHome, root); + if (!owned) return std::unexpected(owned.error()); + auto resolution = evidence::read_checked_resolution(*owned, [&](const fs::path& path) { + return evidence::physical_store_root(result.mapping.physicalHome, path); + }); + if (!resolution) return std::unexpected(resolution.error()); + auto record = read_json_for_update(root / ".xlings-resolution.json"); + if (!record) return std::unexpected(record.error()); + for (std::size_t i = 0; i < resolution->dependencies.size(); ++i) { + const auto& dependency = resolution->dependencies[i]; + auto mapped = map_path(result.mapping, dependency.installDir, result.mapping.recordedHome); + if (!mapped) return std::unexpected(mapped.error()); + (*record)["deps"][i]["install_dir"] = mapped->generic_string(); + (*record)["deps"][i]["libdirs"] = Json::array(); + for (const auto& libdir : dependency.libdirs) { + auto mapped = map_path(result.mapping, libdir, result.mapping.recordedHome); + if (!mapped) return std::unexpected(mapped.error()); + (*record)["deps"][i]["libdirs"].push_back(mapped->generic_string()); + } + } + const auto recordFile = stage / ("resolution-" + std::to_string(serial++) + ".json"); + if (auto written = write_(recordFile, *record); !written) return std::unexpected(written.error()); + result.metadata.push_back({recordFile, result.mapping.recordedHome / relative / ".xlings-resolution.json"}); + } + result.context = stage / "source.json"; + if (auto written = write_(result.context, context); !written) return std::unexpected(written.error()); + if (newSlots) { + const auto document = stage / "slots.json"; + if (auto written = write_(document, *slots); !written) return std::unexpected(written.error()); + if (auto published = platform::rename_no_replace(document, slotsFile); !published) return std::unexpected(published.error()); + } else { + const auto status = fs::symlink_status(slotsFile, ec); + if (ec || !fs::is_regular_file(status)) return std::unexpected("source-slot ownership file changed"); + try { platform::write_file_atomic(slotsFile.string(), slots->dump(2)); } + catch (const std::exception& error) { return std::unexpected(error.what()); } + } + return result; +} + +std::expected, std::string> read() { + const fs::path file(CONTEXT_FILE); + std::error_code ec; + const auto status = fs::symlink_status(file, ec); + if (status.type() == fs::file_type::not_found && (!ec || ec == std::errc::no_such_file_or_directory)) + return std::optional{}; + if (ec || !fs::is_regular_file(status)) return std::unexpected("source context is not a readable regular file"); + if (fs::file_size(file, ec) > 16 * 1024 * 1024 || ec) return std::unexpected("source context is unreadable or too large"); + if (auto readonly = readonly_(file); !readonly) return std::unexpected(readonly.error()); + auto document = read_json_for_update(file); + if (!document) return std::unexpected(document.error()); + try { + if (document->at("schema") != 1) return std::unexpected("unsupported source mapping schema"); + SourceMapping mapping; + mapping.physicalHome = document->at("physical_home").get(); + mapping.recordedHome = document->at("recorded_home").get(); + mapping.logicalHome = document->at("logical_home").get(); + if (!mapping.physicalHome.is_absolute() || mapping.physicalHome == fs::path("/") || mapping.physicalHome.lexically_normal() != mapping.physicalHome || + mapping.recordedHome != fs::path(SOURCE_ALIAS) || mapping.logicalHome != fs::path("/xlings")) + return std::unexpected("invalid source domain mapping"); + if (auto readonly = readonly_(mapping.recordedHome); !readonly) return std::unexpected(readonly.error()); + if (!fs::is_regular_file(fs::symlink_status(mapping.recordedHome / ".xlings-home", ec)) || ec) + return std::unexpected("source marker is not a regular ownership file"); + auto marker = identity_(mapping.recordedHome / ".xlings-home", document->at("marker_device").get(), + document->at("marker_index").get()); + if (!marker) return std::unexpected(marker.error()); + auto shared = shares_store(mapping.recordedHome); + if (!shared || !*shared) return std::unexpected(shared ? "source mapping does not publish a shared store" : shared.error()); + const auto& payloads = document->at("payloads"); + if (!payloads.is_array() || payloads.size() > 4096) return std::unexpected("invalid source mapping payload inventory"); + std::set seen; + for (const auto& payload : payloads) { + PayloadIdentity identity{fs::path(payload.at("relative").get()), + payload.at("device").get(), payload.at("index").get(), payload.at("logical_bound").get()}; + const auto path = mapping.physicalHome / identity.relative; + if (identity.relative.is_absolute() || std::distance(identity.relative.begin(), identity.relative.end()) != 4 || + path.lexically_normal() != path || !within_(mapping.physicalHome / "data/xpkgs", path) || + !xvm::coordinate_from_payload_path(path.generic_string()) || !seen.insert(identity.relative).second) + return std::unexpected("invalid source mapping payload coordinate"); + mapping.payloads.push_back(std::move(identity)); + } + return std::optional{std::move(mapping)}; + } catch (const std::exception& error) { return std::unexpected(std::string("invalid source mapping: ") + error.what()); } +} + +std::expected execution_home(const fs::path& physicalHome) { + auto mapping = read(); + if (!mapping) return std::unexpected(mapping.error()); + if (*mapping && physicalHome == (**mapping).physicalHome) return (**mapping).recordedHome; + return physicalHome; +} +std::expected persisted_home(const fs::path& recordedHome) { + auto mapping = read(); + if (!mapping) return std::unexpected(mapping.error()); + if (*mapping && recordedHome == (**mapping).recordedHome) return (**mapping).physicalHome; + return recordedHome; +} +std::expected borrowed_mount(const fs::path& path) { + auto mapping = read(); + if (!mapping) return std::unexpected(mapping.error()); + if (!*mapping) return false; + for (const auto& payload : (**mapping).payloads) { + const auto root = (**mapping).logicalHome / payload.relative; + if (!within_(root, path.lexically_normal()) || !payload.logicalBound) continue; + auto observed = identity_(root, payload.device, payload.index); + if (!observed) return std::unexpected(observed.error()); + return true; + } + return false; +} +std::expected validate_payload(const fs::path& aliasPayload) { + auto mapping = read(); + if (!mapping) return std::unexpected(mapping.error()); + if (!*mapping || !within_((**mapping).recordedHome, aliasPayload)) return {}; + for (const auto& payload : (**mapping).payloads) { + if ((**mapping).recordedHome / payload.relative != aliasPayload) continue; + if (!payload.logicalBound) return std::unexpected(aliasPayload.string() + ": source runtime closure would mask a private-owned payload slot"); + if (auto observed = identity_(aliasPayload, payload.device, payload.index); !observed) return observed; + return identity_((**mapping).logicalHome / payload.relative, payload.device, payload.index); + } + return std::unexpected(aliasPayload.string() + ": payload is outside the checked source inventory"); +} +std::expected physical_path(const prefix_domain::Domain& domain, + const fs::path& sourceHome, const fs::path& recordedPath) { + if (sourceHome.empty()) return prefix_domain::map_host(domain, recordedPath); + if (!domain.systemSource || sourceHome != *domain.systemSource) + return std::unexpected(sourceHome.string() + ": payload source is not proved by this domain"); + SourceMapping mapping{*domain.systemSource, fs::path(SOURCE_ALIAS), domain.logicalHome, {}}; + return map_path(mapping, recordedPath, mapping.physicalHome); +} +} diff --git a/src/core/subos/domain/domain_producer_source.cppm b/src/core/subos/domain/domain_producer_source.cppm new file mode 100644 index 000000000..ca45bbcb1 --- /dev/null +++ b/src/core/subos/domain/domain_producer_source.cppm @@ -0,0 +1,42 @@ +export module xlings.core.home.domain_producer_source; +import std; +import xlings.core.home.prefix_domain; +export namespace xlings::home::domain_producer_source { +namespace fs = std::filesystem; +inline constexpr std::string_view SOURCE_ALIAS = "/run/xlings-system-source"; +inline constexpr std::string_view CONTEXT_FILE = "/run/xlings-domain-context/source.json"; +struct PayloadIdentity { + fs::path relative; + std::uint64_t device {}; + std::uint64_t index {}; + bool logicalBound { false }; +}; +struct SourceMapping { + fs::path physicalHome; + fs::path recordedHome; + fs::path logicalHome; + std::vector payloads; +}; +struct ReadOnlyBinding { fs::path source; fs::path destination; }; +struct Facade { + SourceMapping mapping; + std::vector metadata; + std::vector payloads; + fs::path context; +}; +// The caller owns an exclusively reserved staging directory for this view. +// Checked formal source registrations and resolution evidence determine every +// payload; a metadata facade changes schema path fields, never package bytes. +std::expected prepare(const prefix_domain::Domain& domain, const fs::path& stage); +// The namespace's RO context and original source-marker file identity prove +// this mapping. An environment variable alone cannot establish authority. +std::expected, std::string> read(); +std::expected execution_home(const fs::path& physicalHome); +std::expected persisted_home(const fs::path& recordedHome); +std::expected borrowed_mount(const fs::path& path); +std::expected validate_payload(const fs::path& aliasPayload); +std::expected map_path(const SourceMapping& mapping, const fs::path& path, + const fs::path& destinationHome); +std::expected physical_path(const prefix_domain::Domain& domain, + const fs::path& sourceHome, const fs::path& recordedPath); +} diff --git a/src/core/subos/domain/evidence.cpp b/src/core/subos/domain/evidence.cpp new file mode 100644 index 000000000..b7ae23a35 --- /dev/null +++ b/src/core/subos/domain/evidence.cpp @@ -0,0 +1,143 @@ +module xlings.core.home.evidence; + +import std; +import xlings.core.home; +import xlings.core.xvm.db; +import xlings.core.xvm.owner; +import xlings.libs.json; + +namespace xlings::home::evidence { +namespace { +bool within(const fs::path& root, const fs::path& candidate) { + const auto relative = candidate.lexically_relative(root); + return candidate == root || + (!relative.empty() && !relative.is_absolute() && *relative.begin() != ".."); +} +fs::path physical_path(const OwnedPayload& owner, const fs::path& recorded) { + if (owner.recordedHome.empty() || owner.recordedHome == owner.home) + return recorded; + const auto normalized = recorded.lexically_normal(); + if (within(owner.recordedHome, normalized)) + return owner.home / normalized.lexically_relative(owner.recordedHome); + return normalized; +} +std::string provider(const xvm::InstallCoordinate& coordinate) { + return coordinate.ns.empty() ? coordinate.package : coordinate.ns + ":" + coordinate.package; +} +} // namespace + +std::expected physical_store_root(const fs::path& ownerHome, + const fs::path& memberPath) { + std::error_code ec; + const auto home = fs::canonical(ownerHome, ec); + if (ec) + return std::unexpected(ownerHome.string() + ": source home is unreadable"); + const auto store = fs::canonical(home / "data/xpkgs", ec); + if (ec || store != home / "data/xpkgs") + return std::unexpected(home.string() + + ": payload store redirects outside its owner or is unreadable"); + const auto candidate = fs::canonical(memberPath, ec); + if (ec || !within(store, candidate) || candidate == store) + return std::unexpected(memberPath.string() + + ": payload does not belong to the declared source home"); + const auto relative = candidate.lexically_relative(store); + auto component = relative.begin(); + const auto package = *component++; + if (component == relative.end()) + return std::unexpected("missing physical payload version"); + const auto root = store / package / *component; + if (!fs::is_directory(fs::symlink_status(root, ec)) || ec) + return std::unexpected(root.string() + ": missing real payload directory"); + const auto coordinate = xvm::coordinate_from_payload_path(root.generic_string()); + if (!coordinate) + return std::unexpected(root.string() + ": invalid physical payload coordinate"); + return OwnedPayload{home, root, *coordinate}; +} + +std::expected +read_checked_resolution(const OwnedPayload& owner, const SourceResolver& resolveSource) { + const auto path = owner.root / ".xlings-resolution.json"; + std::error_code ec; + const auto status = fs::symlink_status(path, ec); + if (status.type() == fs::file_type::not_found || ec == std::errc::no_such_file_or_directory) + return std::unexpected(path.string() + ": missing checked runtime resolution; run owner " + "`xlings install --reconfig` to migrate"); + if (ec || !fs::is_regular_file(status)) + return std::unexpected(path.string() + + ": runtime resolution must be a readable regular file"); + if (fs::file_size(path, ec) > 16 * 1024 * 1024 || ec) + return std::unexpected(path.string() + + ": runtime resolution exceeds 16 MiB or is unreadable"); + auto document = read_json_for_update(path); + if (!document) + return std::unexpected(document.error()); + try { + const auto& package = document->at("package"); + const auto& dependencies = document->at("deps"); + if (!package.is_string() || !dependencies.is_array() || dependencies.size() > 4096) + return std::unexpected(path.string() + ": invalid runtime resolution evidence"); + const auto identity = package.get(); + if (identity != owner.coordinate.canonical() && + identity != owner.coordinate.package + "@" + owner.coordinate.version) + return std::unexpected(path.string() + + ": resolution package contradicts its physical payload"); + Resolution result{owner, {}}; + std::set specs; + for (const auto& entry : dependencies) { + Dependency dependency; + for (const auto* key : {"spec", "name", "version", "install_dir", "source"}) + if (!entry.is_object() || !entry.contains(key) || !entry.at(key).is_string() || + entry.at(key).get().empty()) + return std::unexpected(path.string() + ": malformed dependency provenance"); + dependency.spec = entry.at("spec").get(); + dependency.name = entry.at("name").get(); + dependency.version = entry.at("version").get(); + dependency.source = entry.at("source").get(); + if (!specs.insert(dependency.spec).second) + return std::unexpected(path.string() + ": duplicate dependency spec"); + dependency.installDir = xvm::expand_path( + entry.at("install_dir").get(), + (owner.recordedHome.empty() ? owner.home : owner.recordedHome).string()); + if (!dependency.installDir.is_absolute()) + return std::unexpected(path.string() + + ": recorded dependency path is not absolute"); + auto source = resolveSource(dependency.installDir); + if (!source) + return std::unexpected(source.error()); + if (dependency.name != provider(source->coordinate) || + dependency.version != source->coordinate.version) + return std::unexpected(path.string() + + ": dependency coordinate contradicts its recorded payload"); + dependency.payload = std::move(*source); + dependency.installDir = + fs::canonical(physical_path(dependency.payload, dependency.installDir), ec); + if (ec || !within(dependency.payload.root, dependency.installDir)) + return std::unexpected(path.string() + ": recorded dependency path escapes its proved payload"); + const auto& directories = entry.at("libdirs"); + if (!directories.is_array()) + return std::unexpected(path.string() + ": invalid dependency library provenance"); + for (const auto& directory : directories) { + if (!directory.is_string()) + return std::unexpected(path.string() + + ": invalid dependency library directory"); + fs::path libdir(directory.get()); + if (libdir.empty()) + return std::unexpected(path.string() + ": empty dependency library directory"); + if (!libdir.is_absolute()) + libdir = dependency.installDir / libdir; + libdir = fs::canonical(physical_path(dependency.payload, libdir), ec); + if (ec || !within(dependency.payload.root, libdir)) + return std::unexpected( + path.string() + + ": dependency library directory escapes its physical payload"); + dependency.libdirs.push_back(std::move(libdir)); + } + result.dependencies.push_back(std::move(dependency)); + } + return result; + } catch (const std::exception& error) { + return std::unexpected(path.string() + + ": invalid runtime resolution evidence: " + error.what()); + } +} +} // namespace xlings::home::evidence diff --git a/src/core/subos/domain/evidence.cppm b/src/core/subos/domain/evidence.cppm new file mode 100644 index 000000000..22c7a16b4 --- /dev/null +++ b/src/core/subos/domain/evidence.cppm @@ -0,0 +1,39 @@ +export module xlings.core.home.evidence; + +import std; +import xlings.core.xvm.owner; + +export namespace xlings::home::evidence { +namespace fs = std::filesystem; + +struct OwnedPayload { + fs::path home; + fs::path root; + xvm::InstallCoordinate coordinate; + fs::path recordedHome; // Set only by a caller that proved a namespace mapping. +}; +struct Dependency { + std::string spec; + std::string name; + std::string version; + fs::path installDir; + std::vector libdirs; + std::string source; + OwnedPayload payload; +}; +struct Resolution { + OwnedPayload payload; + std::vector dependencies; +}; + +// The actual, canonical physical store is the authority. Neither a symlinked +// store nor a matching package name can claim another home's payload. +std::expected physical_store_root(const fs::path& ownerHome, + const fs::path& memberPath); + +// A caller supplies its independently proved scope/home membership. This +// callback resolves recorded paths, never recipe dependency names or ranges. +using SourceResolver = std::function(const fs::path&)>; +std::expected read_checked_resolution(const OwnedPayload& owner, + const SourceResolver& resolveSource); +} // namespace xlings::home::evidence diff --git a/src/core/subos/domain/layers.cpp b/src/core/subos/domain/layers.cpp new file mode 100644 index 000000000..dc77160ea --- /dev/null +++ b/src/core/subos/domain/layers.cpp @@ -0,0 +1,401 @@ +module xlings.core.home.layers; + +import std; +import xlings.libs.json; +import xlings.core.home; +import xlings.core.home.evidence; +import xlings.core.home.domain_producer_source; +import xlings.core.home_config; +import xlings.core.xvm.db; +import xlings.core.xvm.bindings; +import xlings.core.xvm.owner; +import xlings.core.xim.payload; + +namespace xlings::home::layers { + +#if !defined(_MSC_VER) // platform-if-ok: compiler, not platform: GCC/MSVC module special members +Snapshot::Snapshot() : home{}, sourceHome{}, logicalHome{}, scope{}, versions(xvm::empty_version_db()), workspace{} {} +Snapshot::~Snapshot() {} +Snapshot::Snapshot(const Snapshot& other) : home(other.home), sourceHome(other.sourceHome), logicalHome(other.logicalHome), scope(other.scope), versions(other.versions), workspace(other.workspace) {} +Snapshot& Snapshot::operator=(const Snapshot& other) { + if (this != &other) { + home = other.home; + sourceHome = other.sourceHome; + logicalHome = other.logicalHome; + scope = other.scope; + versions = other.versions; + workspace = other.workspace; + } + return *this; +} +Snapshot::Snapshot(Snapshot&& other) : home(std::move(other.home)), sourceHome(std::move(other.sourceHome)), logicalHome(std::move(other.logicalHome)), scope(std::move(other.scope)), versions(std::move(other.versions)), workspace(std::move(other.workspace)) {} +Snapshot& Snapshot::operator=(Snapshot&& other) { + if (this != &other) { + home = std::move(other.home); + sourceHome = std::move(other.sourceHome); + logicalHome = std::move(other.logicalHome); + scope = std::move(other.scope); + versions = std::move(other.versions); + workspace = std::move(other.workspace); + } + return *this; +} +BorrowPlan::BorrowPlan() : registrations(xvm::empty_version_db()), members{}, payloads{}, requestedPayload{}, requestedMembers{}, payloadCoordinates{} {} +BorrowPlan::~BorrowPlan() {} +BorrowPlan::BorrowPlan(const BorrowPlan& other) : registrations(other.registrations), members(other.members), payloads(other.payloads), requestedPayload(other.requestedPayload), requestedMembers(other.requestedMembers), payloadCoordinates(other.payloadCoordinates) {} +BorrowPlan& BorrowPlan::operator=(const BorrowPlan& other) { + if (this != &other) { + registrations = other.registrations; + members = other.members; + payloads = other.payloads; + requestedPayload = other.requestedPayload; + requestedMembers = other.requestedMembers; + payloadCoordinates = other.payloadCoordinates; + } + return *this; +} +BorrowPlan::BorrowPlan(BorrowPlan&& other) : registrations(std::move(other.registrations)), members(std::move(other.members)), payloads(std::move(other.payloads)), requestedPayload(std::move(other.requestedPayload)), requestedMembers(std::move(other.requestedMembers)), payloadCoordinates(std::move(other.payloadCoordinates)) {} +BorrowPlan& BorrowPlan::operator=(BorrowPlan&& other) { + if (this != &other) { + registrations = std::move(other.registrations); + members = std::move(other.members); + payloads = std::move(other.payloads); + requestedPayload = std::move(other.requestedPayload); + requestedMembers = std::move(other.requestedMembers); + payloadCoordinates = std::move(other.payloadCoordinates); + } + return *this; +} +#endif +namespace { + +std::expected payload_root_(const fs::path& home, const fs::path& path) { + auto owned = evidence::physical_store_root(home, path); + if (!owned) return std::unexpected(owned.error()); + return owned->root; +} + +std::expected validate_versions_(const nlohmann::json& json) { + if (!json.is_object()) return std::unexpected("versions must be an object"); + for (auto item = json.begin(); item != json.end(); ++item) { + const auto& target = item.key(); + const auto& info = item.value(); + if (!info.is_object() || !info.contains("versions") || !info["versions"].is_object()) + return std::unexpected(target + ": invalid version table"); + for (const auto* field : {"type", "filename"}) + if (info.contains(field) && !info[field].is_string()) + return std::unexpected(target + ": invalid " + field); + if (info.contains("bindings")) { + if (!info["bindings"].is_object()) return std::unexpected(target + ": invalid bindings"); + const auto& bindings = info["bindings"]; + for (auto item = bindings.begin(); item != bindings.end(); ++item) { + const auto& edge = item.value(); + if (!edge.is_object()) return std::unexpected(target + ": invalid binding edge"); + for (auto item = edge.begin(); item != edge.end(); ++item) + if (!item.value().is_string()) return std::unexpected(target + ": invalid binding version"); + } + } + const auto& versions = info["versions"]; + for (auto item = versions.begin(); item != versions.end(); ++item) { + const auto& version = item.key(); + const auto& data = item.value(); + if (!data.is_object() || !data.contains("path") || !data["path"].is_string()) + return std::unexpected(target + "@" + version + ": invalid payload path"); + for (const auto* field : {"kind", "sourceName", "destinationName", "includedir", "libdir", "fileSrc", "fileDst"}) + if (data.contains(field) && !data[field].is_string()) + return std::unexpected(target + "@" + version + ": invalid " + field); + if (data.contains("alias")) { + if (!data["alias"].is_array()) return std::unexpected(target + ": invalid alias"); + for (const auto& item : data["alias"]) + if (!item.is_string()) return std::unexpected(target + ": invalid alias value"); + } + if (data.contains("envs")) { + if (!data["envs"].is_object()) return std::unexpected(target + ": invalid envs"); + const auto& envs = data["envs"]; + for (auto item = envs.begin(); item != envs.end(); ++item) + if (!item.value().is_string()) return std::unexpected(target + ": invalid env value"); + } + } + } + return {}; +} + +std::expected validate_workspace_(const nlohmann::json& json) { + if (!json.is_object()) return std::unexpected("workspace must be an object"); + for (auto item = json.begin(); item != json.end(); ++item) { + const auto& target = item.key(); + const auto& value = item.value(); + if (value.is_string()) continue; + if (!value.is_object() || (!value.contains("active") && !value.contains("installed"))) + return std::unexpected(target + ": invalid system layer opt-in state"); + if (value.contains("active") && !value["active"].is_string()) + return std::unexpected(target + ": invalid active version"); + if (value.contains("installed")) { + if (!value["installed"].is_array()) return std::unexpected(target + ": invalid installed versions"); + for (const auto& version : value["installed"]) + if (!version.is_string()) return std::unexpected(target + ": invalid installed version"); + } + } + return {}; +} + +bool opted_in_(const Snapshot& source, const std::string& target, const std::string& version) { + auto it = source.workspace.installed.find(target); + return it != source.workspace.installed.end() && + std::ranges::find(it->second, version) != it->second.end(); +} + +xvm::VData borrowed_(const xvm::VData& input, const Snapshot& source) { + auto value = input; + const auto home = source.home.string(); + const auto path = [&](const std::string& text) { return xvm::expand_path(text, home); }; + value.path = path(value.path); + value.includedir = path(value.includedir); + value.libdir = path(value.libdir); + for (auto& header : value.bindingHeaders) header.sourceDir = path(header.sourceDir); + const auto logical = source.logicalHome.empty() ? home : source.logicalHome.string(); + for (auto& alias : value.alias) alias = xvm::pin_subos_paths(xvm::expand_path(alias, logical), logical); + for (auto& [name, env] : value.envs) env = xvm::pin_subos_paths(xvm::expand_path(env, logical), logical); + value.sourceHome = source.sourceHome.empty() ? source.home.string() : source.sourceHome.string(); + value.sourceScope = source.scope; + return value; +} +} + +bool owns_payload(const fs::path& home, const fs::path& payload) { + return payload_root_(home, payload).has_value(); +} + +bool owns_source_payload(const fs::path& physicalHome, const fs::path& payload) { + auto view = domain_producer_source::execution_home(physicalHome); + return view && owns_payload(*view, payload); +} + +std::expected read_source_snapshot(const fs::path& physicalHome, std::string_view scope) { + auto view = domain_producer_source::execution_home(physicalHome); + if (!view) return std::unexpected(view.error()); + return read_snapshot(*view, scope); +} + +std::expected read_snapshot(const fs::path& home, std::string_view scope) { + if (scope.empty() || scope == "." || scope == ".." || + scope.find_first_of("/\\") != std::string_view::npos) + return std::unexpected("invalid layer scope"); + std::error_code ec; + auto canonical = fs::canonical(home, ec); + if (ec) return std::unexpected(home.string() + ": " + ec.message()); + auto primary = read_json_for_update(canonical / ".xlings.json"); + if (!primary) return std::unexpected(primary.error()); + if (primary->contains("versions")) { + auto valid = validate_versions_((*primary)["versions"]); + if (!valid) return std::unexpected((canonical / ".xlings.json").string() + ": " + valid.error()); + } + auto json = load_versions_json(canonical); + if (!json) return std::unexpected(canonical.string() + ": versions could not be observed"); + auto valid = validate_versions_(*json); + if (!valid) return std::unexpected(canonical.string() + ": " + valid.error()); + const auto scopePath = canonical / "subos" / scope / ".xlings.json"; + auto document = read_json_for_update(scopePath); + if (!document) return std::unexpected(document.error()); + const auto workspace = document->value("workspace", nlohmann::json::object()); + valid = validate_workspace_(workspace); + if (!valid) return std::unexpected(scopePath.string() + ": " + valid.error()); + Snapshot result; + result.home = canonical; + auto context = domain_producer_source::read(); + if (!context) return std::unexpected(context.error()); + const bool mapped = *context && (**context).recordedHome == canonical; + result.sourceHome = mapped ? (**context).physicalHome : canonical; + result.logicalHome = mapped ? (**context).logicalHome : canonical; + result.scope = scope; + result.versions = xvm::versions_from_json(*json); + result.workspace = xvm::subos_workspace_from_json(workspace); + for (const auto& [target, info] : result.versions) + for (const auto& [version, data] : info.versions) + if (!data.bindingIntegrityIssues.empty() || !data.bindingUnreadable.empty()) + return std::unexpected(target + "@" + version + ": unreadable layer binding metadata"); + return result; +} + +std::expected plan_borrow(const Snapshot& source, + const xvm::VersionDB& destination, const std::string& target, const std::string& version) { + BorrowPlan result; + std::map visited; + xvm::BindingSelectionResolver resolver(source.versions); + std::function(const std::string&, const std::string&)> release; + std::function(const fs::path&)> payload; + release = [&](const std::string& name, const std::string& key) -> std::expected { + auto selection = resolver.resolve(name, key); + if (!selection) return std::unexpected(selection.error().message); + for (const auto& [member, memberVersion] : selection->members) { + if (!opted_in_(source, member, memberVersion)) + return std::unexpected(member + "@" + memberVersion + ": source scope has not opted into this release"); + auto existing = result.members.find(member); + if (existing != result.members.end()) { + if (existing->second != memberVersion) return std::unexpected(member + ": conflicting dependency versions"); + continue; + } + const auto& info = source.versions.at(member); + const auto& data = info.versions.at(memberVersion); + if (!data.sourceHome.empty()) + return std::unexpected(member + ": chained borrowed provenance requires an explicit source layer"); + const auto incoming = borrowed_(data, source); + const auto coordinate = xvm::coordinate_from_payload_path(incoming.path); + const auto owner = xvm::recorded_owner(source.versions, member, memberVersion); + if (!coordinate || !owner || *owner != *coordinate) + return std::unexpected(member + "@" + memberVersion + ": provider contradicts its payload coordinate"); + if (const auto local = destination.find(member); local != destination.end()) { + for (const auto& [localVersion, localData] : local->second.versions) { + if (localData.sourceHome.empty()) + return std::unexpected(member + ": user-owned registration takes precedence; install the closure in this scope"); + if (localVersion == memberVersion && localData.sourceHome != incoming.sourceHome) + return std::unexpected(member + ": conflicting source layer provenance"); + } + } + result.members[member] = memberVersion; + auto& imported = result.registrations[member]; + imported.type = info.type; + imported.filename = info.filename; + imported.versions[memberVersion] = incoming; + // Only edges of selected registrations are imported. + for (const auto& [peer, edges] : info.bindings) + if (const auto edge = edges.find(memberVersion); edge != edges.end()) + imported.bindings[peer][memberVersion] = edge->second; + } + for (const auto& [member, memberVersion] : selection->members) { + auto root = payload_root_(source.home, borrowed_(source.versions.at(member).versions.at(memberVersion), source).path); + if (!root) return std::unexpected(root.error()); + if (!visited.contains(*root)) { + auto checked = payload(*root); + if (!checked) return checked; + } + } + return {}; + }; + payload = [&](const fs::path& root) -> std::expected { + if (auto source = domain_producer_source::validate_payload(root); !source) return source; + const auto seen = visited.find(root); + if (seen != visited.end()) { + if (seen->second == 1) return std::unexpected(root.string() + ": cyclic runtime resolution evidence"); + return {}; + } + if (visited.size() >= 4096) return std::unexpected("runtime dependency closure exceeds 4096 payloads"); + if (xim::stamped_incomplete(root)) + return std::unexpected(root.string() + ": source payload is stamped incomplete; run owner `xlings install --reconfig`"); + if (xim::classify_payload_platform(root) == xim::PayloadPlatform::Foreign) + return std::unexpected(root.string() + ": source payload belongs to another platform"); + visited[root] = 1; + auto owned = evidence::physical_store_root(source.home, root); + if (!owned) return std::unexpected(owned.error()); + auto checked = evidence::read_checked_resolution(*owned, [&](const fs::path& path) { + return evidence::physical_store_root(source.home, path); + }); + if (!checked) return std::unexpected(checked.error()); + for (const auto& dependency : checked->dependencies) { + const auto depRoot = std::expected{dependency.payload.root}; + if (auto visiting = visited.find(*depRoot); visiting != visited.end() && visiting->second == 1) + return std::unexpected(depRoot->string() + ": cyclic runtime resolution evidence"); + bool registered = false; + for (const auto& [depTarget, info] : source.versions) { + for (const auto& [depVersion, data] : info.versions) { + auto owned = payload_root_(source.home, xvm::expand_path(data.path, source.home.string())); + if (!owned || *owned != *depRoot) continue; + auto checked = release(depTarget, depVersion); + if (!checked) return checked; + registered = true; + } + } + if (!registered) return std::unexpected(depRoot->string() + ": runtime dependency has no registered release"); + } + visited[root] = 2; + result.payloads.push_back(root); + return {}; + }; + auto selected = release(target, version); + if (!selected) return std::unexpected(selected.error()); + // Every materialized source must belong to the checked closure. Header + // metadata can reference a dependency payload, but never an unrelated tree. + for (const auto& [name, info] : result.registrations) + for (const auto& [key, data] : info.versions) { + std::vector assets{data.path, data.includedir, data.libdir}; + const auto kind = xvm::effective_kind(info, data); + if (kind == "lib") { + const auto sourceName = xvm::effective_source_name(name, info, data, kind); + assets.push_back((fs::path(data.path) / sourceName).string()); + } else if (kind == "files") { + if (data.fileSrc.empty() || fs::path(data.fileSrc).is_absolute() || + data.fileDst.empty() || fs::path(data.fileDst).is_absolute()) + return std::unexpected(name + "@" + key + ": invalid borrowed file placement"); + for (const auto& part : fs::path(data.fileDst)) + if (part == "..") return std::unexpected(name + ": file destination escapes the scope"); + assets.push_back((fs::path(data.path) / data.fileSrc).string()); + } + for (const auto& header : data.bindingHeaders) assets.push_back(header.sourceDir); + for (const auto& asset : assets) { + if (asset.empty()) continue; + auto root = payload_root_(source.home, asset); + if (!root || std::ranges::find(result.payloads, *root) == result.payloads.end()) + return std::unexpected(name + "@" + key + ": asset source is outside its checked runtime closure"); + } + } + auto requested = payload_root_(source.home, result.registrations.at(target).versions.at(version).path); + if (!requested) return std::unexpected(requested.error()); + result.requestedPayload = *requested; + for (const auto& root : result.payloads) { + auto coordinate = xvm::coordinate_from_payload_path(root.generic_string()); + if (!coordinate) return std::unexpected(root.string() + ": missing checked coordinate"); + result.payloadCoordinates[coordinate->canonical()] = root; + } + for (const auto& [member, key] : result.members) { + const auto& data = result.registrations.at(member).versions.at(key); + auto root = payload_root_(source.home, data.path); + if (!root) return std::unexpected(root.error()); + if (*root == result.requestedPayload) result.requestedMembers[member] = key; + } + return result; +} + +std::expected, std::string> plan_borrow_package( + const Snapshot& source, const xvm::VersionDB& destination, + const std::string& provider, const std::string& version) { + BorrowPlan combined; + auto proposed = destination; + bool found = false; + for (const auto& [target, info] : source.versions) { + for (const auto& [key, data] : info.versions) { + const auto owner = xvm::recorded_owner(source.versions, target, key); + if (!owner) continue; + const auto identity = owner->ns.empty() ? owner->package : owner->ns + ":" + owner->package; + if (identity != provider || owner->version != version) continue; + found = true; + if (combined.requestedMembers.contains(target)) continue; + auto closure = plan_borrow(source, proposed, target, key); + if (!closure) return std::unexpected(closure.error()); + if (!combined.requestedPayload.empty() && combined.requestedPayload != closure->requestedPayload) + return std::unexpected(provider + "@" + version + ": conflicting physical package payloads"); + combined.requestedPayload = closure->requestedPayload; + for (const auto& [member, memberKey] : closure->members) { + if (const auto previous = combined.members.find(member); + previous != combined.members.end() && previous->second != memberKey) + return std::unexpected(member + ": conflicting package closure versions"); + combined.members[member] = memberKey; + } + combined.requestedMembers.insert(closure->requestedMembers.begin(), closure->requestedMembers.end()); + combined.payloadCoordinates.insert(closure->payloadCoordinates.begin(), closure->payloadCoordinates.end()); + for (const auto& root : closure->payloads) + if (std::ranges::find(combined.payloads, root) == combined.payloads.end()) combined.payloads.push_back(root); + for (const auto& [member, imported] : closure->registrations) { + auto& into = combined.registrations[member]; + into.type = imported.type; + into.filename = imported.filename; + for (const auto& [memberKey, value] : imported.versions) into.versions[memberKey] = value; + for (const auto& [peer, edges] : imported.bindings) + for (const auto& [memberKey, linked] : edges) into.bindings[peer][memberKey] = linked; + proposed[member] = into; + } + } + } + if (!found) return std::optional{}; + return std::optional{std::move(combined)}; +} +} diff --git a/src/core/subos/domain/layers.cppm b/src/core/subos/domain/layers.cppm new file mode 100644 index 000000000..b749f3fa5 --- /dev/null +++ b/src/core/subos/domain/layers.cppm @@ -0,0 +1,77 @@ +export module xlings.core.home.layers; + +import std; +export import xlings.core.xvm.types; + +export namespace xlings::home::layers { +namespace fs = std::filesystem; + +struct Snapshot { + fs::path home; + fs::path sourceHome; // Physical authority; home is the execution view. + fs::path logicalHome; + std::string scope; + xvm::VersionDB versions; + xvm::SubosWorkspace workspace; +#if defined(_MSC_VER) // platform-if-ok: compiler, not platform: GCC/MSVC module special members + Snapshot() = default; + ~Snapshot() = default; + Snapshot(const Snapshot&) = default; + Snapshot& operator=(const Snapshot&) = default; + Snapshot(Snapshot&&) = default; + Snapshot& operator=(Snapshot&&) = default; +#else + Snapshot(); + ~Snapshot(); + Snapshot(const Snapshot&); + Snapshot& operator=(const Snapshot&) ; + Snapshot(Snapshot&&); + Snapshot& operator=(Snapshot&&) ; +#endif +}; + +struct BorrowPlan { + xvm::VersionDB registrations; + xvm::Workspace members; + std::vector payloads; + fs::path requestedPayload; + xvm::Workspace requestedMembers; + std::map payloadCoordinates; +#if defined(_MSC_VER) // platform-if-ok: compiler, not platform: GCC/MSVC module special members + BorrowPlan() = default; + ~BorrowPlan() = default; + BorrowPlan(const BorrowPlan&) = default; + BorrowPlan& operator=(const BorrowPlan&) = default; + BorrowPlan(BorrowPlan&&) = default; + BorrowPlan& operator=(BorrowPlan&&) = default; +#else + BorrowPlan(); + ~BorrowPlan(); + BorrowPlan(const BorrowPlan&); + BorrowPlan& operator=(const BorrowPlan&) ; + BorrowPlan(BorrowPlan&&); + BorrowPlan& operator=(BorrowPlan&&) ; +#endif +}; + +// Reads the home authority and the opt-in state independently: an unreadable +// document is an error, while a genuinely absent document is observed empty. +std::expected read_snapshot(const fs::path& home, + std::string_view scope = "default"); +std::expected read_source_snapshot(const fs::path& physicalHome, + std::string_view scope = "default"); +bool owns_source_payload(const fs::path& physicalHome, const fs::path& payload); +// Resolves every release and every recorded runtime dependency before the +// caller changes state. Missing old evidence requires explicit --reconfig. +std::expected plan_borrow(const Snapshot& source, + const xvm::VersionDB& destination, const std::string& target, const std::string& version); + +// A formal package may register only differently named members. Absence is +// distinct from a present package whose evidence or provenance is broken. +std::expected, std::string> plan_borrow_package( + const Snapshot& source, const xvm::VersionDB& destination, + const std::string& provider, const std::string& version); + +// Physical ownership, never inferred from a borrowed registration's path. +bool owns_payload(const fs::path& home, const fs::path& payload); +} diff --git a/src/core/subos/domain/prefix_domain.cpp b/src/core/subos/domain/prefix_domain.cpp new file mode 100644 index 000000000..2f691e362 --- /dev/null +++ b/src/core/subos/domain/prefix_domain.cpp @@ -0,0 +1,170 @@ +module xlings.core.home.prefix_domain; +import std; +import xlings.core.home; +import xlings.libs.json; +import xlings.platform; + +namespace xlings::home::prefix_domain { +namespace { +using Json = nlohmann::json; +fs::path normal_(const fs::path& path) { return path.lexically_normal(); } +bool within_(const fs::path& root, const fs::path& path) { + const auto relative = path.lexically_relative(root); + return path == root || (!relative.empty() && !relative.is_absolute() && *relative.begin() != ".."); +} +std::expected present_(const fs::path& path) { + std::error_code ec; + const auto status = fs::symlink_status(path, ec); + if (status.type() == fs::file_type::not_found && (!ec || ec == std::errc::no_such_file_or_directory)) return false; + if (ec) return std::unexpected(path.string() + ": cannot inspect domain: " + ec.message()); + return true; +} +std::expected real_directory_(const fs::path& path) { + std::error_code ec; + const auto status = fs::symlink_status(path, ec); + if (ec || !fs::is_directory(status)) return std::unexpected(path.string() + ": domain directory is not a real directory"); + platform::read_symlink(path, ec); + if (!ec) return std::unexpected(path.string() + ": domain directory redirects through a link"); + return {}; +} +Json json_(const Domain& domain) { + Json result{{"schema", 1}, {"owner_home", domain.ownerHome.generic_string()}, + {"physical_home", domain.physicalHome.generic_string()}, {"logical_home", domain.logicalHome.generic_string()}, + {"layout", domain.layout}, {"private_home", domain.privateHome}, + {"system_candidate", domain.systemCandidate.generic_string()}}; + if (domain.systemSource) result["system_source"] = domain.systemSource->generic_string(); + return result; +} +std::expected validate_private_(const Domain& domain) { + auto exists = present_(domain.physicalHome); + if (!exists) return std::unexpected(exists.error()); + if (!*exists) return {}; + auto real = real_directory_(domain.physicalHome); + if (!real) return real; + const auto marker = domain.physicalHome / ".xlings-domain.json"; + std::error_code ec; + if (!fs::is_regular_file(fs::symlink_status(marker, ec)) || ec) + return std::unexpected(marker.string() + ": domain ownership must be a readable regular file"); + auto doc = read_json_for_update(marker); + if (!doc) return std::unexpected(doc.error()); + auto expected = json_(domain); + for (const auto* key : {"schema", "owner_home", "physical_home", "logical_home", "layout", "private_home", "system_candidate"}) + if (!doc->contains(key) || (*doc)[key] != expected[key]) + return std::unexpected(marker.string() + ": missing or contradictory domain ownership; existing data preserved"); + if (doc->contains("system_source") != expected.contains("system_source") || + (expected.contains("system_source") && (*doc)["system_source"] != expected["system_source"])) + return std::unexpected(marker.string() + ": system domain source changed; existing data preserved"); + auto shared = shares_store(domain.physicalHome); + if (!shared || !*shared) return std::unexpected(shared ? marker.string() + ": private domain home is not root/multi" : shared.error()); + return {}; +} +std::expected safe_parents_(const Domain& domain, bool create) { + for (const auto& relative : {fs::path("domains"), fs::path("domains/xlings")}) { + const auto path = domain.ownerHome / relative; + auto exists = present_(path); + if (!exists) return std::unexpected(exists.error()); + if (*exists) { + auto real = real_directory_(path); + if (!real) return real; + } else if (create) { + std::error_code ec; + if (!fs::create_directory(path, ec) || ec) return std::unexpected(path.string() + ": domain parent creation conflict"); + } + } + return {}; +} +} + +std::expected resolve(const fs::path& ownerHome, const fs::path& logicalHome, + const fs::path& systemCandidate) { + std::error_code ec; + const auto owner = fs::canonical(ownerHome, ec); + if (ec || !fs::is_directory(owner, ec) || ec) return std::unexpected(ownerHome.string() + ": domain owner home is unreadable"); + if (!logicalHome.is_absolute() || normal_(logicalHome) != logicalHome) + return std::unexpected("prefix domain must be an absolute normalized path"); + const auto originalOwner = fs::absolute(ownerHome, ec).lexically_normal(); + if (ec) return std::unexpected(ownerHome.string() + ": cannot resolve the owner's logical prefix"); + auto ownerMarker = shares_store(owner); + if (!ownerMarker) return std::unexpected(ownerMarker.error()); + if (logicalHome == owner || logicalHome == originalOwner) + return Domain{owner, owner, logicalHome, "single", std::nullopt, false, {}}; + if (logicalHome != fs::path("/xlings")) + return std::unexpected("prefix domain must be /xlings or the owner's existing home path"); + if (!systemCandidate.is_absolute() || normal_(systemCandidate) != systemCandidate) + return std::unexpected("system domain candidate must be an absolute normalized path"); + Domain result{owner, owner / "domains/xlings/private", "/xlings", "multi", std::nullopt, true, systemCandidate}; + auto systemExists = present_(systemCandidate); + if (!systemExists) return std::unexpected(systemExists.error()); + if (*systemExists) { + auto real = real_directory_(systemCandidate); + if (!real) return std::unexpected(real.error()); + auto shared = shares_store(systemCandidate); + if (!shared) return std::unexpected(shared.error()); + if (!*shared) return std::unexpected(systemCandidate.string() + ": existing system domain is not declared multi or root/multi"); + result.systemSource = fs::canonical(systemCandidate, ec); + if (ec) return std::unexpected(systemCandidate.string() + ": system domain is unreadable"); + } + auto parents = safe_parents_(result, false); + if (!parents) return std::unexpected(parents.error()); + auto validated = validate_private_(result); + if (!validated) return std::unexpected(validated.error()); + return result; +} + +std::expected prepare_private(const Domain& domain) { + if (!domain.privateHome) return real_directory_(domain.physicalHome); + auto fresh = resolve(domain.ownerHome, domain.logicalHome, + domain.systemCandidate); + if (!fresh) return std::unexpected(fresh.error()); + if (fresh->physicalHome != domain.physicalHome || fresh->systemSource != domain.systemSource) + return std::unexpected("prefix domain source changed after selection"); + auto parents = safe_parents_(domain, true); + if (!parents) return parents; + auto exists = present_(domain.physicalHome); + if (!exists) return std::unexpected(exists.error()); + if (*exists) return validate_private_(domain); + std::error_code ec; + if (!fs::create_directory(domain.physicalHome, ec) || ec) + return std::unexpected(domain.physicalHome.string() + ": private domain reservation conflict"); + try { + Json marker{{"schema", 1}, {"id", "domain-" + std::format("{:x}", std::random_device{}())}, + {"mode", "root"}, {"layout", "multi"}}; + platform::write_file_atomic((domain.physicalHome / ".xlings-home").string(), marker.dump(2)); + platform::write_file_atomic((domain.physicalHome / ".xlings-domain.json").string(), json_(domain).dump(2)); + } catch (const std::exception& error) { + return std::unexpected(domain.physicalHome.string() + ": domain reservation incomplete; preserved for review: " + error.what()); + } + return {}; +} + +std::expected map_host(const Domain& domain, const fs::path& logicalPath) { + if (!logicalPath.is_absolute() || normal_(logicalPath) != logicalPath || !within_(domain.logicalHome, logicalPath)) + return std::unexpected(logicalPath.string() + ": path escapes its logical prefix domain"); + return (domain.physicalHome / logicalPath.lexically_relative(domain.logicalHome)).lexically_normal(); +} +std::expected map_guest(const Domain& domain, const fs::path& physicalPath) { + if (!physicalPath.is_absolute() || normal_(physicalPath) != physicalPath) + return std::unexpected(physicalPath.string() + ": physical prefix path is not absolute and normalized"); + for (const auto& root : {domain.physicalHome, domain.systemSource.value_or(domain.physicalHome)}) + if (within_(root, physicalPath)) return (domain.logicalHome / physicalPath.lexically_relative(root)).lexically_normal(); + return std::unexpected(physicalPath.string() + ": path does not belong to this prefix domain"); +} +std::string serialize(const Domain& domain) { return json_(domain).dump(); } +std::expected parse(std::string_view document, const fs::path& ownerHome) { + try { + const auto json = Json::parse(document); + if (!json.is_object() || json.at("schema") != 1 || !json.at("private_home").is_boolean()) + return std::unexpected("invalid prefix domain schema"); + const fs::path logical(json.at("logical_home").get()); + const auto system = json.contains("system_candidate") ? fs::path(json.at("system_candidate").get()) : fs::path("/xlings"); + auto selected = resolve(ownerHome, logical, system); + if (!selected) return selected; + for (const auto* key : {"owner_home", "physical_home", "logical_home", "layout", "private_home"}) + if (json.at(key) != json_(*selected).at(key)) return std::unexpected("prefix domain ownership disagrees with its current owner"); + if (json.contains("system_source") != selected->systemSource.has_value() || + (selected->systemSource && json.at("system_source") != selected->systemSource->generic_string())) + return std::unexpected("prefix domain system source changed after declaration"); + return selected; + } catch (const std::exception& error) { return std::unexpected(std::string("invalid prefix domain document: ") + error.what()); } +} +} diff --git a/src/core/subos/domain/prefix_domain.cppm b/src/core/subos/domain/prefix_domain.cppm new file mode 100644 index 000000000..222aba548 --- /dev/null +++ b/src/core/subos/domain/prefix_domain.cppm @@ -0,0 +1,28 @@ +export module xlings.core.home.prefix_domain; +import std; + +export namespace xlings::home::prefix_domain { +namespace fs = std::filesystem; +struct Domain { + fs::path ownerHome; + fs::path physicalHome; + fs::path logicalHome; + std::string layout; + std::optional systemSource; + bool privateHome { false }; + fs::path systemCandidate; +}; + +// A pure selection. Explicit /xlings domains use an owner-private physical +// home; a valid system home supplies read-only payloads, never a writable home. +std::expected resolve(const fs::path& ownerHome, + const fs::path& logicalHome = "/xlings", const fs::path& systemCandidate = "/xlings"); +// Reserves a missing private home exclusively; unknown pre-existing homes, +// links and contradictory domain markers are refused without cleanup. +std::expected prepare_private(const Domain& domain); +std::expected map_host(const Domain& domain, const fs::path& logicalPath); +std::expected map_guest(const Domain& domain, const fs::path& physicalPath); +// Persisted with an instance and consumed by the namespace producer. +std::string serialize(const Domain& domain); +std::expected parse(std::string_view document, const fs::path& ownerHome); +} diff --git a/src/core/subos/keeper.cppm b/src/core/subos/keeper.cppm deleted file mode 100644 index ba72e4f39..000000000 --- a/src/core/subos/keeper.cppm +++ /dev/null @@ -1,195 +0,0 @@ -// Auto-keeper for high-frequency sandbox exec (M4 — Linux only). -// -// Problem: each `xlings subos use --sandbox --cmd ...` invocation -// spins up bwrap from scratch — for tmpfs/image storage this means -// mount setup on every call, ~100-500ms overhead. An agent running 100 -// commands burns 10-50 seconds on mount overhead alone. -// -// Solution: keep one bwrap process alive in the background after the -// first sandbox use; record its PID. Subsequent `subos use --cmd ...` -// invocations nsenter into that bwrap's mount namespace instead of -// remounting from scratch. -// -// Lifecycle: -// - First use: spawn bwrap with a long-running keeper script; -// write PID to /.keeper.pid. -// - Subsequent: detect alive keeper → nsenter --mount=/proc//ns/mnt -// -- sh -c . Touch /.keeper.lastused. -// - Idle TTL : keeper's own polling loop exits if .lastused not bumped -// for `ttl_sec`; bwrap exits → mount namespace gone. -// - Stop : `xlings subos stop ` sends SIGTERM, cleans state. -// - Stale : if PID file exists but process is dead, treat as -// no-keeper and respawn. -// -// Auto-trigger (per design): storage=image|tmpfs + --sandbox + Linux. -// In this MVP the keeper is OPT-IN via --keep flag; the auto-default -// trigger is a one-line toggle in use_sandbox_mode_ that we leave off -// pending broader e2e soak. M5 adds --no-keep / --ttl / --keep -// overrides. -// -// Refs: .agents/docs/subos-as-xpkg-design-2026-05-16.md (M4, D9). -module; - -// POSIX headers used by the Linux keeper primitives must live in the -// global module fragment so they don't collide with `import std;` later. -// Same pattern as subos.cppm itself uses. -#if defined(__linux__) -#include -#include -#include -#include -#endif - -export module xlings.core.subos.keeper; - -import std; -import xlings.core.config; - -export namespace xlings::subos::keeper { - -namespace fs = std::filesystem; - -// Default idle TTL in seconds — the keeper self-exits when no -// .keeper.lastused activity is observed within this window. 5 min is -// the design default (D9): long enough that an agent's multi-step -// workflow doesn't re-mount between steps, short enough that idle -// keepers don't pile up on the system. -constexpr int DEFAULT_TTL_SEC = 300; - -// Special value passed via --keep: keeper never expires on idle. Caller -// must `subos stop` explicitly. Encoded as a very large int (10 years). -constexpr int TTL_NEVER = 60 * 60 * 24 * 365 * 10; - -struct KeeperState { - fs::path pidFile; // /subos//.keeper.pid - fs::path lastUsedFile; // /subos//.keeper.lastused -}; - -inline KeeperState state_for(const std::string& subosName) { - auto& p = Config::paths(); - auto dir = p.homeDir / "subos" / subosName; - return { dir / ".keeper.pid", dir / ".keeper.lastused" }; -} - -// Touch the last-used timestamp. Called by every exec that hits the -// keeper so its idle countdown resets. -inline void touch_activity(const std::string& subosName) { - auto s = state_for(subosName); - std::ofstream ofs(s.lastUsedFile, std::ios::trunc); - if (ofs) { - auto now = std::chrono::system_clock::now().time_since_epoch(); - auto secs = std::chrono::duration_cast(now).count(); - ofs << secs; - } -} - -// Is there a live keeper for this subos? Stale PID files are cleaned -// up here so callers can treat false as a definitive "no keeper". -inline bool is_alive(const std::string& subosName) { -#if !defined(__linux__) - (void)subosName; - return false; -#else - auto s = state_for(subosName); - if (!fs::exists(s.pidFile)) return false; - std::ifstream ifs(s.pidFile); - pid_t pid = -1; - ifs >> pid; - if (pid <= 0 || ::kill(pid, 0) != 0) { - // Stale → clean up so the next caller respawns fresh - std::error_code ec; - fs::remove(s.pidFile, ec); - fs::remove(s.lastUsedFile, ec); - return false; - } - return true; -#endif -} - -// Record a freshly-spawned keeper's PID. Called by the sandbox-entry -// path after fork()+exec(bwrap, ...) returns the child PID. -inline void register_pid(const std::string& subosName, int pid) { - auto s = state_for(subosName); - std::error_code ec; - fs::create_directories(s.pidFile.parent_path(), ec); - { - std::ofstream ofs(s.pidFile, std::ios::trunc); - if (ofs) ofs << pid; - } - touch_activity(subosName); -} - -// nsenter into the keeper's mount namespace and run cmd via sh -c. -// Returns the wrapped command's exit code, or -1 on failure to launch. -// On non-Linux: returns -1 (caller falls back to fresh-sandbox path). -inline int nsenter_and_exec(const std::string& subosName, - const std::string& cmd) { -#if !defined(__linux__) - (void)subosName; (void)cmd; - return -1; -#else - auto s = state_for(subosName); - std::ifstream ifs(s.pidFile); - pid_t pid = -1; - ifs >> pid; - if (pid <= 0) return -1; - - touch_activity(subosName); - - // Build nsenter cmdline. We can't pass cmd directly to /bin/sh -c - // through system() without escaping its single quotes — wrap and - // escape so embedded ' chars survive. - std::string escaped; - escaped.reserve(cmd.size() + 8); - for (char c : cmd) { - if (c == '\'') escaped += "'\\''"; // close, escape, reopen - else escaped += c; - } - auto full = std::format("nsenter --mount=/proc/{}/ns/mnt -- /bin/sh -c '{}'", - pid, escaped); - auto rc = std::system(full.c_str()); - if (rc == -1) return -1; - if (WIFEXITED(rc)) return WEXITSTATUS(rc); - return rc; -#endif -} - -// Stop the keeper (sent by `xlings subos stop ` or by lifecycle -// hooks like subos remove). SIGTERM gives the keeper a chance to clean -// up; if it doesn't die in 2s we follow with SIGKILL. -inline int stop_keeper(const std::string& subosName) { - auto s = state_for(subosName); - if (!fs::exists(s.pidFile)) return 0; -#if defined(__linux__) - std::ifstream ifs(s.pidFile); - pid_t pid = -1; - ifs >> pid; - if (pid > 0 && ::kill(pid, 0) == 0) { - ::kill(pid, SIGTERM); - // Best-effort wait - for (int i = 0; i < 20; ++i) { - if (::kill(pid, 0) != 0) break; - ::usleep(100'000); // 100ms - } - if (::kill(pid, 0) == 0) ::kill(pid, SIGKILL); - } -#endif - std::error_code ec; - fs::remove(s.pidFile, ec); - fs::remove(s.lastUsedFile, ec); - return 0; -} - -// Trigger predicate for auto-spawn — defined per D9: only meaningful -// for image/tmpfs storage in sandbox mode on Linux. -inline bool should_auto_keeper(const std::string& storage, bool sandbox) { -#if !defined(__linux__) - (void)storage; (void)sandbox; - return false; -#else - if (!sandbox) return false; - return storage == "image" || storage == "tmpfs"; -#endif -} - -} // namespace xlings::subos::keeper diff --git a/src/core/subos/ports.cpp b/src/core/subos/ports.cpp new file mode 100644 index 000000000..50db2d11a --- /dev/null +++ b/src/core/subos/ports.cpp @@ -0,0 +1,40 @@ +module xlings.core.subos.ports; + +import std; +import xlings.core.config; +import xlings.core.entry_binary; +import xlings.core.xim.commands; +import xlings.core.xvm.shim; +import xlings.core.xvm.shim_identity; +import xlings.runtime; +import xlings.subos.home_view; +import xlings.subos.ports; + +namespace xlings::subos { + +HomeView home_view() { + return HomeView{ .home = Config::paths().homeDir }; +} + +Ports make_ports(EventStream& stream) { + Ports ports; + ports.install_backend = [&stream](std::string_view package) -> std::expected { + std::vector targets{ "xim:" + std::string(package) }; + if (xim::cmd_install(targets, /*yes=*/true, /*noDeps=*/false, stream) != 0) + return std::unexpected("could not install " + std::string(package)); + return {}; + }; + ports.shim_owner = + [classifier = xvm::ShimClassifier{entry_binary::path_of(Config::paths().homeDir)}]( + const std::filesystem::path& path) mutable -> std::optional { + // This asks whether the backend candidate is an xlings dispatcher; + // resolve_owner_home alone also falls back to the caller's entry. + const auto identity = classifier.classify(path); + if (identity.state == xvm::ShimState::Current || identity.state == xvm::ShimState::Stale) + return xvm::resolve_owner_home(path); + return std::nullopt; + }; + return ports; +} + +} // namespace xlings::subos diff --git a/src/core/subos/ports.cppm b/src/core/subos/ports.cppm new file mode 100644 index 000000000..7fb954e7e --- /dev/null +++ b/src/core/subos/ports.cppm @@ -0,0 +1,21 @@ +export module xlings.core.subos.ports; + +import std; +import xlings.core.config; +import xlings.runtime; +import xlings.subos.home_view; +import xlings.subos.ports; + +// The adapter between xlings core and the SubOS core (design §23): this home +// as the SubOS core's HomeView, and xlings' package manager behind its Ports. +// Everything that knows both sides lives in src/core/subos/; modules/subos +// knows neither Config nor xim. +export namespace xlings::subos { + +// This home, as the SubOS core sees it. +HomeView home_view(); + +// The ports, bound to an event stream for anything they report. +Ports make_ports(EventStream& stream); + +} // namespace xlings::subos diff --git a/src/core/subos/root/root.cpp b/src/core/subos/root/root.cpp new file mode 100644 index 000000000..e9fdcefd8 --- /dev/null +++ b/src/core/subos/root/root.cpp @@ -0,0 +1,265 @@ +module xlings.core.subos.root; + +import std; +import luban.machine; +import xlings.libs.json; +import xlings.platform; +import xlings.core.profile; +import xlings.core.config; +import xlings.core.xvm.types; +import xlings.core.xvm.db; +import xlings.core.xvm.shim; +import xlings.subos.rootfs; +import xlings.subos.roles; +import luban.boot; +import xlings.subos.home_view; +import xlings.subos.library_cache; +import xlings.core.subos.store_closure; +import xlings.core.subos.root_view; +import xlings.store; +import xlings.core.xim.retention; + +namespace xlings::subos_root { + +namespace rf = xlings::subos::rootfs; + +namespace { + +std::expected read_json(const fs::path& p) { + std::error_code ec; + const auto status = fs::symlink_status(p, ec); + if (ec && ec != std::errc::no_such_file_or_directory) + return std::unexpected(p.string() + ": cannot be inspected: " + ec.message()); + if (status.type() == fs::file_type::not_found) return nlohmann::json::object(); + if (!fs::is_regular_file(p, ec) || ec) + return std::unexpected(p.string() + ": is not a readable regular file"); + std::ifstream in(p, std::ios::binary); + if (!in) return std::unexpected(p.string() + ": cannot be read"); + std::string bytes; + try { bytes.assign(std::istreambuf_iterator(in), {}); } + catch (const std::exception& e) { return std::unexpected(p.string() + ": " + e.what()); } + auto j = nlohmann::json::parse(bytes, nullptr, false); + if (in.bad() || !j.is_object()) + return std::unexpected(p.string() + ": could not be parsed as a JSON object"); + return j; +} + +std::expected kind_in(const nlohmann::json& j, const fs::path& file) { + auto it = j.find("kind"); + if (it == j.end()) return rl::Kind::View; + if (it->is_string()) + if (auto kind = rl::kind_from_string(it->get()); kind) return *kind; + return std::unexpected(file.string() + ": unknown or invalid SubOS kind"); +} + +fs::path entry_of(const fs::path& home) { return home / "bin" / "xlings"; } + +} // namespace + +rl::Kind kind_of(const fs::path& home, std::string_view name) { + return read_kind(home, name).value_or(rl::Kind::View); +} + +std::expected read_kind(const fs::path& home, std::string_view name) { + const auto file = subos::HomeView{home}.instance_file(name); + const auto j = read_json(file); + if (!j) return std::unexpected(j.error()); + return kind_in(*j, file); +} + +std::expected declare_kind(const fs::path& home, std::string_view name, + rl::Kind kind) { + const auto file = subos::HomeView{home}.instance_file(name); + auto j = read_json(file); + if (!j) return std::unexpected(j.error()); + if (auto previous = kind_in(*j, file); !previous) return std::unexpected(previous.error()); + (*j)["kind"] = std::string(rl::to_string(kind)); + std::error_code ec; + fs::create_directories(file.parent_path(), ec); + if (ec) return std::unexpected("cannot create " + file.parent_path().string() + ": " + ec.message()); + try { + platform::write_file_atomic(file.string(), j->dump(2) + "\n"); + } catch (const std::exception& e) { + return std::unexpected(e.what()); + } + return {}; +} + +std::optional running_host(const fs::path& home) { + return read_running_host(home).value_or(std::nullopt); +} + +std::expected, std::string> read_running_host(const fs::path& home) { + const auto anchor = read_json("/etc/xlings/root.json"); + if (!anchor) return std::unexpected(anchor.error()); + auto it = anchor->find("home"); + std::error_code anchor_ec; + const auto status = fs::symlink_status("/etc/xlings/root.json", anchor_ec); + if (status.type() == fs::file_type::not_found) return std::nullopt; + if (it == anchor->end() || !it->is_string()) + return std::unexpected("/etc/xlings/root.json: home must be an absolute path string"); + const auto declared = fs::path(it->get()); + if (declared.empty() || !declared.is_absolute() || declared.lexically_normal() != declared) + return std::unexpected("/etc/xlings/root.json: home must be a nonempty absolute path"); + std::error_code ec; + if (!fs::equivalent(declared, home, ec)) return std::nullopt; + return rf::host_of("/", declared); +} + +rl::Role role_of(const fs::path& home, std::string_view name) { + return read_role(home, name).value_or(rl::Role{}); +} + +std::expected read_role(const fs::path& home, std::string_view name) { + rl::Role r; + auto host = read_running_host(home); + if (!host) return std::unexpected(host.error()); + r.host = *host == std::string(name); + auto c = luban::boot::load(subos::HomeView{home}.boot_file()); + if (!c) return std::unexpected(c.error()); + { + std::error_code ec; + if (fs::exists(subos::HomeView{home}.boot_file(), ec)) + r.boot_entry = c->default_entry == name || c->fallback == name + || (c->once && *c->once == name); + } + return r; +} + +fs::path tree_of(const fs::path& home, std::string_view name) { + if (running_host(home) == std::string(name)) return "/"; + return subos::HomeView{home}.instance(name) / std::string(rf::kTree); +} + +rf::Inputs inputs(const fs::path& home, const fs::path& subos_dir, + const xvm::Workspace& workspace, const xvm::VersionDB& db) { + rf::Inputs in; + in.sysroot_usr = subos_dir / "usr"; + in.sysroot_lib = subos_dir / "lib"; + in.sysroot_share = subos_dir / "share"; + const auto h = home.string(); + std::set seen; + bool have_xlings = false; + for (const auto& [target, version] : workspace) { + auto it = db.find(target); + if (it == db.end()) continue; + auto vit = it->second.versions.find(version); + if (vit == it->second.versions.end()) continue; + const auto& info = it->second; + const auto& data = vit->second; + const auto kind = xvm::effective_kind(info, data); + const auto source = xvm::effective_source_name(target, info, data, kind); + const auto dest = xvm::effective_destination_name(target, data, kind, source); + const fs::path dir = xvm::expand_path(data.path, h); + if (auto root = store::payload_root(dir); root && seen.insert(*root).second) + in.payloads.push_back(*root); + if (kind == "program" && !dest.empty()) { + if (dest == "xlings") have_xlings = true; + // Arguments have to be added at exec time: such a program keeps + // its shim. Nothing on the boot path has any. + if (!data.alias.empty()) { + in.programs.push_back({dest, entry_of(home)}); + continue; + } + auto file = xvm::resolve_executable(source, data.path, h); + if (file.empty()) file = dir / source; + in.programs.push_back({dest, file}); + } else if (kind == "lib" && !dest.empty()) { + in.libraries.push_back({dest, dir / source}); + } + } + if (!have_xlings) in.programs.push_back({"xlings", entry_of(home)}); + // Stage-0 at a fixed path in every root (part 2 §8.2): what an init's + // restart hands / to, for `subos boot --now`. + in.programs.push_back({"xlings-init", entry_of(home)}); + if (std::error_code ec; fs::exists(home / "bin" / "luban-init", ec)) + in.programs.push_back({"luban-init", home / "bin" / "luban-init"}); + return in; +} + +std::expected refresh_cache(const fs::path& home, std::string_view name) { + const auto tree = tree_of(home, name); + if (tree == "/") + return subos::library_cache::refresh(tree, {home}, name); + std::error_code ec; + if (!fs::exists(tree / "usr/bin/ldconfig", ec)) { + if (ec) + return std::unexpected("cannot inspect root ldconfig: " + ec.message()); + return false; + } + auto scope = store_closure::read_scope(home, std::string(name), tree); + if (!scope) + return std::unexpected(scope.error()); + auto view = root_view::prepare(*scope); + if (!view) + return std::unexpected(view.error()); + const auto result = subos::library_cache::refresh(tree, {home}, name, (*view)->bindings()); + (*view)->close(); + return result; +} + +std::optional> +refresh(const fs::path& home, std::string_view name, const fs::path& subos_dir, + const xvm::Workspace& workspace, const xvm::VersionDB& db, std::string_view reason) { + auto kind = read_kind(home, name); + if (!kind) return std::unexpected(kind.error()); + if (*kind != rl::Kind::Rootfs) return std::nullopt; + if (auto role = read_role(home, name); !role) return std::unexpected(role.error()); + Refreshed out; + const auto plan = rf::plan(inputs(home, subos_dir, workspace, db)); + out.conflicts = plan.conflicts.size(); + const auto before = rf::current(subos_dir); + auto gen = rf::commit(subos_dir, plan, reason); + if (!gen) return std::unexpected(gen.error()); + out.generation = *gen; + out.changed = !before || *before != *gen; + + const auto failed = [&](std::string error) -> std::expected { + if (before && out.changed) { + if (auto restored = rf::switch_to(subos_dir, *before, rf::Flush::Durable, rf::Verify::Tree); !restored) + error += "; cannot restore prior generation: " + restored.error(); + } + return std::unexpected(std::move(error)); + }; + + // The machine state of the root this SubOS is: the instance's own tree, + // or `/` when it is the running host. + try { + const auto tree = tree_of(home, name); + if (tree != "/") { + if (auto laid = rf::lay_out(tree, rf::usr_of(subos_dir), home); !laid) + return failed(laid.error()); + } + const auto usr = rf::usr_of(subos_dir); + auto etc = luban::machine::fill_machine_etc(tree / "etc", subos_dir); + if (!etc) return failed("machine /etc " + (tree / "etc").string() + ": " + etc.error()); + out.etc_added = std::move(*etc); + auto users = luban::machine::apply_sysusers(tree / "etc", usr); + if (!users) return failed("machine /etc " + (tree / "etc").string() + ": " + users.error()); + out.users_added = std::move(*users); + if (auto cache = refresh_cache(home, name); !cache) + return failed(cache.error()); + } catch (const std::exception& error) { + return failed(error.what()); + } + // Retention (design part 3 §7.1): the newest kKeepGenerations besides the + // current one; what only the pruned ones held is released. + if (out.changed && !rf::prune(subos_dir, rf::kKeepGenerations).empty()) + (void)xim::retention::release(); + return out; +} + +std::optional> +refresh(const fs::path& home, std::string_view name, std::string_view reason) { + auto kind = read_kind(home, name); + if (!kind) return std::unexpected(kind.error()); + if (*kind != rl::Kind::Rootfs) return std::nullopt; + const auto dir = subos::HomeView{home}.instance(name); + xvm::Workspace ws; + for (auto& snap : profile::load_subos_snapshots(home)) + if (snap.name == name) ws = snap.workspace.active; + const auto db = Config::versions(); + return refresh(home, name, dir, ws, db, reason); +} + +} // namespace xlings::subos_root diff --git a/src/core/subos/root/root.cppm b/src/core/subos/root/root.cppm new file mode 100644 index 000000000..cf254de0b --- /dev/null +++ b/src/core/subos/root/root.cppm @@ -0,0 +1,65 @@ +export module xlings.core.subos.root; + +import std; +import xlings.core.xvm.types; +import xlings.subos.rootfs; +import xlings.subos.roles; + +// The adapter between a SubOS's workspace and its root projection (design +// part 2 §6): xvm's entries and the payloads they live in, turned into +// `rootfs::Inputs`; the generation written; the instance's root tree laid +// out with its machine state. Also what the SubOS is (its declared kind) and +// what it is to the running machine (its role). +// +// Called by `xself::sync_shim_tables` -- the one function every workspace +// change goes through -- so an install, a use or a remove in a rootfs SubOS +// produces a new generation the same way it rebuilds a view's shims. +export namespace xlings::subos_root { + +namespace fs = std::filesystem; +namespace rl = xlings::subos::roles; + +// Declared at `subos new --rootfs`, in config/subos//instance.json (owner- +// written, read-only inside, like the policy). Absent is a view: every SubOS +// made before part 2 keeps behaving as one. +rl::Kind kind_of(const fs::path& home, std::string_view name); +// Decision paths must refuse invalid present metadata instead of downgrading +// a rootfs to a view. Only missing legacy metadata defaults to a view. +std::expected read_kind(const fs::path& home, std::string_view name); +std::expected declare_kind(const fs::path& home, std::string_view name, + rl::Kind kind); + +// The SubOS that is `/` of the running machine, when this home is that +// machine's system home (deployment R: /etc/xlings/root.json names it and +// /usr is its projection). +std::optional running_host(const fs::path& home); +std::expected, std::string> read_running_host(const fs::path& home); +rl::Role role_of(const fs::path& home, std::string_view name); +std::expected read_role(const fs::path& home, std::string_view name); +// Where a SubOS's root tree is: `/` for the running host, else /rootfs. +fs::path tree_of(const fs::path& home, std::string_view name); + +subos::rootfs::Inputs inputs(const fs::path& home, const fs::path& subos_dir, + const xvm::Workspace& workspace, const xvm::VersionDB& db); + +std::expected refresh_cache(const fs::path& home, std::string_view name); + +struct Refreshed { + int generation { 0 }; + bool changed { false }; + std::size_t conflicts { 0 }; + std::vector etc_added; + std::vector users_added; +}; + +// Bring a rootfs SubOS's projection up to date with `workspace`. A view is +// left alone (nullopt). +std::optional> +refresh(const fs::path& home, std::string_view name, const fs::path& subos_dir, + const xvm::Workspace& workspace, const xvm::VersionDB& db, std::string_view reason); + +// The same for a SubOS of this home by name, reading its workspace from disk. +std::optional> +refresh(const fs::path& home, std::string_view name, std::string_view reason); + +} // namespace xlings::subos_root diff --git a/src/core/subos/root/root_cmd.cpp b/src/core/subos/root/root_cmd.cpp new file mode 100644 index 000000000..6399eb611 --- /dev/null +++ b/src/core/subos/root/root_cmd.cpp @@ -0,0 +1,1073 @@ +// `subos` commands of the root projection (design part 2): `new --rootfs`, +// rollback, boot, export, diff, pack -- and the role table every command that +// could hurt a running system asks. +module xlings.core.subos; + +import std; +import xlings.core.config; +import xlings.core.home; +import xlings.core.home.prefix_domain; +import xlings.core.home.domain_producer; +import xlings.core.home.domain_producer_source; +import xlings.core.subos.store_closure; +import xlings.libs.json; +import xlings.core.log; +import xlings.platform; +import xlings.core.profile; +import xlings.core.xvm.types; +import xlings.core.xvm.db; +import xlings.core.elfread; +import xlings.core.subos.root; +import xlings.subos.home_view; +import xlings.subos.policy_store; +import xlings.subos.policy; +import xlings.subos.rootfs; +import xlings.subos.library_cache; +import xlings.subos.roles; +import luban.boot; +import xlings.observe; +import xlings.core.subos.ports; +import xlings.core.xself; +import xlings.core.xim.extract; +import xlings.subos.tools; +import xlings.subos.caps; + +namespace xlings::subos { + +namespace { + +namespace rf = xlings::subos::rootfs; +namespace bt = luban::boot; + +fs::path home_dir_() { return Config::paths().homeDir; } + +std::optional workspace_of_(const std::string& name) { + for (auto& snap : profile::load_subos_snapshots(home_dir_())) + if (snap.name == name) return snap.workspace; + return std::nullopt; +} + +void error_(EventStream& stream, std::string message, std::string hint = {}, + ErrorCode code = ErrorCode::InvalidInput) { + stream.emit(ErrorEvent{ .code = code, .message = std::move(message), .recoverable = false, + .hint = std::move(hint) }); +} + +bool exists_(const std::string& name) { + std::error_code ec; + return !name.empty() && fs::is_directory(HomeView{home_dir_()}.instance(name), ec); +} + +std::string read_text_(const fs::path& p) { + std::ifstream in(p, std::ios::binary); + return {std::istreambuf_iterator(in), {}}; +} + +// A scratch directory belongs to this operation only after exclusive creation. +class OwnedStage { +private: + fs::path path_; + +public: + explicit OwnedStage(fs::path path) : path_{std::move(path)} {} + OwnedStage(OwnedStage&& other) noexcept : path_{std::exchange(other.path_, {})} {} + OwnedStage(const OwnedStage&) = delete; + ~OwnedStage() { + if (path_.empty()) return; + std::error_code ec; + fs::remove_all(path_, ec); // subos-remove-all-ok: exclusively created scratch tree, never an existing output + } + const fs::path& path() const { return path_; } + + static std::expected create(const fs::path& parent) { + std::error_code ec; + fs::create_directories(parent, ec); + if (ec) return std::unexpected("cannot create output directory: " + ec.message()); + static std::atomic serial { 0 }; + for (int attempt = 0; attempt < 100; ++attempt) { + const auto path = parent / std::format(".xlings-stage-{}-{}", + std::chrono::steady_clock::now().time_since_epoch().count(), serial++); + if (fs::create_directory(path, ec)) return OwnedStage{path}; + if (ec && ec != std::errc::file_exists) + return std::unexpected("cannot create staging directory: " + ec.message()); + } + return std::unexpected("cannot reserve a staging directory"); + } +}; + +std::expected require_new_output_(const fs::path& path) { + std::error_code ec; + const auto status = fs::symlink_status(path, ec); + if (ec && ec != std::errc::no_such_file_or_directory) + return std::unexpected("cannot inspect output " + path.string() + ": " + ec.message()); + if (fs::exists(status)) + return std::unexpected("output already exists: " + path.string()); + return {}; +} + +std::expected publish_file_(const fs::path& from, const fs::path& to) { + return platform::rename_no_replace(from, to); +} + +bool safe_component_(std::string_view value) { + return !value.empty() && value != "." && value != ".." + && std::ranges::all_of(value, [](unsigned char c) { + return std::isalnum(c) || c == '.' || c == '_' || c == '+' || c == '-'; + }); +} + +std::expected write_json_(const fs::path& path, const nlohmann::json& value) { + std::ofstream out(path); + out << value.dump(2) << "\n"; + out.close(); + if (!out) return std::unexpected("cannot write " + path.string()); + return {}; +} + +// /data/xpkgs// of an absolute path. +// A copy that keeps links as links (a payload is full of them). +std::expected copy_tree_(const fs::path& from, const fs::path& to) { + std::error_code ec; + fs::create_directories(to.parent_path(), ec); + if (ec) return std::unexpected("cannot create copy destination: " + ec.message()); + fs::copy(from, to, fs::copy_options::recursive | fs::copy_options::copy_symlinks + | fs::copy_options::overwrite_existing, ec); + if (ec) return std::unexpected(std::format("cannot copy {}: {}", from.string(), ec.message())); + return {}; +} + +int run_tool_(const std::vector& argv, EventStream& stream, std::string_view what) { + const int rc = platform::run_argv_with_timeout(argv, std::chrono::minutes(30)); + if (rc != 0) { + std::string line; + for (auto& a : argv) line += (line.empty() ? "" : " ") + a; + error_(stream, std::format("{} failed (exit {}): {}", what, rc, line), + {}, ErrorCode::Internal); + } + return rc; +} + +// A tool from the one table (xlings.subos.tools): the home's payload first, +// then the machine's at the paths the table names. Missing is an error that +// names the package bringing it. +std::optional tool_(std::string_view name, EventStream& stream) { + auto ports = subos::make_ports(stream); + if (auto found = subos::tools::first(name, home_view(), ports)) return found->bin.string(); + const auto hint = subos::tools::install_hint(name); + error_(stream, std::format("{} is not available", name), hint, ErrorCode::NotFound); + return std::nullopt; +} + +// The tools that write an image run as root inside a user namespace, so the +// files they record are root's, as on any distribution's image. +std::optional> as_root_(std::vector argv, EventStream& stream) { + auto ports = subos::make_ports(stream); + auto bwrap = subos::caps::locate_bwrap(home_view(), ports); + if (!bwrap || !bwrap->usable) { + error_(stream, "writing a root image needs a user namespace and bwrap cannot make one here", + "xlings self doctor --isolation", ErrorCode::NotFound); + return std::nullopt; + } + std::vector a{bwrap->bin.string(), "--unshare-user", "--uid", "0", "--gid", "0", + "--dev-bind", "/", "/", "--"}; + a.insert(a.end(), argv.begin(), argv.end()); + return a; +} + +} // namespace + +// ── shared with the other subos commands ───────────────────────────── + +bool enters_sandboxed_(const std::string& name) { + const auto kind = subos_root::read_kind(home_dir_(), name); + return policy_store::has_file(home_view(), name) || !kind || *kind == roles::Kind::Rootfs; +} + +bool role_allows_(roles::Op op, const std::string& name, EventStream& stream) { + const auto home = home_dir_(); + const auto domainScope = xlings::home::domain_producer::read_scope(home, name); + if (!domainScope) { error_(stream, domainScope.error()); return false; } + if (*domainScope) { + error_(stream, std::format("cannot {} '{}': this operation requires the prefix-domain scope adapter", roles::to_string(op), name), + "export is available through the namespace producer; the outer control directory is not the package workspace"); + return false; + } + return role_allows_at_(home, op, name, stream); +} + +bool role_allows_at_(const fs::path& home, roles::Op op, const std::string& name, EventStream& stream) { + const auto kind = subos_root::read_kind(home, name); + if (!kind) { error_(stream, kind.error()); return false; } + const auto role = subos_root::read_role(home, name); + if (!role) { error_(stream, role.error()); return false; } + const auto v = roles::check(op, *kind, *role, name); + if (v.allowed) return true; + error_(stream, std::format("cannot {} '{}': {}", roles::to_string(op), name, v.reason), v.next); + return false; +} + +namespace { + +nlohmann::json read_json_(const fs::path& p) { + auto j = nlohmann::json::parse(read_text_(p), nullptr, false); + return j.is_object() ? j : nlohmann::json::object(); +} + +// "xim:busybox@1.36.1" -> "busybox": what a package spec is ABOUT, for the +// upper template of a chain to replace the lower one's. +std::string package_key_(std::string spec) { + if (auto at = spec.find('@'); at != std::string::npos) spec.resize(at); + if (auto colon = spec.rfind(':'); colon != std::string::npos) spec = spec.substr(colon + 1); + return spec; +} + +// What a root made from a subos-type xpkg is (design part 2 §9): its +// template's `subos_kind`, the `packages` it declares, its `boot` -- merged +// down its `from` chain (luban-desktop from luban-core from luban-tiny), the +// upper template winning a package or a file both carry. +struct Declared { + bool rootfs { false }; + std::vector packages; + std::string init; +}; + +std::optional declared_by_template_(const fs::path& instance, EventStream& stream) { + Declared d; + std::vector chain{read_json_(instance / ".xlings.json")}; + for (std::string from = chain.back().value("from", std::string()); !from.empty();) { + if (chain.size() > 8) { + stream.emit(ErrorEvent{ .code = ErrorCode::InvalidInput, + .message = "the `from` chain is longer than 8: a cycle?", + .recoverable = false }); + return std::nullopt; + } + const auto dir = resolve_base_package_(from, stream); + if (dir.empty()) return std::nullopt; + // The lower template's files, where the upper one has none. + std::error_code ec; + for (fs::recursive_directory_iterator it(dir, ec), end; !ec && it != end; it.increment(ec)) { + const auto rel = it->path().lexically_relative(dir); + if (rel == ".xlings.json") continue; + const auto at = instance / rel; + std::error_code sec; + if (it->is_directory(sec) && !it->is_symlink(sec)) { fs::create_directories(at, sec); continue; } + if (!fs::exists(fs::symlink_status(at, sec))) + fs::copy(it->path(), at, fs::copy_options::copy_symlinks, sec); + } + chain.push_back(read_json_(dir / ".xlings.json")); + from = chain.back().value("from", std::string()); + } + std::map by_key; + std::vector order; + for (auto it = chain.rbegin(); it != chain.rend(); ++it) { // bottom first + if (it->value("subos_kind", std::string()) == "rootfs") d.rootfs = true; + for (auto& p : it->value("packages", nlohmann::json::array())) { + if (!p.is_string()) continue; + const auto key = package_key_(p.get()); + if (!by_key.contains(key)) order.push_back(key); + by_key[key] = p.get(); + } + if (auto b = it->find("boot"); b != it->end() && b->is_object()) + if (auto init = b->value("init", std::string()); !init.empty()) d.init = init; + } + for (auto& k : order) d.packages.push_back(by_key[k]); + return d; +} + +} // namespace + +std::expected preflight_domain_at_creation_(std::string_view name, bool rootfs, + std::string_view domain, std::string_view fromSpec) { + if (domain.empty()) return {}; + if (!rootfs) return std::unexpected("--domain requires --rootfs"); + if constexpr (!platform::is_linux) return std::unexpected("prefix-domain root producers require Linux namespaces"); + auto selected = xlings::home::prefix_domain::resolve(home_dir_(), fs::path(domain)); + if (!selected) return std::unexpected(selected.error()); + if (selected->privateHome) { + if (name.empty() || name == "." || name == ".." || name == "current" || name.find_first_of("/\\") != std::string_view::npos) + return std::unexpected("invalid prefix-domain instance name"); + auto registry = xlings::home::read_json_for_update(home_dir_() / ".xlings.json"); + if (!registry) return std::unexpected(registry.error()); + if (registry->contains("subos") && (!(*registry)["subos"].is_object() || (*registry)["subos"].contains(std::string(name)))) + return std::unexpected("prefix-domain control registry is invalid or already claims this name"); + if (auto fresh = require_new_output_(HomeView{home_dir_()}.instance(name)); !fresh) return fresh; + if (auto fresh = require_new_output_(HomeView{home_dir_()}.instance_file(name)); !fresh) return fresh; + if (auto fresh = require_new_output_(HomeView{home_dir_()}.config_dir(name)); !fresh) return fresh; + if (auto fresh = require_new_output_(HomeView{selected->physicalHome}.instance(name)); !fresh) return fresh; + if (!fromSpec.empty() && fromSpec.find_first_of(":@") == std::string_view::npos) + return std::unexpected("cross-prefix producer --from requires a package coordinate; host-scope forks need an explicit domain import"); + } + return {}; +} + +std::optional produce_domain_at_creation_(const std::string& name, bool, + std::string_view domain, std::span arguments, EventStream& stream) { + auto selected = xlings::home::prefix_domain::resolve(home_dir_(), fs::path(domain)); + if (!selected) { error_(stream, selected.error()); return 1; } + if (!selected->privateHome) return std::nullopt; + const auto entry = platform::get_executable_path(); + if (auto prepared = xlings::home::domain_producer::prepare(*selected, entry); !prepared) { + error_(stream, prepared.error()); return 1; + } + const std::vector initialize{"self", "init"}; + auto init = xlings::home::domain_producer::run(*selected, initialize); + if (!init || *init != 0) { + error_(stream, init ? std::format("prefix-domain initialization failed (exit {}); private domain retained", *init) : init.error()); + return init ? *init : 1; + } + auto produced = xlings::home::domain_producer::run(*selected, arguments); + if (!produced || *produced != 0) { + error_(stream, produced ? std::format("namespace producer failed (exit {}); private domain retained", *produced) : produced.error()); + return produced ? *produced : 1; + } + if (auto published = xlings::home::domain_producer::publish_scope(*selected, name); !published) { + error_(stream, published.error()); return 1; + } + log::info("'{}' was produced inside {} (owner-private home {})", name, selected->logicalHome.string(), selected->physicalHome.string()); + return 0; +} + +std::optional run_domain_operation_(const std::string& name, + std::span arguments, EventStream& stream) { + auto scope = xlings::home::domain_producer::read_scope(home_dir_(), name); + if (!scope) { error_(stream, scope.error()); return 1; } + if (!*scope) return std::nullopt; + if (arguments.size() >= 2 && arguments[0] == "subos" && arguments[1] == "use") { + for (std::size_t i = 2; i < arguments.size(); ++i) { + if (arguments[i] == "--cmd") { ++i; continue; } + if (arguments[i] == "--shell" || arguments[i].starts_with("--shell=") || arguments[i] == "--global") { + error_(stream, "a private prefix-domain root requires an entered namespace; shell environment/global activation cannot expose its logical prefix"); + return 1; + } + } + } + const auto* callerMode = std::getenv("XLINGS_SUBOS_MODE"); + if (callerMode && std::string_view(callerMode) == "sandbox" && arguments.size() >= 2 && arguments[1] == "config" && + std::ranges::any_of(arguments.subspan(2), [](const std::string& value) { return value.starts_with("--") && value != "--json"; })) { + const auto decision = policy::decide(policy::legacy(), {.kind = "policy_change", .from_inside = true, .instance = name}); + stream.emit(ErrorEvent{ .code = ErrorCode::Permission, .message = "E_PERMISSION: " + decision.reason, + .recoverable = false, .hint = "outside the sandbox: " + decision.owner_command }); + return 13; + } + auto result = xlings::home::domain_producer::run((**scope).domain, arguments, std::nullopt, true); + if (!result) { error_(stream, result.error()); return 1; } + return *result; +} + +int declare_root_at_creation_(const std::string& name, bool rootfs, const std::string& from, + EventStream& stream, std::string_view domain) { + const auto home = home_dir_(); + if (auto ready = preflight_domain_at_creation_(name, rootfs, domain, from); !ready) { + error_(stream, ready.error()); + return 1; + } + if (!domain.empty()) { + auto selected = xlings::home::prefix_domain::resolve(home, fs::path(domain)); + if (!selected) { error_(stream, selected.error()); return 1; } + const auto file = HomeView{home}.instance_file(name); + auto doc = xlings::home::read_json_for_update(file); + if (!doc) { error_(stream, doc.error()); return 1; } + (*doc)["prefix_domain"] = nlohmann::json::parse(xlings::home::prefix_domain::serialize(*selected)); + if (auto written = write_json_(file, *doc); !written) { + error_(stream, written.error()); + return 1; + } + } + // A fork of a root is a root (as a fork of a declared instance keeps its policy). + if (exists_(from)) { + const auto kind = subos_root::read_kind(home, from); + if (!kind) { error_(stream, kind.error()); return 1; } + rootfs = rootfs || *kind == roles::Kind::Rootfs; + } + // A root made from a template (subos:luban-*): what it declares. + std::optional declared; + if (!from.empty() && !exists_(from)) { + declared = declared_by_template_(HomeView{home}.instance(name), stream); + if (!declared) return 1; + rootfs = rootfs || declared->rootfs; + } + if (!rootfs) return 0; + if constexpr (!platform::is_linux) { + error_(stream, "a rootfs SubOS is a Linux root; this host is not Linux", + "build it on Linux; on Windows import it with `wsl --import` (xlings subos export --tar)"); + return 1; + } + if (auto d = subos_root::declare_kind(home, name, roles::Kind::Rootfs); !d) { + error_(stream, d.error(), {}, ErrorCode::Internal); + return 1; + } + if (declared && !declared->init.empty()) { + const auto file = HomeView{home}.instance_file(name); + auto j = read_json_(file); + j["init"] = declared->init; + if (auto written = write_json_(file, j); !written) { + error_(stream, written.error(), {}, ErrorCode::Internal); + return 1; + } + } + if (declared && !declared->packages.empty()) { + // Its packages, installed into it: the same install as any other, + // so the generation that follows is the usual one. + auto bin = xself::xlings_binary_in_home(home); + if (bin.empty()) bin = platform::get_executable_path(); + std::vector argv{bin.string(), "install", "-y", "--subos", name}; + argv.insert(argv.end(), declared->packages.begin(), declared->packages.end()); + log::info("installing what '{}' declares: {} package(s)", name, declared->packages.size()); + if (run_tool_(argv, stream, "installing the declared packages") != 0) { + std::string list; + for (auto& p : declared->packages) list += " " + p; + log::info("'{}' is made but not complete: `xlings install -y --subos {}{}` finishes it, " + "`xlings subos remove {}` takes it away", name, name, list, name); + return 1; + } + } + auto r = subos_root::refresh(home, name, "subos new --rootfs"); + if (r && !*r) { + error_(stream, "the root of '" + name + "' could not be laid out: " + r->error(), {}, + ErrorCode::Internal); + return 1; + } + const auto usr = rf::usr_of(HomeView{home}.instance(name)); + std::error_code ec; + log::info("'{}' is a root: generation {}; `xlings subos use {}` enters it as `/`", name, + r && *r ? (*r)->generation : 0, name); + if (!fs::exists(usr / "bin" / "sh", ec)) + log::info("it has no shell yet: `xlings install busybox --subos {}`, or create it " + "--from subos:luban-tiny", name); + return 0; +} + +// ── rollback ───────────────────────────────────────────────────────── + +int run_rollback_(int argc, char* argv[], EventStream& stream, const UsageError& usageError) { + std::string name; + std::optional to; + bool list = false; + for (int i = 3; i < argc; ++i) { + std::string a = argv[i]; + if (a == "--to" && i + 1 < argc) { + int k = 0; + std::string v = argv[++i]; + auto [p, ec] = std::from_chars(v.data(), v.data() + v.size(), k); + if (ec != std::errc{} || p != v.data() + v.size()) { usageError("--to expects a generation number"); return 1; } + to = k; + } else if (a == "--list") list = true; + else if (!a.empty() && a[0] != '-' && name.empty()) name = a; + else { usageError("unknown option for `xlings subos rollback`: " + a); return 1; } + } + if (!name.empty()) { + std::vector domainArgs{"subos", "rollback"}; + domainArgs.insert(domainArgs.end(), argv + 3, argv + argc); + if (auto result = run_domain_operation_(name, domainArgs, stream)) return *result; + } + if (!exists_(name)) { usageError("usage: xlings subos rollback [--to ] [--list]"); return 1; } + if (!role_allows_(roles::Op::Rollback, name, stream)) return 1; + const auto dir = HomeView{home_dir_()}.instance(name); + const auto gens = rf::generations(dir); + const auto now = rf::current(dir); + if (list) { + nlohmann::json rows = nlohmann::json::array(); + for (int k : gens) { + auto g = rf::info(dir, k); + rows.push_back({{"generation", k}, {"current", now && *now == k}, + {"created", g ? g->created : ""}, {"reason", g ? g->reason : ""}, + {"links", g ? g->links : 0}}); + log::println("{} {:>3} {} {}", now && *now == k ? "*" : " ", k, + g ? g->created : "", g ? g->reason : ""); + } + stream.emit(DataEvent{"subos_generations", rows.dump()}); + return 0; + } + if (!to) { + auto it = now ? std::ranges::find(gens, *now) : gens.end(); + if (it == gens.end() || it == gens.begin()) { + error_(stream, "'" + name + "' has no earlier generation", "xlings subos rollback " + name + " --list"); + return 1; + } + to = *std::prev(it); + } + if (auto sw = rf::switch_to(dir, *to); !sw) { + error_(stream, sw.error(), "xlings subos rollback " + name + " --list"); + return 1; + } + if (auto cache = subos_root::refresh_cache(home_dir_(), name); !cache) { + if (now) { + if (auto restored = rf::switch_to(dir, *now, rf::Flush::Durable, rf::Verify::Tree); !restored) + error_(stream, "cannot restore prior generation: " + restored.error()); + } + error_(stream, cache.error()); + return 1; + } + observe::append(HomeView{home_dir_()}.logs_dir(name) / "events.ndjson", observe::Event{ + .kind = observe::Kind::Lifecycle, + .fields = {{"event", "rollback"}, {"instance", name}, {"from", now ? *now : 0}, {"to", *to}}}); + log::info("'{}': root generation {} (was {})", name, *to, now ? std::to_string(*now) : "none"); + return 0; +} + +// ── boot ───────────────────────────────────────────────────────────── + +int run_boot_(int argc, char* argv[], EventStream& stream, const UsageError& usageError) { + const auto home = home_dir_(); + const auto file = HomeView{home}.boot_file(); + std::string name; + bool once = false, mark_good = false, fallback = false, now = false; + for (int i = 3; i < argc; ++i) { + std::string a = argv[i]; + if (a == "--once") once = true; + else if (a == "--now") now = true; + else if (a == "--fallback") fallback = true; + else if (a == "--mark-good") mark_good = true; + else if (!a.empty() && a[0] != '-' && name.empty()) name = a; + else { usageError("unknown option for `xlings subos boot`: " + a); return 1; } + } + auto cfg = bt::load(file); + if (!cfg) { error_(stream, cfg.error()); return 1; } + if (mark_good) { + if (auto s = bt::save(file, bt::mark_good(*cfg)); !s) { error_(stream, s.error()); return 1; } + log::info("boot of '{}' marked good", cfg->booted.empty() ? "?" : cfg->booted); + return 0; + } + if (name.empty()) { + stream.emit(DataEvent{"subos_boot", bt::to_json(*cfg).dump()}); + log::println("default {}", cfg->default_entry); + log::println("fallback {}", cfg->fallback); + if (cfg->once) log::println("next boot {} (once)", *cfg->once); + if (!cfg->booted.empty()) + log::println("this boot {} via {}{}", cfg->booted, cfg->via, cfg->good ? ", marked good" : ""); + return 0; + } + if (!exists_(name)) { error_(stream, "no SubOS named '" + name + "'"); return 1; } + if (!role_allows_(roles::Op::Boot, name, stream)) return 1; + // --now (part 2 §8.1): hand / to it without restarting the kernel. Only + // an init that can re-exec stage-0 can do that -- busybox init with a + // `::restart:` entry naming /usr/bin/xlings-init (luban-tiny's), on the + // machine this home is the root of. Anything else would leave the + // running processes on one SubOS and new ones on another: refused, and + // a reboot does it. + if (now) { + const auto host = subos_root::running_host(home); + std::error_code ec; + const auto init = fs::read_symlink("/proc/1/exe", ec).filename().string(); + const auto inittab = read_text_("/etc/inittab"); + const bool restartable = host && init == "busybox" + && inittab.find("::restart:/usr/bin/xlings-init") != std::string::npos; + if (!restartable) { + error_(stream, !host ? "this home is not the root of the running machine" + : "this machine's init cannot hand / to another SubOS without a reboot", + std::format("xlings subos boot {} --once, then reboot", name)); + return 1; + } + cfg->once = name; + if (auto s = bt::save(file, *cfg); !s) { error_(stream, s.error()); return 1; } + log::info("switching user space to '{}' now", name); + std::cout.flush(); + if (!platform::send_signal(1, platform::sig::quit)) { + error_(stream, "init did not take the signal: " + platform::error_text(platform::last_error())); + return 1; + } + return 0; + } + if (once) cfg->once = name; + else if (fallback) cfg->fallback = name; + else { cfg->default_entry = name; cfg->tries[name] = bt::kTries; } + if (auto s = bt::save(file, *cfg); !s) { error_(stream, s.error()); return 1; } + observe::append(HomeView{home}.logs_dir(name) / "events.ndjson", observe::Event{ + .kind = observe::Kind::Lifecycle, + .fields = {{"event", "boot-entry"}, {"instance", name}, + {"as", once ? "once" : fallback ? "fallback" : "default"}}}); + log::info("'{}' {}; it takes effect when the machine boots", name, + once ? "boots next, once" : fallback ? "is the fallback" : "is the default boot"); + return 0; +} + +// ── diff ───────────────────────────────────────────────────────────── + +int run_diff_(int argc, char* argv[], EventStream& stream, const UsageError& usageError) { + if (argc < 5) { usageError("usage: xlings subos diff "); return 1; } + const std::string a = argv[3], b = argv[4]; + auto wa = workspace_of_(a), wb = workspace_of_(b); + if (!wa || !wb) { error_(stream, "no SubOS named '" + (!wa ? a : b) + "'"); return 1; } + nlohmann::json rows = nlohmann::json::array(); + std::set names; + for (auto& [t, _] : wa->active) names.insert(t); + for (auto& [t, _] : wb->active) names.insert(t); + for (auto& t : names) { + auto ia = wa->active.find(t), ib = wb->active.find(t); + const std::string va = ia == wa->active.end() ? "" : ia->second; + const std::string vb = ib == wb->active.end() ? "" : ib->second; + if (va == vb) continue; + const char* mark = va.empty() ? "+" : vb.empty() ? "-" : "~"; + rows.push_back({{"target", t}, {a, va}, {b, vb}}); + log::println("{} {:<24} {:<14} {}", mark, t, va.empty() ? "-" : va, vb.empty() ? "-" : vb); + } + stream.emit(DataEvent{"subos_diff", rows.dump()}); + if (rows.empty()) log::info("'{}' and '{}' have the same packages", a, b); + return 0; +} + +// ── export ─────────────────────────────────────────────────────────── + +int run_export_(int argc, char* argv[], EventStream& stream, const UsageError& usageError) { + std::string name; + fs::path rootfs_dir, tarball, disk; + std::string size = "4G"; + bool with_data = false; + for (int i = 3; i < argc; ++i) { + std::string a = argv[i]; + if (a == "--rootfs" && i + 1 < argc) rootfs_dir = argv[++i]; + else if (a == "--tar" && i + 1 < argc) tarball = argv[++i]; + else if (a == "--disk" && i + 1 < argc) disk = argv[++i]; + else if (a == "--size" && i + 1 < argc) size = argv[++i]; + else if (a == "--with-data") with_data = true; + else if (!a.empty() && a[0] != '-' && name.empty()) name = a; + else { usageError("unknown option for `xlings subos export`: " + a); return 1; } + } + const int outputs = !rootfs_dir.empty() + !tarball.empty() + !disk.empty(); + if (name.empty() || outputs != 1) { + usageError("usage: xlings subos export --rootfs | --tar | --disk " + "[--size 4G] [--with-data]"); + return 1; + } + auto domainScope = xlings::home::domain_producer::read_scope(home_dir_(), name); + if (!domainScope) { error_(stream, domainScope.error()); return 1; } + if (*domainScope) { + const auto out = fs::absolute(!rootfs_dir.empty() ? rootfs_dir : (!tarball.empty() ? tarball : disk)); + if (auto fresh = require_new_output_(out); !fresh) { error_(stream, fresh.error()); return 1; } + auto scratch = OwnedStage::create(out.parent_path()); + if (!scratch) { error_(stream, scratch.error()); return 1; } + const fs::path guest = "/run/xlings-domain-output"; + std::vector arguments{"subos", "export", name, + !rootfs_dir.empty() ? "--rootfs" : (!tarball.empty() ? "--tar" : "--disk"), (guest / "output").string()}; + if (!disk.empty()) arguments.insert(arguments.end(), {"--size", size}); + if (with_data) arguments.push_back("--with-data"); + auto exported = xlings::home::domain_producer::run((**domainScope).domain, arguments, + xlings::home::domain_producer::OutputBinding{scratch->path(), guest}); + if (!exported || *exported != 0) { + error_(stream, exported ? std::format("namespace export failed (exit {})", *exported) : exported.error()); + return exported ? *exported : 1; + } + if (auto published = platform::rename_no_replace(scratch->path() / "output", out); !published) { + error_(stream, published.error()); return 1; + } + return 0; + } + if (!exists_(name)) { error_(stream, "no SubOS named '" + name + "'"); return 1; } + if (!role_allows_(roles::Op::Export, name, stream)) return 1; + if constexpr (!platform::is_linux) { + error_(stream, "exporting a root needs Linux", "export it on a Linux machine"); + return 1; + } + + const auto out = fs::absolute(!rootfs_dir.empty() ? rootfs_dir : (!tarball.empty() ? tarball : disk)); + if (auto fresh = require_new_output_(out); !fresh) { + error_(stream, fresh.error(), "choose a new output path"); + return 1; + } + + const auto home = home_dir_(); + const auto instance = HomeView{home}.instance(name); + // The image's xlings runs with no host under it: the static release build. + fs::path entry = home / "bin" / "xlings"; + std::error_code ec; + auto check_io = [&]() { + if (!ec) return true; + error_(stream, "cannot construct exported root: " + ec.message(), {}, ErrorCode::Internal); + return false; + }; + auto write_json = [&](const fs::path& path, const nlohmann::json& value) { + const auto written = write_json_(path, value); + if (written) return true; + error_(stream, written.error(), {}, ErrorCode::Internal); + return false; + }; + if (!fs::exists(entry, ec)) entry = platform::get_executable_path(); + entry = fs::weakly_canonical(entry, ec); + if (auto info = elfread::read(entry); !info || !info->interpreter.empty()) { + error_(stream, entry.string() + " is not a static build; an image's xlings must run with no " + "host libraries", + "use the release build of xlings (xlings install xlings), or `mcpp build --target " + "x86_64-linux-musl`"); + return 1; + } + auto ws = workspace_of_(name); + if (!ws) { error_(stream, "the workspace of '" + name + "' is unreadable"); return 1; } + if (auto r = subos_root::refresh(home, name, "export"); r && !*r) { + error_(stream, r->error(), {}, ErrorCode::Internal); + return 1; + } + + auto closureInputs = subos_root::store_closure::read_scope(home, name, subos_root::tree_of(home, name)); + if (!closureInputs) { error_(stream, closureInputs.error()); return 1; } + auto checkedClosure = subos_root::store_closure::collect(*closureInputs); + if (!checkedClosure) { error_(stream, checkedClosure.error()); return 1; } + auto sourceMapping = xlings::home::domain_producer_source::read(); + if (!sourceMapping) { error_(stream, sourceMapping.error()); return 1; } + const auto originalVersions = Config::versions(); + auto exportedVersions = originalVersions; + const auto selectedKey = [&](const std::string& target, const std::string& key) { + const auto installed = ws->installed.find(target); + const auto active = ws->active.find(target); + return (installed != ws->installed.end() && std::ranges::find(installed->second, key) != installed->second.end()) || + (active != ws->active.end() && active->second == key); + }; + const auto imagePath = [&](const fs::path& path) -> std::expected { + const auto normalized = path.lexically_normal(); + if (!normalized.is_absolute()) return normalized; + for (const auto& mount : checkedClosure->mounts) { + const auto slot = mount.source.lexically_relative(mount.home); + std::vector sources{mount.source, mount.destination}; + if (*sourceMapping && mount.home == (**sourceMapping).recordedHome) + sources.push_back((**sourceMapping).physicalHome / slot); + for (const auto& source : sources) { + const auto relative = normalized.lexically_relative(source); + if (!relative.empty() && !relative.is_absolute() && *relative.begin() != "..") { + auto mapped = (home / slot / relative).lexically_normal(); + if (mapped.filename().empty() && mapped != mapped.root_path()) mapped = mapped.parent_path(); + return mapped; + } + } + } + return std::unexpected(normalized.string() + ": export path is outside the checked payload closure"); + }; + for (auto target = exportedVersions.begin(); target != exportedVersions.end();) { + for (auto version = target->second.versions.begin(); version != target->second.versions.end();) { + if (!selectedKey(target->first, version->first)) { + version = target->second.versions.erase(version); continue; + } + auto& data = version->second; + const auto mapField = [&](std::string& text) -> bool { + if (text.empty()) return true; + auto mapped = imagePath(fs::path(xvm::expand_path(text, home.string()))); + if (!mapped) { error_(stream, mapped.error()); return false; } + text = mapped->generic_string(); return true; + }; + if (!mapField(data.path) || !mapField(data.includedir) || !mapField(data.libdir) || !mapField(data.fileSrc)) return 1; + for (auto& header : data.bindingHeaders) if (!mapField(header.sourceDir)) return 1; + data.sourceHome.clear(); data.sourceScope.clear(); data.layerMetadata.clear(); + ++version; + } + if (target->second.versions.empty()) target = exportedVersions.erase(target); + else ++target; + } + + auto scratch = OwnedStage::create(out.parent_path()); + if (!scratch) { error_(stream, scratch.error(), {}, ErrorCode::Internal); return 1; } + const auto stage = scratch->path() / "rootfs"; + const auto image_home = stage / home.relative_path(); + const auto image_default = image_home / "subos" / "default"; + log::info("exporting '{}' ...", name); + + // The image's own system home: this instance is its `default`. + std::set copiedSlots; + for (const auto& mount : checkedClosure->mounts) { + const auto relative = mount.source.lexically_relative(mount.home); + if (relative.empty() || relative.is_absolute() || *relative.begin() == ".." || + !copiedSlots.insert(relative).second) { + error_(stream, "export closure has conflicting or unproved payload destinations"); return 1; + } + const auto copied = image_home / relative; + if (auto c = copy_tree_(mount.source, copied); !c) { + error_(stream, c.error(), {}, ErrorCode::Internal); return 1; + } + // ELF interpreters and recipe aliases retain their logical source + // prefix. Only the checked slot is exposed there; bytes live once in + // the image's owned store and no external source home is needed. + const auto ownedSlot = home / relative; + if (mount.destination != ownedSlot) { + const auto logicalSlot = stage / mount.destination.relative_path(); + fs::create_directories(logicalSlot.parent_path(), ec); + if (!check_io()) return 1; + fs::create_directory_symlink(ownedSlot, logicalSlot, ec); + if (!check_io()) return 1; + } + { + auto evidence = xlings::home::read_json_for_update(copied / ".xlings-resolution.json"); + if (!evidence) { error_(stream, evidence.error()); return 1; } + if (!evidence->contains("deps") || !(*evidence)["deps"].is_array()) { + error_(stream, "exported payload has no checked runtime evidence"); return 1; + } + for (auto& dependency : (*evidence)["deps"]) { + const auto mapField = [&](nlohmann::json& field) -> bool { + if (!field.is_string()) { error_(stream, "invalid exported runtime dependency path"); return false; } + const fs::path path(field.get()); + if (!path.is_absolute()) return true; + auto mapped = imagePath(path); + if (!mapped) { error_(stream, mapped.error()); return false; } + field = mapped->generic_string(); return true; + }; + if (!dependency.contains("install_dir") || !mapField(dependency["install_dir"])) return 1; + if (dependency.contains("libdirs")) { + if (!dependency["libdirs"].is_array()) { error_(stream, "invalid exported runtime libdirs"); return 1; } + for (auto& libdir : dependency["libdirs"]) if (!mapField(libdir)) return 1; + } + } + if (!write_json(copied / ".xlings-resolution.json", *evidence)) return 1; + } + } + const bool has_data = fs::exists(home / "data", ec); + if (!check_io()) return 1; + for (fs::directory_iterator it(has_data ? home / "data" : instance, ec), end; + has_data && !ec && it != end; it.increment(ec)) { + const auto& e = *it; + const auto n = e.path().filename().string(); + if (n == "xpkgs" || n == "runtimedir" || n == "stale" || n.starts_with(".")) continue; + if (auto c = copy_tree_(e.path(), image_home / "data" / n); !c) { + error_(stream, c.error(), {}, ErrorCode::Internal); + return 1; + } + } + if (!check_io()) return 1; + fs::create_directories(image_default, ec); + if (!check_io()) return 1; + for (fs::directory_iterator it(instance, ec), end; !ec && it != end; it.increment(ec)) { + const auto& e = *it; + const auto n = e.path().filename().string(); + if (n == rf::kTree || n == rf::kPointer || n == rf::kGenerations || n == "home" || n == "tmp" + || n == "home.img" || n == ".mountpoint") + continue; + if (auto c = copy_tree_(e.path(), image_default / n); !c) { + error_(stream, c.error(), {}, ErrorCode::Internal); + return 1; + } + } + if (!check_io()) return 1; + // The projection, re-planned for its new name: same payloads, its + // sysroot now at subos/default. + { + auto plan = rf::plan(subos_root::inputs(home, instance, ws->active, exportedVersions)); + const auto from = instance.generic_string(), to = (home / "subos" / "default").generic_string(); + for (auto& l : plan.links) { + auto t = l.target.generic_string(); + if (t.starts_with(from + "/") || t == from) l.target = to + t.substr(from.size()); + } + if (auto c = rf::commit(image_default, plan, "export of " + name); !c) { + error_(stream, c.error(), {}, ErrorCode::Internal); + return 1; + } + } + fs::create_directories(image_home / "subos", ec); + if (!check_io()) return 1; + fs::create_directory_symlink("default", image_home / "subos" / "current", ec); + if (!check_io()) return 1; + fs::create_directories(image_home / "bin", ec); + if (!check_io()) return 1; + fs::copy_file(entry, image_home / "bin" / "xlings", fs::copy_options::overwrite_existing, ec); + if (!check_io()) return 1; + fs::permissions(image_home / "bin" / "xlings", fs::perms::owner_all | fs::perms::group_read + | fs::perms::group_exec | fs::perms::others_read | fs::perms::others_exec, + fs::perm_options::replace, ec); + if (!check_io()) return 1; + // Stage-0's own binary travels with the client it belongs to (part 3 §8). + if (const auto init = entry.parent_path() / "luban-init"; fs::is_regular_file(init, ec)) { + fs::copy_file(init, image_home / "bin" / "luban-init", fs::copy_options::overwrite_existing, ec); + if (!check_io()) return 1; + fs::permissions(image_home / "bin" / "luban-init", fs::perms::owner_all | fs::perms::group_read + | fs::perms::group_exec | fs::perms::others_read | fs::perms::others_exec, + fs::perm_options::replace, ec); + if (!check_io()) return 1; + } + ec.clear(); + fs::create_directories(image_home / "boot", ec); + if (!check_io()) return 1; + fs::create_symlink("../bin/xlings", image_home / "boot" / "xlings-init", ec); + if (!ec && fs::exists(image_home / "bin" / "luban-init")) + fs::create_symlink("../bin/luban-init", image_home / "boot" / "luban-init", ec); + if (!check_io()) return 1; + { + // The home config: this instance's packages, nothing of the builder's. + auto j = nlohmann::json::parse(read_text_(home / ".xlings.json"), nullptr, false); + if (!j.is_object()) j = nlohmann::json::object(); + nlohmann::json versions = nlohmann::json::object(); + if (j.contains("versions") && j["versions"].is_object()) { + for (auto target = j["versions"].begin(); target != j["versions"].end(); ++target) { + const auto normalizedTarget = exportedVersions.find(target.key()); + if (normalizedTarget == exportedVersions.end()) continue; + auto encodedTarget = target.value(); + auto& entries = encodedTarget["versions"]; + for (auto version = entries.begin(); version != entries.end();) { + const auto normalized = normalizedTarget->second.versions.find(version.key()); + if (normalized == normalizedTarget->second.versions.end()) { + version = entries.erase(version); continue; + } + const auto encoded = xvm::vdata_to_json(normalized->second); + for (const auto* field : {"path", "includedir", "libdir", "fileSrc", "bindingHeaders"}) + if (encoded.contains(field)) version.value()[field] = encoded[field]; + version.value().erase("layer"); + ++version; + } + versions[target.key()] = std::move(encodedTarget); + } + } + j["versions"] = versions; + j.erase("dbIndex"); + fs::remove(image_home / "data/versions.json", ec); + if (!check_io()) return 1; + j["activeSubos"] = "default"; + for (auto k : {"knownProjects", "subos"}) j.erase(k); + if (!write_json(image_home / ".xlings.json", j)) return 1; + const bool multi = home == fs::path("/xlings"); + nlohmann::json marker{{"layout", multi ? "multi" : "single"}, {"mode", "root"}, {"root_layout", multi ? "multi" : "single"}}; + if (!write_json(image_home / ".xlings-home", marker)) return 1; + fs::create_directories(image_home / "config" / "subos" / "default", ec); + if (!check_io()) return 1; + if (!write_json(image_home / "config" / "subos" / "default" / "instance.json", + nlohmann::json{{"kind", "rootfs"}})) return 1; + bt::Config boot; + if (auto saved = bt::save(image_home / "boot.json", boot); !saved) { + error_(stream, saved.error(), {}, ErrorCode::Internal); + return 1; + } + } + + // The root around it. + if (auto l = rf::lay_out(stage, rf::usr_of(home / "subos" / "default"), home); !l) { + error_(stream, l.error(), {}, ErrorCode::Internal); + return 1; + } + { + const auto tree = subos_root::tree_of(home, name); + // Machine state: /etc always (factory links become the files they + // name -- they pointed into this home's copy of the instance), the + // rest only when asked for. + fs::copy(tree / "etc", stage / "etc", fs::copy_options::recursive + | fs::copy_options::overwrite_existing, ec); + if (!check_io()) return 1; + if (with_data) { + for (auto d : {"root", "home", "var", "srv", "opt"}) { + if (auto copied = copy_tree_(tree / d, stage / d); !copied) { + error_(stream, copied.error(), {}, ErrorCode::Internal); + return 1; + } + } + } + fs::create_directories(stage / "etc" / "xlings", ec); + if (!check_io()) return 1; + if (!write_json(stage / "etc" / "xlings" / "root.json", + nlohmann::json{{"home", home.generic_string()}})) return 1; + const bool has_resolver = fs::exists(stage / "etc" / "resolv.conf", ec); + if (!check_io()) return 1; + if (!has_resolver) { + std::ofstream resolver(stage / "etc" / "resolv.conf"); + resolver.close(); + if (!resolver) { error_(stream, "cannot create resolver configuration"); return 1; } + } + } + + int rc = 0; + if (!tarball.empty()) { + // In-process, root-owned entries: no host tar, no user namespace. + if (auto written = xim::write_tar_gz(stage, scratch->path() / "output", xim::ArchiveOwner::Root); + !written) { + error_(stream, "writing the tarball: " + written.error(), {}, ErrorCode::Internal); + return 1; + } + } else if (!disk.empty()) { + auto mkfs = tool_("mkfs.ext4", stream); + if (!mkfs) return 1; + auto argv = as_root_({*mkfs, "-q", "-F", "-L", "luban", "-d", stage.string(), + (scratch->path() / "output").string(), size}, stream); + if (!argv) return 1; + rc = run_tool_(*argv, stream, "writing the disk image"); + } + if (rc != 0) return 1; + if (!rootfs_dir.empty()) { + if (auto published = platform::rename_no_replace(stage, out); !published) { + error_(stream, published.error(), {}, ErrorCode::Internal); + return 1; + } + } else { + // Publish a completed file without ever replacing an existing name. + if (auto published = publish_file_(scratch->path() / "output", out); !published) { + error_(stream, published.error()); + return 1; + } + } + observe::append(HomeView{home}.logs_dir(name) / "events.ndjson", observe::Event{ + .kind = observe::Kind::Lifecycle, + .fields = {{"event", "export"}, {"instance", name}, {"to", fs::absolute(out).string()}, + {"with_data", with_data}}}); + log::info("'{}' exported to {} (its system home: {})", name, fs::absolute(out).string(), + home.string()); + if (!tarball.empty()) + log::info(" docker import {} luban:{} | wsl --import {}", out.string(), name, + out.string()); + if (!disk.empty()) + log::info(" boots with init={}/boot/{} root=/dev/vda", home.string(), + fs::exists(home / "bin" / "luban-init") ? "luban-init" : "xlings-init"); + return 0; +} + +// ── pack ───────────────────────────────────────────────────────────── + +int run_pack_(int argc, char* argv[], EventStream& stream, const UsageError& usageError) { + std::string name, as; + fs::path out_dir = "."; + for (int i = 3; i < argc; ++i) { + std::string a = argv[i]; + if (a == "--as" && i + 1 < argc) as = argv[++i]; + else if (a == "--out" && i + 1 < argc) out_dir = argv[++i]; + else if (!a.empty() && a[0] != '-' && name.empty()) name = a; + else { usageError("unknown option for `xlings subos pack`: " + a); return 1; } + } + const auto at = as.find('@'); + if (name.empty() || at == std::string::npos) { + usageError("usage: xlings subos pack --as [--out ]"); + return 1; + } + if (!exists_(name)) { error_(stream, "no SubOS named '" + name + "'"); return 1; } + auto ws = workspace_of_(name); + if (!ws) { error_(stream, "the workspace of '" + name + "' is unreadable"); return 1; } + const auto colon = as.find(':'); + const auto pkg = as.substr(colon == std::string::npos ? 0 : colon + 1, + at - (colon == std::string::npos ? 0 : colon + 1)); + const auto ver = as.substr(at + 1); + if (!safe_component_(pkg) || !safe_component_(ver) || as.find('@', at + 1) != std::string::npos + || (colon != std::string::npos && (colon >= at || !safe_component_(as.substr(0, colon))))) { + usageError("--as expects a package name and version without path separators"); + return 1; + } + const auto home = home_dir_(); + const auto kind = subos_root::read_kind(home, name); + if (!kind) { error_(stream, kind.error()); return 1; } + const auto file = fs::absolute(out_dir) / std::format("{}-{}.tar.gz", pkg, ver); + if (auto fresh = require_new_output_(file); !fresh) { + error_(stream, fresh.error(), "choose a new package version or output directory"); + return 1; + } + auto scratch = OwnedStage::create(fs::absolute(out_dir)); + if (!scratch) { error_(stream, scratch.error(), {}, ErrorCode::Internal); return 1; } + const auto stage = scratch->path() / std::format("{}-{}", pkg, ver); + std::error_code ec; + fs::create_directory(stage, ec); + if (ec) { error_(stream, "cannot create package staging: " + ec.message()); return 1; } + // What a subos-type xpkg carries: the workspace it declares and, for a + // root, its kind. Payloads are not packed: they come from the index. + nlohmann::json j; + j["workspace"] = nlohmann::json::object(); + for (auto& [t, v] : ws->active) j["workspace"][t] = v; + if (*kind == roles::Kind::Rootfs) j["subos_kind"] = "rootfs"; + { + std::ofstream config(stage / ".xlings.json"); + config << j.dump(2) << "\n"; + config.close(); + if (!config) { error_(stream, "cannot write package configuration"); return 1; } + } + const auto archive = scratch->path() / "output.tar.gz"; + if (auto written = xim::write_tar_gz(stage, archive, xim::ArchiveOwner::AsOnDisk, + stage.filename().string()); + !written) { + error_(stream, "packing: " + written.error(), {}, ErrorCode::Internal); + return 1; + } + if (auto published = publish_file_(archive, file); !published) { + error_(stream, published.error()); + return 1; + } + log::info("packed '{}' as {}: {}", name, as, file.string()); + log::info(" a recipe: package = {{ name = \"{}\", type = \"subos\", xpm = {{ linux = {{ [\"{}\"] = " + "{{ url = \"\", sha256 = \"...\" }} }} }} }}", pkg, ver); + return 0; +} + +} // namespace xlings::subos diff --git a/src/core/subos/root/root_view.cpp b/src/core/subos/root/root_view.cpp new file mode 100644 index 000000000..19c15b3fc --- /dev/null +++ b/src/core/subos/root/root_view.cpp @@ -0,0 +1,418 @@ +module xlings.core.subos.root_view; + +import std; +import xlings.platform; +import xlings.libs.json; +import xlings.core.home; +import xlings.core.home.layers; +import xlings.core.home.domain_producer_source; +import xlings.core.xvm.db; +import xlings.subos.rootfs; + +namespace xlings::subos_root::root_view { +namespace { +using json = nlohmann::json; + +bool within(const fs::path& root, const fs::path& path) { + const auto relative = path.lexically_relative(root); + return path == root || + (!relative.empty() && !relative.is_absolute() && *relative.begin() != ".."); +} + +void filter_versions(json& versions, const xvm::WorkspaceInstalled& installed) { + for (auto target = versions.begin(); target != versions.end();) { + const auto selected = installed.find(target.key()); + if (selected == installed.end()) { + target = versions.erase(target); + continue; + } + auto& entries = target.value().at("versions"); + for (auto version = entries.begin(); version != entries.end();) { + if (std::ranges::find(selected->second, version.key()) == selected->second.end()) + version = entries.erase(version); + else + ++version; + } + ++target; + } +} + +class PrivateView final : public View { + fs::path stage_; + fs::path instance_; + std::vector bindings_; + std::map skeletons_; + std::map sourceIdentities_; + std::unique_ptr identity_; + std::vector> retained_; + + bool skeleton_binding(const Binding& binding) const { + const auto found = skeletons_.find(binding.destination); + return found != skeletons_.end() && found->second == binding.source; + } + + fs::path guest_home(const fs::path& destination) const { + fs::path best; + for (const auto& [home, skeleton] : skeletons_) + if (within(home, destination) && home.native().size() > best.native().size()) + best = home; + if (best.empty()) + throw std::runtime_error("root refresh cannot add an unprepared home: " + + destination.string()); + return best; + } + + void switch_pointer(const fs::path& value) { + std::random_device random; + const auto temporary = instance_ / std::format(".root-{:x}-{:x}", random(), random()); + fs::create_directory_symlink(value, temporary); + std::error_code ec; + fs::rename(temporary, instance_ / "root", ec); + if (ec) { + fs::remove(temporary); + throw std::runtime_error("cannot switch private root projection: " + ec.message()); + } + } + + fs::path mountpoint(const fs::path& home, const fs::path& destination, bool directory) { + if (!destination.is_absolute() || destination.lexically_normal() != destination || + !within(home, destination)) + throw std::runtime_error("invalid root view mount destination: " + + destination.string()); + const auto target = skeletons_.at(home) / destination.lexically_relative(home); + fs::create_directories(directory ? target : target.parent_path()); + if (!directory) { + std::ofstream output(target, std::ios::binary); + if (!output) + throw std::runtime_error("cannot reserve root metadata mountpoint"); + } + return target; + } + + void bind(const fs::path& home, const fs::path& source, const fs::path& destination) { + std::error_code ec; + const auto status = fs::symlink_status(source, ec); + if (ec || (!fs::is_directory(status) && !fs::is_regular_file(status))) + throw std::runtime_error("root view source is not a real file or directory: " + + source.string()); + const auto identity = platform::file_identity(source); + if (!identity) + throw std::runtime_error("cannot inspect root source file identity: " + + source.string()); + mountpoint(home, destination, fs::is_directory(status)); + bindings_.push_back({source, destination}); + sourceIdentities_[destination] = *identity; + } + + void metadata(const fs::path& home, const fs::path& destination, const json& document) { + const auto source = stage_ / "metadata" / std::to_string(bindings_.size()); + fs::create_directories(source.parent_path()); + platform::write_file_atomic(source.string(), document.dump(2) + "\n"); + bind(home, source, destination); + } + + public: + const std::vector& bindings() const override { + return bindings_; + } + const fs::path& private_instance() const override { + return instance_; + } + void close() noexcept override { + if (stage_.empty()) + return; + std::error_code ignored; + fs::remove_all(stage_, ignored); // subos-remove-all-ok: exclusively reserved owner-private root-view staging + stage_.clear(); + for (auto& view : retained_) + view->close(); + retained_.clear(); + } + + std::expected refresh(std::span target) override { + auto inputs = store_closure::read_scope(identity_->home, identity_->scope, identity_->root); + if (!inputs) + return std::unexpected(inputs.error()); + if (inputs->home != identity_->home || inputs->instance != identity_->instance) + return std::unexpected("root refresh owner identity changed"); + inputs->logicalHome = identity_->logicalHome; + inputs->controlInstance = identity_->controlInstance; + auto closure = store_closure::collect(*inputs); + if (!closure) + return std::unexpected(closure.error()); + auto candidate = std::make_shared(); + std::vector additions; + std::vector removals; + fs::path previous; + bool applied = false; + bool pointerChanged = false; + try { + candidate->build(*inputs, *closure); + previous = fs::read_symlink(instance_ / "root"); + for (const auto& next : candidate->bindings_) { + if (candidate->skeleton_binding(next)) + continue; + const auto old = + std::ranges::find(bindings_, next.destination, &Binding::destination); + const auto priorIdentity = sourceIdentities_.find(next.destination); + if (old != bindings_.end() && old->source == next.source && + priorIdentity != sourceIdentities_.end() && + priorIdentity->second == candidate->sourceIdentities_.at(next.destination)) + continue; + const auto home = guest_home(next.destination); + std::error_code ec; + const auto status = fs::symlink_status(next.source, ec); + if (ec) + throw std::runtime_error("cannot inspect refreshed root source: " + + ec.message()); + const auto hostTarget = + skeletons_.at(home) / next.destination.lexically_relative(home); + if (!fs::exists(fs::symlink_status(hostTarget, ec))) + mountpoint(home, next.destination, fs::is_directory(status)); + auto replacement = next; + replacement.replace = old != bindings_.end(); + additions.push_back(std::move(replacement)); + } + for (const auto& old : bindings_) { + if (skeleton_binding(old)) + continue; + if (std::ranges::find(candidate->bindings_, old.destination, + &Binding::destination) == candidate->bindings_.end()) + removals.push_back(old.destination); + } + const auto deepestFirst = [](const fs::path& a, const fs::path& b) { + return a.native().size() > b.native().size(); + }; + std::ranges::sort(removals, deepestFirst); + std::vector current; + current.reserve(bindings_.size() + candidate->bindings_.size()); + for (const auto& old : bindings_) + if (skeleton_binding(old)) + current.push_back(old); + for (const auto& nextBinding : candidate->bindings_) + if (!candidate->skeleton_binding(nextBinding)) + current.push_back(nextBinding); + retained_.reserve(retained_.size() + 1); + const auto next = fs::path("root.gen") / std::to_string(closure->generation); + if (auto changed = platform::root_mount::update(target, additions, removals); !changed) + throw std::runtime_error(changed.error()); + applied = true; + if (next != previous) { + switch_pointer(next); + pointerChanged = true; + } + bindings_.swap(current); + sourceIdentities_.swap(candidate->sourceIdentities_); + for (auto& old : retained_) + old->close(); + retained_.clear(); + retained_.push_back(candidate); + return {}; + } catch (const std::exception& error) { + std::string reason = error.what(); + if (pointerChanged) { + try { + switch_pointer(previous); + } catch (const std::exception& failure) { + reason += "; pointer rollback failed: " + std::string(failure.what()); + } + } + if (applied) + reason += "; namespace mount transaction committed; end the session"; + candidate->close(); + return std::unexpected("root refresh failed: " + reason); + } + } + + void build(const store_closure::Inputs& inputs, const store_closure::Closure& closure) { + identity_ = std::make_unique(inputs); + const auto physicalHome = fs::canonical(inputs.home); + identity_->home = physicalHome; + const auto guestHome = inputs.logicalHome.empty() ? physicalHome : inputs.logicalHome; + if (!guestHome.is_absolute() || guestHome.lexically_normal() != guestHome) + throw std::runtime_error("root view logical home must be a normalized absolute path"); + const auto parent = physicalHome / "run/subos" / inputs.scope; + for (const auto& component : {physicalHome / "run", physicalHome / "run/subos", parent}) { + std::error_code ec; + const auto status = fs::symlink_status(component, ec); + if (status.type() == fs::file_type::not_found) { + fs::create_directory(component); + } else if (ec || !fs::is_directory(status)) { + throw std::runtime_error("root view staging parent is not a real directory: " + + component.string()); + } + } + std::random_device random; + for (unsigned attempt = 0; attempt < 32; ++attempt) { + const auto candidate = parent / std::format("root-view-{:x}-{:x}", random(), random()); + if (fs::create_directory(candidate)) { + stage_ = candidate; + break; + } + } + if (stage_.empty()) + throw std::runtime_error("cannot reserve a private root view"); + fs::permissions(stage_, fs::perms::owner_all, fs::perm_options::replace); + + std::map homes{{guestHome, physicalHome}}; + for (const auto& payload : closure.mounts) { + homes.try_emplace(payload.guestHome, payload.home); + } + auto mapping = home::domain_producer_source::read(); + if (!mapping) + throw std::runtime_error(mapping.error()); + auto system = home::read_system_layer(); + if (!system) + throw std::runtime_error(system.error()); + if (*system && **system != physicalHome) + homes.try_emplace(**system, **system); + for (const auto& source : closure.sources) + homes.try_emplace(source.executionHome, source.executionHome); + if (*mapping) { + homes.try_emplace((**mapping).recordedHome, (**mapping).recordedHome); + const fs::path context(home::domain_producer_source::CONTEXT_FILE); + homes.try_emplace(context.parent_path(), context.parent_path()); + } + for (const auto& [guest, physical] : homes) { + auto skeleton = stage_ / "homes" / std::to_string(skeletons_.size()); + fs::create_directories(skeleton / "data/xpkgs"); + for (const auto name : {"subos", "config", "logs", "run", "state", "bin"}) + fs::create_directories(skeleton / name); + skeletons_.emplace(guest, skeleton); + bindings_.push_back({skeleton, guest}); + } + std::map slots; + for (const auto& payload : closure.mounts) { + const auto [slot, inserted] = slots.emplace(payload.destination, payload.source); + if (!inserted && slot->second != payload.source) + throw std::runtime_error("two physical payloads claim the same root slot: " + + payload.destination.string()); + if (inserted) + bind(payload.guestHome, payload.source, payload.destination); + if (payload.home != payload.guestHome) { + bind(payload.home, payload.source, payload.source); + const auto record = payload.source / ".xlings-resolution.json"; + bind(payload.home, record, record); + bind(payload.guestHome, record, payload.destination / ".xlings-resolution.json"); + } + } + + std::map sourceInstalled; + for (const auto& source : closure.sources) + for (const auto& [target, keys] : source.installed) { + auto& selected = sourceInstalled[source.executionHome][target]; + for (const auto& key : keys) + if (std::ranges::find(selected, key) == selected.end()) + selected.push_back(key); + } + if (*mapping) { + const auto alias = (**mapping).recordedHome; + sourceInstalled.try_emplace(alias); + const fs::path context(home::domain_producer_source::CONTEXT_FILE); + bind(context.parent_path(), context, context); + bind(alias, alias / ".xlings-home", alias / ".xlings-home"); + } + for (const auto& [sourceHome, selected] : sourceInstalled) { + auto primary = home::read_json_for_update(sourceHome / ".xlings.json"); + if (!primary) + throw std::runtime_error(primary.error()); + auto versions = primary->value("versions", json::object()); + filter_versions(versions, selected); + (*primary)["versions"] = std::move(versions); + primary->erase("dbIndex"); + primary->erase("knownProjects"); + metadata(sourceHome, sourceHome / ".xlings.json", *primary); + const auto marker = sourceHome / ".xlings-home"; + if (!*mapping || sourceHome != (**mapping).recordedHome) + bind(sourceHome, marker, marker); + } + for (const auto& source : closure.sources) { + const auto path = source.executionHome / "subos" / source.scope / ".xlings.json"; + auto document = home::read_json_for_update(path); + if (!document) + throw std::runtime_error(document.error()); + auto& workspace = (*document)["workspace"]; + for (auto target = workspace.begin(); target != workspace.end();) { + const auto selected = source.installed.find(target.key()); + if (selected == source.installed.end()) { + target = workspace.erase(target); + continue; + } + target.value()["installed"] = selected->second; + if (target.value().contains("active") && + std::ranges::find(selected->second, + target.value()["active"].get()) == + selected->second.end()) + target.value().erase("active"); + ++target; + } + metadata(source.executionHome, path, *document); + } + + const auto guestInstance = guestHome / "subos" / inputs.scope; + instance_ = mountpoint(guestHome, guestInstance, true); + const auto pointer = fs::path("root.gen") / std::to_string(closure.generation); + fs::create_directory_symlink(pointer, instance_ / "root"); + bind(guestHome, closure.generationUsr, guestInstance / pointer / "usr"); + if (inputs.scope != "default") + fs::create_directory_symlink(inputs.scope, skeletons_.at(guestHome) / "subos/default"); + fs::create_directory_symlink(inputs.scope, skeletons_.at(guestHome) / "subos/current"); + + for (const auto name : {"usr", "lib", "bin", "share"}) { + const auto source = inputs.instance / name; + std::error_code ec; + const auto status = fs::symlink_status(source, ec); + if (status.type() == fs::file_type::not_found) + continue; + bind(guestHome, source, guestInstance / name); + } + const auto entry = physicalHome / "bin/xlings"; + std::error_code ec; + const auto entryStatus = fs::symlink_status(entry, ec); + if (entryStatus.type() != fs::file_type::not_found) + bind(guestHome, fs::canonical(entry), guestHome / "bin/xlings"); + + auto primary = home::read_json_for_update(physicalHome / ".xlings.json"); + if (!primary) + throw std::runtime_error(primary.error()); + auto versions = xvm::versions_to_json(inputs.versions); + auto installed = inputs.installed; + for (const auto& [target, version] : inputs.active) { + auto& keys = installed[target]; + if (std::ranges::find(keys, version) == keys.end()) + keys.push_back(version); + } + filter_versions(versions, installed); + (*primary)["versions"] = std::move(versions); + primary->erase("dbIndex"); + primary->erase("knownProjects"); + (*primary)["activeSubos"] = inputs.scope; + metadata(guestHome, guestHome / ".xlings.json", *primary); + + auto workspace = home::read_json_for_update(inputs.instance / ".xlings.json"); + if (!workspace) + throw std::runtime_error(workspace.error()); + metadata(guestHome, guestInstance / ".xlings.json", *workspace); + for (const auto& source : closure.metadata) { + if (source == physicalHome / ".xlings.json") + continue; + bind(guestHome, source, guestHome / source.lexically_relative(physicalHome)); + } + } +}; +} // namespace + +std::expected, std::string> prepare(const store_closure::Inputs& inputs) { + auto closure = store_closure::collect(inputs); + if (!closure) + return std::unexpected(closure.error()); + auto view = std::make_shared(); + try { + view->build(inputs, *closure); + return std::shared_ptr(std::move(view)); + } catch (const std::exception& error) { + view->close(); + return std::unexpected("cannot prepare filtered root view: " + std::string(error.what())); + } +} +} // namespace xlings::subos_root::root_view diff --git a/src/core/subos/root/root_view.cppm b/src/core/subos/root/root_view.cppm new file mode 100644 index 000000000..abb593212 --- /dev/null +++ b/src/core/subos/root/root_view.cppm @@ -0,0 +1,23 @@ +export module xlings.core.subos.root_view; + +import std; +import xlings.platform.root_mount; +import xlings.core.subos.store_closure; + +export namespace xlings::subos_root::root_view { +namespace fs = std::filesystem; +using Binding = platform::root_mount::Binding; + +class View { + public: + virtual ~View() = default; + virtual const std::vector& bindings() const = 0; + virtual const fs::path& private_instance() const = 0; + virtual std::expected refresh(std::span target) = 0; + // Detached supervision transfers this lifetime explicitly; the launching + // process's destructor must not remove a live namespace's skeleton. + virtual void close() noexcept = 0; +}; + +std::expected, std::string> prepare(const store_closure::Inputs& inputs); +} // namespace xlings::subos_root::root_view diff --git a/src/core/subos/root/store_closure.cpp b/src/core/subos/root/store_closure.cpp new file mode 100644 index 000000000..bd1cf59f9 --- /dev/null +++ b/src/core/subos/root/store_closure.cpp @@ -0,0 +1,349 @@ +module xlings.core.subos.store_closure; + +import std; +import xlings.core.home; +import xlings.core.home.evidence; +import xlings.core.home.layers; +import xlings.core.home.domain_producer_source; +import xlings.core.xvm.owner; +import xlings.core.xvm.db; +import xlings.core.xvm.bindings; +import xlings.core.elfread; +import xlings.subos.rootfs; + +namespace xlings::subos_root::store_closure { +namespace ev = home::evidence; +namespace source_view = home::domain_producer_source; + +Inputs::Inputs() : versions(xvm::empty_version_db()) { +} +Inputs::~Inputs() = default; +Inputs::Inputs(const Inputs& other) + : home(other.home), scope(other.scope), instance(other.instance), root(other.root), + versions(other.versions), active(other.active), installed(other.installed), + logicalHome(other.logicalHome), controlInstance(other.controlInstance) { +} +Inputs& Inputs::operator=(const Inputs& other) { + if (this != &other) { + home = other.home; + scope = other.scope; + instance = other.instance; + root = other.root; + versions = other.versions; + active = other.active; + installed = other.installed; + logicalHome = other.logicalHome; + controlInstance = other.controlInstance; + } + return *this; +} +Inputs::Inputs(Inputs&& other) + : home(std::move(other.home)), scope(std::move(other.scope)), + instance(std::move(other.instance)), root(std::move(other.root)), + versions(std::move(other.versions)), active(std::move(other.active)), + installed(std::move(other.installed)), logicalHome(std::move(other.logicalHome)), + controlInstance(std::move(other.controlInstance)) { +} +Inputs& Inputs::operator=(Inputs&& other) { + if (this != &other) { + home = std::move(other.home); + scope = std::move(other.scope); + instance = std::move(other.instance); + root = std::move(other.root); + versions = std::move(other.versions); + active = std::move(other.active); + installed = std::move(other.installed); + logicalHome = std::move(other.logicalHome); + controlInstance = std::move(other.controlInstance); + } + return *this; +} + +namespace { +bool within(const fs::path& root, const fs::path& path) { + const auto relative = path.lexically_relative(root); + return path == root || + (!relative.empty() && !relative.is_absolute() && *relative.begin() != ".."); +} +bool opted_in(const xvm::WorkspaceInstalled& installed, const std::string& target, + const std::string& key) { + const auto it = installed.find(target); + return it != installed.end() && std::ranges::find(it->second, key) != it->second.end(); +} +} // namespace + +std::expected collect(const Inputs& inputs) { + try { + std::error_code ec; + const auto canonicalHome = fs::canonical(inputs.home, ec); + if (ec) + return std::unexpected("root closure home is unreadable: " + inputs.home.string()); + auto installed = inputs.installed; + for (const auto& [target, key] : inputs.active) { + auto& versions = installed[target]; + if (std::ranges::find(versions, key) == versions.end()) + versions.push_back(key); + } + auto mapping = source_view::read(); + if (!mapping) + return std::unexpected(mapping.error()); + std::map allowed; + std::map guestHomes; + std::map, SourceScope> sources; + std::set> checked; + xvm::BindingSelectionResolver resolver(inputs.versions); + for (const auto& [target, keys] : installed) { + for (const auto& key : keys) { + if (checked.contains({target, key})) + continue; + auto selection = resolver.resolve(target, key); + if (!selection) + return std::unexpected(selection.error().message); + for (const auto& [member, memberKey] : selection->members) { + if (!opted_in(installed, member, memberKey)) + return std::unexpected(member + "@" + memberKey + + ": root scope has not opted into this release"); + const auto* data = xvm::get_vdata(inputs.versions, member, memberKey); + const auto owner = xvm::recorded_owner(inputs.versions, member, memberKey); + if (!data || !owner) + return std::unexpected( + member + ": root closure needs a formal package registration"); + const auto sourceHome = + data->sourceHome.empty() ? canonicalHome : fs::path(data->sourceHome); + const auto recordedHome = + inputs.logicalHome.empty() ? canonicalHome : inputs.logicalHome; + fs::path expanded = xvm::expand_path(data->path, recordedHome.string()); + if (data->sourceHome.empty() && + within(recordedHome, expanded.lexically_normal())) + expanded = canonicalHome / + expanded.lexically_normal().lexically_relative(recordedHome); + auto executionHome = data->sourceHome.empty() + ? std::expected(canonicalHome) + : source_view::execution_home(sourceHome); + if (!executionHome) + return std::unexpected(executionHome.error()); + const bool mappedSource = !data->sourceHome.empty() && *mapping && + (**mapping).physicalHome == sourceHome; + if (mappedSource) { + auto mapped = source_view::map_path(**mapping, expanded.lexically_normal(), + *executionHome); + if (!mapped) + return std::unexpected(mapped.error()); + expanded = *mapped; + } + if (!data->sourceHome.empty() && *executionHome == canonicalHome) + return std::unexpected( + member + ": borrowed source must be distinct from the owning home"); + auto physical = ev::physical_store_root(*executionHome, expanded); + if (!physical) + return std::unexpected(physical.error()); + if (*owner != physical->coordinate) + return std::unexpected( + member + + ": root registration contradicts its physical package provider"); + if (!data->sourceHome.empty()) { + if (data->sourceScope.empty()) + return std::unexpected(member + ": missing borrowed source scope"); + auto source = + home::layers::read_source_snapshot(sourceHome, data->sourceScope); + if (!source) + return std::unexpected(source.error()); + auto proof = home::layers::plan_borrow(*source, {}, member, memberKey); + if (!proof || proof->requestedPayload != physical->root) + return std::unexpected( + member + ": borrowed source no longer proves this payload"); + } + if (data->sourceHome.empty()) { + physical->recordedHome = recordedHome; + guestHomes[physical->root] = recordedHome; + } else { + physical->recordedHome = physical->home; + const auto guest = mappedSource ? (**mapping).logicalHome : physical->home; + guestHomes[physical->root] = guest; + auto& source = sources[{physical->home, data->sourceScope}]; + source.physicalHome = sourceHome; + source.executionHome = physical->home; + source.guestHome = guest; + source.scope = data->sourceScope; + auto& keys = source.installed[member]; + if (std::ranges::find(keys, memberKey) == keys.end()) + keys.push_back(memberKey); + } + allowed[physical->root] = std::move(*physical); + checked.insert({member, memberKey}); + } + } + } + auto recorded_source = + [&](const fs::path& path) -> std::expected { + for (const auto& [root, authority] : allowed) { + auto physicalPath = path.lexically_normal(); + if (!authority.recordedHome.empty() && within(authority.recordedHome, physicalPath)) + physicalPath = + authority.home / physicalPath.lexically_relative(authority.recordedHome); + if (*mapping && authority.home == (**mapping).recordedHome && + within((**mapping).logicalHome, physicalPath)) + physicalPath = + authority.home / physicalPath.lexically_relative((**mapping).logicalHome); + const auto actual = fs::canonical(physicalPath, ec); + if (!ec && within(root, actual)) + return authority; + ec.clear(); + } + return std::unexpected(path.string() + + ": recorded runtime dependency is not opted into this root " + "scope; run owner install --reconfig"); + }; + std::map visited; + std::function(const ev::OwnedPayload&)> visit; + visit = [&](const ev::OwnedPayload& authority) -> std::expected { + auto& state = visited[authority.root]; + if (state == 1) + return std::unexpected(authority.root.string() + + ": cyclic checked runtime evidence"); + if (state == 2) + return {}; + if (visited.size() > 4096) + return std::unexpected("root closure exceeds 4096 payloads"); + state = 1; + auto resolution = ev::read_checked_resolution(authority, recorded_source); + if (!resolution) + return std::unexpected(resolution.error()); + for (const auto& dependency : resolution->dependencies) { + auto nested = visit(dependency.payload); + if (!nested) + return nested; + } + state = 2; + return {}; + }; + for (const auto& [root, authority] : allowed) { + auto resolution = visit(authority); + if (!resolution) + return std::unexpected(resolution.error()); + } + Closure result; + for (const auto& [root, authority] : allowed) { + result.payloads.push_back(root); + const auto guestHome = guestHomes.at(root); + result.mounts.push_back({root, guestHome / root.lexically_relative(authority.home), + authority.home, guestHome}); + } + for (auto& [identity, source] : sources) + result.sources.push_back(std::move(source)); + auto permitted_elf_path = [&](const fs::path& path) { + for (const auto& mount : result.mounts) { + auto candidate = path.lexically_normal(); + if (within(mount.destination, candidate)) + candidate = mount.source / candidate.lexically_relative(mount.destination); + const auto actual = fs::weakly_canonical(candidate, ec); + if (!ec && within(mount.source, actual)) + return true; + ec.clear(); + } + return false; + }; + for (const auto& root : result.payloads) { + for (auto it = fs::recursive_directory_iterator(root, ec); + !ec && it != fs::recursive_directory_iterator(); it.increment(ec)) { + if (!it->is_regular_file(ec)) + continue; + const auto file = it->path(); + if (!elfread::is_elf(file)) + continue; + const auto info = elfread::read(file); + if (!info) + return std::unexpected(file.string() + ": cannot verify ELF runtime paths"); + if (!info->interpreter.empty()) { + const fs::path interpreter(info->interpreter); + const bool rootLoader = interpreter == "/lib64/ld-linux-x86-64.so.2" || + interpreter == "/lib/ld-linux-aarch64.so.1"; + if (!rootLoader && !permitted_elf_path(interpreter)) + return std::unexpected( + file.string() + + ": ELF interpreter is outside its checked root closure"); + } + for (auto search : info->searchPaths) { + for (const auto token : {"${ORIGIN}", "$ORIGIN"}) { + std::size_t position; + while ((position = search.find(token)) != std::string::npos) + search.replace(position, std::string_view(token).size(), + file.parent_path().string()); + } + const fs::path directory(search); + if (directory == "/usr/lib" || directory == "/usr/lib64" || + directory == "/lib" || directory == "/lib64") + continue; + if (!directory.is_absolute() || search.find('$') != std::string::npos || + !permitted_elf_path(directory)) + return std::unexpected( + file.string() + + ": ELF search path is outside its recorded root closure: " + search); + } + } + if (ec) + return std::unexpected(root.string() + + ": cannot inspect payload runtime paths: " + ec.message()); + } + for (const auto& path : {canonicalHome / ".xlings.json", canonicalHome / ".xlings-home"}) { + const auto status = fs::symlink_status(path, ec); + if (status.type() == fs::file_type::not_found) { + ec.clear(); + continue; + } + if (ec || !fs::is_regular_file(status)) + return std::unexpected(path.string() + + ": root metadata is not a readable regular file"); + result.metadata.push_back(path); + } + const auto config = canonicalHome / "config/subos" / inputs.scope; + const auto configStatus = fs::symlink_status(config, ec); + if (configStatus.type() != fs::file_type::not_found || + (ec && ec != std::errc::no_such_file_or_directory)) { + if (ec || !fs::is_directory(configStatus)) + return std::unexpected(config.string() + + ": root policy metadata is not a real directory"); + const auto policy = config / "policy.json"; + const auto status = fs::symlink_status(policy, ec); + if (status.type() != fs::file_type::not_found || + (ec && ec != std::errc::no_such_file_or_directory)) { + if (ec || !fs::is_regular_file(status)) + return std::unexpected(policy.string() + ": root policy is not a regular file"); + result.metadata.push_back(policy); + } + } + if (auto generation = subos::rootfs::current(inputs.instance)) { + if (auto valid = subos::rootfs::validate_generation(inputs.instance, *generation); + !valid) + return std::unexpected(valid.error()); + result.generation = *generation; + const auto path = inputs.instance / std::string(subos::rootfs::kGenerations) / + std::to_string(*generation) / "usr"; + result.generationUsr = fs::canonical(path, ec); + if (ec) + return std::unexpected("root generation is unreadable"); + } else { + return std::unexpected("root has no checked immutable generation"); + } + return result; + } catch (const std::exception& error) { + return std::unexpected("cannot collect root store closure: " + std::string(error.what())); + } +} +std::expected read_scope(const fs::path& home, const std::string& scope, + const fs::path& root) { + auto snapshot = home::layers::read_snapshot(home, scope); + if (!snapshot) + return std::unexpected(snapshot.error()); + Inputs inputs; + inputs.home = snapshot->home; + inputs.scope = snapshot->scope; + inputs.instance = snapshot->home / "subos" / snapshot->scope; + inputs.root = root; + inputs.versions = std::move(snapshot->versions); + inputs.active = std::move(snapshot->workspace.active); + inputs.installed = std::move(snapshot->workspace.installed); + return inputs; +} + +} // namespace xlings::subos_root::store_closure diff --git a/src/core/subos/root/store_closure.cppm b/src/core/subos/root/store_closure.cppm new file mode 100644 index 000000000..2f9c310ed --- /dev/null +++ b/src/core/subos/root/store_closure.cppm @@ -0,0 +1,54 @@ +export module xlings.core.subos.store_closure; + +import std; +export import xlings.core.xvm.types; + +export namespace xlings::subos_root::store_closure { +namespace fs = std::filesystem; + +struct Inputs { + fs::path home; + std::string scope; + fs::path instance; + fs::path root; + xvm::VersionDB versions; + xvm::Workspace active; + xvm::WorkspaceInstalled installed; + fs::path logicalHome; + fs::path controlInstance; + + Inputs(); + ~Inputs(); + Inputs(const Inputs&); + Inputs& operator=(const Inputs&); + Inputs(Inputs&&); + Inputs& operator=(Inputs&&); +}; +struct PayloadMount { + fs::path source; + fs::path destination; + fs::path home; + fs::path guestHome; +}; +struct SourceScope { + fs::path physicalHome; + fs::path executionHome; + fs::path guestHome; + std::string scope; + xvm::WorkspaceInstalled installed; +}; +struct Closure { + std::vector payloads; + std::vector mounts; + std::vector metadata; + std::vector sources; + fs::path generationUsr; + int generation{0}; +}; + +// Scope registration and checked evidence determine membership. ELF paths +// verify that membership; they never add an unrecorded package by name. +std::expected collect(const Inputs& inputs); +std::expected read_scope(const fs::path& home, const std::string& scope, + const fs::path& root); +} // namespace xlings::subos_root::store_closure diff --git a/src/core/subos/run.cpp b/src/core/subos/run.cpp new file mode 100644 index 000000000..f222a2a71 --- /dev/null +++ b/src/core/subos/run.cpp @@ -0,0 +1,469 @@ +// `subos exec / start / cp`: running things in an instance from outside it, +// and the isolation flags every entry shares (module xlings.core.subos). +module xlings.core.subos; + +import std; +import xlings.core.config; +import xlings.core.home_config; +import xlings.libs.json; +import xlings.core.log; +import xlings.platform; +import xlings.runtime; +import xlings.core.utils; +import xlings.core.xself; +import xlings.core.xvm.types; +import xlings.core.xvm.db; +import xlings.core.xim.commands; +import xlings.subos.gpu; +import xlings.subos.graphics; +import xlings.core.subos.sandbox; +import xlings.subos.manifest; +import xlings.cli.spec; +import xlings.i18n; +import xlings.core.confirm; +import xlings.core.destructive_log; +import xlings.subos.userdata; +import xlings.subos.model; +import xlings.core.subos.ports; +import xlings.subos.session; +import xlings.subos.home_view; +import xlings.subos.policy; +import xlings.subos.policy_store; +import xlings.subos.broker; +import xlings.observe; +import xlings.core.home; +import xlings.core.home.domain_producer; +import xlings.libs.sha256; +import xlings.core.subos.root; +import xlings.subos.rootfs; +import xlings.subos.roles; +import xlings.core.version_order; + +namespace xlings::subos { + +std::optional> split_env_(std::string_view kv) { + auto eq = kv.find('='); + if (eq == std::string_view::npos || eq == 0) return std::nullopt; + return std::pair{std::string(kv.substr(0, eq)), std::string(kv.substr(eq + 1))}; +} + +int parse_isolation_flag_(std::string_view a, int& i, int argc, char* argv[], + IsolationArgs& x, std::string& err) { + auto value = [&](std::string_view flag) -> std::optional { + if (a.starts_with(std::string(flag) + "=")) return std::string(a.substr(flag.size() + 1)); + if (a == flag && i + 1 < argc) return std::string(argv[++i]); + return std::nullopt; + }; + if (a == "--sandbox") { + x.sandbox = true; + if (i + 1 < argc) { + std::string_view next = argv[i + 1]; + if (next == "bwrap" || next == "proot" || next == "landlock") { x.backend = next; ++i; } + } + return 1; + } + if (a.starts_with("--sandbox=")) { + x.sandbox = true; + auto v = a.substr(10); + if (v == "bwrap" || v == "proot" || v == "landlock") { x.backend = v; return 1; } + auto p = policy::preset_from_string(v); + if (!p || *p == policy::Preset::Legacy) { + err = std::format("--sandbox={}: expected dev, private or locked (or a backend: bwrap, proot, landlock)", v); + return -1; + } + x.preset = *p; + return 1; + } + if (a == "--net" || a.starts_with("--net=")) { + auto v = value("--net"); + auto n = v ? policy::net_from_string(*v) : std::nullopt; + if (!n) { err = "--net expects host, nat, none or proxy"; return -1; } + x.overrides.net = *n; + x.sandbox = true; + return 1; + } + if (a == "--proxy" || a.starts_with("--proxy=")) { + auto v = value("--proxy"); + if (!v || v->empty()) { err = "--proxy expects socks5h://HOST:PORT"; return -1; } + x.overrides.proxy = *v; + x.overrides.net = policy::Net::Proxy; + x.sandbox = true; + return 1; + } + if (a == "--fetch" || a.starts_with("--fetch=")) { + auto v = value("--fetch"); + auto f = v ? policy::fetch_from_string(*v) : std::nullopt; + if (!f) { err = "--fetch expects auto, ask or deny"; return -1; } + x.overrides.fetch = *f; + return 1; + } + if (a == "--observe" || a.starts_with("--observe=")) { + auto v = value("--observe"); + auto level = v ? policy::observe_from_string(*v) : std::nullopt; + if (!level) { err = "--observe expects off, basic, standard or full"; return -1; } + x.overrides.observe = *level; + x.sandbox = true; + return 1; + } + if (a == "--allow" || a.starts_with("--allow=")) { + auto v = value("--allow"); + if (!v) { err = "--allow expects a grant"; return -1; } + std::string_view list(*v); + while (!list.empty()) { + auto comma = list.find(','); + x.overrides.allow.insert(std::string(list.substr(0, comma))); + if (comma == std::string_view::npos) break; + list.remove_prefix(comma + 1); + } + x.sandbox = true; + return 1; + } + if (a == "--mount" || a.starts_with("--mount=")) { + auto v = value("--mount"); + if (!v) { err = "--mount expects [:][:ro|rw]"; return -1; } + std::error_code ec; + auto m = policy::parse_mount(*v, utils::get_env_or_default("HOME"), + fs::current_path(ec).generic_string()); + if (!m) { err = m.error(); return -1; } + x.overrides.mounts.push_back(std::move(*m)); + x.sandbox = true; + return 1; + } + if (a == "--publish" || a.starts_with("--publish=")) { + auto v = value("--publish"); + if (!v || v->find(':') == std::string::npos) { err = "--publish expects HOST:SANDBOX ports, e.g. 8080:80"; return -1; } + x.publish.push_back(*v); + x.sandbox = true; + return 1; + } + if (a == "--no-degrade") { + x.overrides.no_degrade = true; + x.sandbox = true; + return 1; + } + return 0; +} + + + +// `subos exec [options] -- ` (design §12): one command in an +// instance from outside it, as argv (nothing to shell-escape). Joins the +// running session when there is one; otherwise starts one for the command +// (with --sandbox) or runs it with the instance's environment. Exit codes are +// the command's own, 125 when it never started, 126/127, 124, 128+n. +int run_exec_(int argc, char* argv[], EventStream& stream) { + std::string name, cwd, from; + std::map env; + std::optional timeout; + bool json = false, temp = false; + IsolationArgs iso; + std::vector> normalizedMounts; + std::vector command; + auto fail = [&](std::string message, std::string hint = {}) { + stream.emit(ErrorEvent{ .code = ErrorCode::InvalidInput, .message = std::move(message), + .recoverable = false, .hint = std::move(hint) }); + if (json) std::println(std::cerr, "{}", nlohmann::json{{"exit", session::kExitSetup}, + {"phase", "setup"}}.dump()); + return session::kExitSetup; + }; + for (int i = 3; i < argc; ++i) { + std::string a = argv[i]; + if (a == "--") { command.assign(argv + i + 1, argv + argc); break; } + std::string err; + if (auto r = parse_isolation_flag_(a, i, argc, argv, iso, err); r == 1) { + if (a == "--mount" || a.starts_with("--mount=")) { + const auto& mount = iso.overrides.mounts.back(); + auto spec = mount.src + (mount.dst.empty() ? "" : ":" + mount.dst); + if (mount.mode_given) spec += mount.rw ? ":rw" : ":ro"; + normalizedMounts.emplace_back(i, a == "--mount" ? spec : "--mount=" + spec); + } + continue; + } else if (r < 0) return fail(err); + if (a == "--json") json = true; + else if (a == "--temp") temp = true; + else if (a == "--from" && i + 1 < argc) from = argv[++i]; + else if (a == "--cwd" && i + 1 < argc) cwd = argv[++i]; + else if (a == "--env" && i + 1 < argc) { + auto kv = split_env_(argv[++i]); + if (!kv) return fail("--env expects K=V"); + env[kv->first] = kv->second; + } + else if (a == "--timeout" && i + 1 < argc) { + auto d = model::parse_duration(argv[++i]); + if (!d) return fail("--timeout expects seconds or 30s / 10m / 2h"); + timeout = std::chrono::seconds(*d); + } + else if (!a.empty() && a[0] != '-' && name.empty() && !temp) name = std::move(a); + else return fail("unknown option for `xlings subos exec`: " + a, + "usage: xlings subos exec [--sandbox] [--cwd D] [--env K=V] " + "[--timeout T] [--json] -- "); + } + if (command.empty()) return fail("missing the command: xlings subos exec -- "); + if (!from.empty() && !temp) return fail("--from needs --temp"); + + // --temp: a throwaway instance, removed afterwards; its audit stays. + std::string tempName; + if (temp) { + if (!name.empty()) return fail("--temp takes no name"); + std::random_device rd; + tempName = std::format("tmp-{:06x}", rd() & 0xffffff); + name = tempName; + EventStream quiet; + int rc = from.empty() + ? create(name, {}, sandbox::StorageMode::Shared, "50G", "", /*yes=*/true, "--temp", quiet) + : new_from(name, {}, sandbox::StorageMode::Shared, "50G", from, "", /*yes=*/true, quiet); + if (rc != 0) return fail("could not create a temporary instance" + + (from.empty() ? std::string{} : " from " + from)); + } else if (name.empty()) { + return fail("missing for: xlings subos exec (or --temp)"); + } else { + auto resolved = resolve_candidate_(name); + if (resolved.selected.empty() || resolved.autoSelected) { + emit_candidates_(stream, resolved, name); + return fail("no SubOS named '" + name + "'", "xlings subos list"); + } + } + + if (!temp) { + std::vector domainArgs{"subos", "exec"}; + domainArgs.insert(domainArgs.end(), argv + 3, argv + argc); + for (const auto& [index, spec] : normalizedMounts) domainArgs[index - 1] = spec; + if (auto result = run_domain_operation_(name, domainArgs, stream)) return *result; + } + + const auto started = std::chrono::steady_clock::now(); + int rc = 0; + std::string mode; + if (iso.sandbox || iso.preset || session::find(home_view(), name) + || enters_sandboxed_(name)) { + mode = "sandbox"; + use_detail_::apply_subos_env_(name); + auto declared = declared_env_(name); + declared.insert(env.begin(), env.end()); + for (auto& [k, v] : env) declared[k] = v; + EventStream quiet; + EventStream& out = json ? static_cast(quiet) : stream; + rc = sandbox::enter(name, out, sandbox::EnterOptions{ + .backend = iso.backend, .argv = command, .cwd = cwd, .env = std::move(declared), + .timeout = timeout, .exec_codes = true, .announce = false, + .preset = iso.preset, .overrides = iso.overrides, .publish = iso.publish }); + } else { + // An instance without a sandbox: its environment, this process's + // stdio, no supervisor (design §16, "没有沙箱的实例"). + mode = "shell"; + if (!cwd.empty()) { + std::error_code ec; + fs::current_path(cwd, ec); + if (ec) return fail("--cwd: " + cwd + ": " + ec.message()); + } + auto& p = Config::paths(); + auto bin_dir = p.homeDir / "subos" / name / "bin"; + platform::set_env_variable("XLINGS_ACTIVE_SUBOS", name); + platform::set_env_variable("XLINGS_BIN", bin_dir.string()); + platform::set_env_variable("XLINGS_SUBOS_LIB", (p.homeDir / "subos" / name / "lib").string()); + platform::set_env_variable("PATH", use_detail_::rebuild_path_for_subos_( + utils::get_env_or_default("PATH"), p.homeDir, bin_dir)); + use_detail_::apply_subos_env_(name); + for (auto& [k, v] : env) platform::set_env_variable(k, v); + observe::append(home_view().logs_dir(name) / "events.ndjson", observe::Event{ + .kind = observe::Kind::Ops, + .fields = {{"event", "exec"}, {"instance", name}, {"mode", "shell"}, + {"program", command[0]}, {"argc", command.size()}}}); + if (timeout && platform::is_windows) + log::warn("--timeout needs --sandbox on Windows; running without it"); + rc = timeout && platform::is_posix ? platform::run_argv_with_timeout(command, *timeout) + : platform::run_argv(command); + observe::append(home_view().logs_dir(name) / "events.ndjson", observe::Event{ + .kind = observe::Kind::Ops, + .fields = {{"event", "exec-end"}, {"instance", name}, {"mode", "shell"}, + {"program", command[0]}, {"exit", rc}}}); + } + + if (!tempName.empty()) { + // The instance this command created, removed by the same command: the + // user asked for a throwaway with --temp, which is the confirmation. + // `remove` with the --temp spelling as its yes: the one deletion + // entry point, the registry entry, and a destructive-log line that + // says how it was confirmed. + EventStream quiet; + if (remove(name, /*yes=*/true, "--temp", quiet) != 0) + log::warn("could not remove the temporary instance {}", name); + } + if (json) { + auto ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - started).count(); + nlohmann::json result{{"instance", name}, {"exit", rc}, {"mode", mode}, {"ms", ms}, + {"temp", temp}}; + if (rc == session::kExitSetup) result["phase"] = "setup"; + if (rc == session::kExitTimeout && timeout) result["timeout"] = true; + std::println(std::cerr, "{}", result.dump()); + } + return rc; +} + +// `subos start [--sandbox[=backend]] [--ttl 30m]`: a session that runs +// without a terminal, for a series of `subos exec` (design §12.1). +int run_start_(int argc, char* argv[], EventStream& stream, + const std::function& usageError) { + std::string name; + int nameIndex = 0; + std::vector> normalizedMounts; + IsolationArgs iso; // a session is a sandbox, whatever was said + int ttl = 0; + for (int i = 3; i < argc; ++i) { + std::string a = argv[i]; + std::string err; + if (auto r = parse_isolation_flag_(a, i, argc, argv, iso, err); r == 1) { + if (a == "--mount" || a.starts_with("--mount=")) { + const auto& mount = iso.overrides.mounts.back(); + auto spec = mount.src + (mount.dst.empty() ? "" : ":" + mount.dst); + if (mount.mode_given) spec += mount.rw ? ":rw" : ":ro"; + normalizedMounts.emplace_back(i, a == "--mount" ? spec : "--mount=" + spec); + } + continue; + } + else if (r < 0) { usageError(err); return 1; } + if (a == "--ttl" && i + 1 < argc) { + auto d = model::parse_duration(argv[++i]); + if (!d) { usageError("--ttl expects seconds or 30s / 10m / 2h"); return 1; } + ttl = static_cast(*d); + } + else if (!a.empty() && a[0] != '-' && name.empty()) { name = std::move(a); nameIndex = i; } + else { usageError("unknown option for `xlings subos start`: " + a); return 1; } + } + if (name.empty()) { usageError("missing for: xlings subos start"); return 1; } + auto resolved = resolve_use_name_(name, stream); + if (resolved.selected.empty()) return resolved.exitCode; + name = resolved.selected; + std::vector domainArgs{"subos", "start"}; + domainArgs.insert(domainArgs.end(), argv + 3, argv + argc); + domainArgs[nameIndex - 1] = name; + for (const auto& [index, spec] : normalizedMounts) domainArgs[index - 1] = spec; + if (auto result = run_domain_operation_(name, domainArgs, stream)) return *result; + if (auto live = session::find(home_view(), name)) { + log::info("'{}' is already running (session {})", name, live->id); + return 0; + } + if (auto rc = use_detail_::validate_subos_(name, stream); rc != 0) return rc; + use_detail_::apply_subos_env_(name); + auto rc = sandbox::enter(name, stream, sandbox::EnterOptions{ + .backend = iso.backend, .env = declared_env_(name), .ttl = ttl, .detached = true, + .preset = iso.preset, .overrides = iso.overrides, .publish = iso.publish }); + if (rc != 0) return rc; + if (auto live = session::find(home_view(), name)) { + log::info("started session {} for '{}'{}", live->id, name, + ttl > 0 ? std::format(" (ends after {}s idle)", ttl) : std::string(" (until `subos stop`)")); + return 0; + } + return 1; +} + +// A keeper process an xlings before 2026.10 left behind (sessions replaced +// it): `subos stop` still ends it. COMPAT: drop in 2027.4. +void stop_legacy_keeper_(const std::string& name) { + const auto pid_file = Config::paths().homeDir / "subos" / name / ".keeper.pid"; + std::error_code ec; + if (!fs::exists(pid_file, ec)) return; + int pid = 0; + std::ifstream(pid_file) >> pid; + if (pid > 0 && platform::is_process_alive(pid)) { + platform::send_signal(pid, platform::sig::terminate); + for (int i = 0; i < 20 && platform::is_process_alive(pid); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + if (platform::is_process_alive(pid)) platform::send_signal(pid, platform::sig::kill); + } + fs::remove(pid_file, ec); + fs::remove(pid_file.parent_path() / ".keeper.lastused", ec); +} + +// `subos cp `, one side `:` (design §12.1). Paths +// inside are the instance's own: its home and its /tmp. +int run_cp_(int argc, char* argv[], EventStream& stream, + const std::function& usageError) { + std::vector paths; + for (int i = 3; i < argc; ++i) { + std::string a = argv[i]; + if (!a.empty() && a[0] == '-') { usageError("unknown option for `xlings subos cp`: " + a); return 1; } + paths.push_back(std::move(a)); + } + if (paths.size() != 2) { usageError("usage: xlings subos cp : | : "); return 1; } + auto split = [](const std::string& p) -> std::optional> { + auto colon = p.find(':'); + // A Windows drive letter (C:\x) is a host path, not an instance. + if (colon == std::string::npos || colon < 2) return std::nullopt; + return std::pair{p.substr(0, colon), p.substr(colon + 1)}; + }; + auto src = split(paths[0]); + auto dst = split(paths[1]); + if (static_cast(src) == static_cast(dst)) { + usageError("exactly one side of `subos cp` names an instance as :"); + return 1; + } + const auto& inst = src ? *src : *dst; + auto resolved = resolve_use_name_(inst.first, stream); + if (resolved.selected.empty()) return resolved.exitCode; + auto scope = xlings::home::domain_producer::read_scope(Config::paths().homeDir, resolved.selected); + if (!scope) { + stream.emit(ErrorEvent{ .code = ErrorCode::InvalidInput, .message = scope.error(), .recoverable = false }); + return 1; + } + const auto instance = *scope ? (**scope).producerInstance : Config::subos_dir(resolved.selected); + const auto instanceHome = *scope ? (**scope).domain.physicalHome : Config::paths().homeDir; + const auto logicalHome = *scope ? (**scope).domain.logicalHome : Config::paths().homeDir; + auto user = utils::get_env_or_default(platform::is_windows ? "USERNAME" : "USER"); + if (user.empty()) user = "user"; + if (!role_allows_at_(instanceHome, roles::Op::Copy, resolved.selected, stream)) return 1; + // A rootfs instance's own tree is everything but what the projection and + // the kernel provide (part 2 §6.1); a view's is its home and /tmp. + const auto kind = subos_root::read_kind(instanceHome, resolved.selected); + if (!kind) { + stream.emit(ErrorEvent{ .code = ErrorCode::InvalidInput, .message = kind.error(), + .recoverable = false }); + return 1; + } + const bool rootfs = *kind == roles::Kind::Rootfs; + auto mapped = rootfs + ? model::inside_to_root(instance / std::string(rootfs::kTree), + inst.second, logicalHome) + : model::inside_to_host(instance, user, inst.second); + if (!mapped) { + stream.emit(ErrorEvent{ .code = ErrorCode::InvalidInput, + .message = rootfs ? inst.second + " is not the root's own (/usr, /proc, /sys, /dev, /run and " + "the xlings home are provided, not copied into)" + : inst.second + " is not the instance's own (only /home/" + user + " and /tmp are)", + .recoverable = false }); + return 1; + } + // Paths inside are resolved beneath the instance's root and never + // through a link it made (platform::copy_into_beneath). + const auto root = rootfs ? instance / std::string(rootfs::kTree) + : instance; + const auto rel = mapped->lexically_relative(root); + std::expected done; + if (src) { + fs::path to = paths[1]; + std::error_code ec; + if (fs::is_directory(to, ec)) to /= rel.filename(); + done = platform::copy_out_of_beneath(root, rel, to); + } else { + std::error_code ec; + if (!fs::exists(fs::symlink_status(paths[0], ec))) { + stream.emit(ErrorEvent{ .code = ErrorCode::NotFound, .message = paths[0] + ": not found", + .recoverable = false }); + return 1; + } + done = platform::copy_into_beneath(paths[0], root, rel); + } + if (!done) { + stream.emit(ErrorEvent{ .code = ErrorCode::Internal, + .message = "copy failed: " + done.error(), .recoverable = false }); + return 1; + } + observe::append(xlings::subos::HomeView{instanceHome}.logs_dir(resolved.selected) / "events.ndjson", observe::Event{ + .kind = observe::Kind::Fs, + .fields = {{"event", "cp"}, {"instance", resolved.selected}, + {"direction", src ? "out" : "in"}, {"path", inst.second}}}); + return 0; +} + +} // namespace xlings::subos diff --git a/src/core/subos/sandbox.cpp b/src/core/subos/sandbox.cpp index c952310a7..c69032378 100644 --- a/src/core/subos/sandbox.cpp +++ b/src/core/subos/sandbox.cpp @@ -1,22 +1,3 @@ -module; - -// System headers used by the sandbox backends only. `import std;` does not -// pull these in, and the named-module purview forbids including them there. -#include -#if defined(_WIN32) -#define WIN32_LEAN_AND_MEAN -// See src/core/subos.cppm: windows.h's min/max macros break std::min({...}). -// Not yet triggered here, and that is exactly why it is worth closing -- -// the failure only appears on Windows, and only once someone writes the call. -#define NOMINMAX -#include -#else -#include -#include -#include -#include -#endif - module xlings.core.subos.sandbox; import std; @@ -28,8 +9,31 @@ import xlings.runtime; import xlings.core.utils; import xlings.core.xim.commands; import xlings.core.xim.compatibility; -import xlings.core.subos.gpu; +import xlings.subos.gpu; import xlings.core.xvm.shim; +import xlings.subos.home_view; +import xlings.subos.ports; +import xlings.subos.policy; +import xlings.subos.policy_store; +import xlings.subos.caps; +import xlings.subos.spec; +import xlings.confine.provider; +import xlings.confine; +import xlings.confine.gates; +import xlings.subos.session; +import xlings.subos.broker; +import xlings.core.elfread; +import xlings.core.confirm; +import xlings.observe; +import xlings.core.subos.ports; +import xlings.core.subos.root; +import xlings.core.subos.store_closure; +import xlings.core.subos.root_view; +import xlings.subos.roles; +import xlings.subos.elevation; +import xlings.subos.tools; +import xlings.carrier; +import xlings.subos.userdata; namespace xlings::subos::sandbox { @@ -51,20 +55,14 @@ StorageMode read_storage_mode_(const fs::path& subos_dir) { return StorageMode::Shared; } -// /etc/* template builders + sandbox dir layout init. uid_t / gid_t -// are POSIX types — Windows MSVC doesn't have them. Sandbox is -// Linux-only by design (proot uses ptrace + Linux syscall semantics); -// the only caller (use_sandbox_mode_) is also Linux-guarded, so we -// guard these helpers too rather than fight the type system with -// platform-portable substitutes. -#if defined(__linux__) || defined(__APPLE__) - +// /etc/* template builders + sandbox dir layout init (POSIX sandboxes; +// plain code, so it compiles everywhere and needs no #if). // We write per-user passwd/group at sandbox init time so getpwuid // (real_uid) inside the sandbox returns the real user's home // (= /home/) and shell — most CLI tools depend on this. Root // is also included so anything that does getpwuid(0) (scripts that // assume "root must exist") doesn't bail. -std::string make_etc_passwd_(const std::string& user, uid_t uid, gid_t gid) { +std::string make_etc_passwd_(const std::string& user, unsigned uid, unsigned gid) { auto home = "/home/" + user; // When running as root (uid 0), emit a SINGLE uid-0 entry whose home is // the sandbox home (/home/), so getpwuid(0)->pw_dir agrees with @@ -82,7 +80,7 @@ std::string make_etc_passwd_(const std::string& user, uid_t uid, gid_t gid) { user, uid, gid, user, home); } -std::string make_etc_group_(const std::string& user, gid_t gid) { +std::string make_etc_group_(const std::string& user, unsigned gid) { // Mirror passwd: a single gid-0 entry when running as root avoids a // duplicate group-0 record (root + both gid 0). if (gid == 0) { @@ -100,12 +98,13 @@ std::string make_etc_group_(const std::string& user, gid_t gid) { // repeated `subos use --sandbox` is cheap. void init_sandbox_dirs_(const fs::path& subos_dir, const std::string& user, - uid_t uid, gid_t gid) + unsigned uid, unsigned gid, + std::string_view etc_name = "etc") { auto user_home = subos_dir / "home" / user; fs::create_directories(user_home); fs::create_directories(subos_dir / "tmp"); - auto etc = subos_dir / "etc"; + auto etc = subos_dir / std::string(etc_name); fs::create_directories(etc); // Empty `subos/` marker dir at sandbox root. xlings's project @@ -164,19 +163,39 @@ void init_sandbox_dirs_(const fs::path& subos_dir, try_write(fish_config_dir / "config.fish", kSandboxFishConfig); } -#endif // __linux__ / __APPLE__ - int init_image_(const fs::path& img, const std::string& size) { if (fs::exists(img)) return 0; - auto truncate_cmd = "truncate -s " + size + " " + img.string(); - if (std::system(truncate_cmd.c_str()) != 0) return 1; - auto mkfs_cmd = "mkfs.ext4 -F -m 0 -q " + img.string() + " 2>/dev/null"; - return std::system(mkfs_cmd.c_str()); + // A sparse file of `size` (K/M/G/T suffixes, as truncate takes it), then + // an ext4 on it with the mkfs the tool table finds. + auto bytes = policy::parse_size(size); + if (!bytes) { + log::error("image size '{}' is not a size", size); + return 1; + } + { + std::ofstream create(img, std::ios::binary); + if (!create) return 1; + } + std::error_code ec; + fs::resize_file(img, *bytes, ec); + if (ec) { + log::error("cannot size {}: {}", img.string(), ec.message()); + return 1; + } + auto mkfs = subos::tools::first("mkfs.ext4", subos::home_view(), Ports{}); + if (!mkfs) { + log::error("mkfs.ext4 is not available; {}", subos::tools::install_hint("mkfs.ext4")); + return 1; + } + return platform::run_argv({mkfs->bin.string(), "-F", "-m", "0", "-q", img.string()}); } +// A mount at exactly `path`, from this process's mount table. bool is_mounted_(const fs::path& path) { - auto cmd = "mountpoint -q " + path.string() + " 2>/dev/null"; - return std::system(cmd.c_str()) == 0; + std::ifstream in("/proc/self/mountinfo"); + const auto table = std::string(std::istreambuf_iterator(in), {}); + const auto at = subos::userdata::mount_under_in(table, path); + return at && *at == path; } // Mount an image file at mountpoint. Supports multi-terminal reuse. @@ -187,425 +206,79 @@ int mount_image_(const fs::path& img, const fs::path& mountpoint, fs::create_directories(mountpoint); if (is_mounted_(mountpoint)) return 0; // already mounted - // mount (privileged). priv_prefix() is "" when already root — sudo is - // redundant and frequently absent in minimal root containers, where the - // old hardcoded "sudo mount" died with "sudo: command not found". - auto cmd = platform::priv_prefix() + "mount -o loop " + img.string() + " " - + mountpoint.string(); - auto rc = std::system(cmd.c_str()); + // Privileged, through the one elevation path (run directly when already + // root -- sudo is frequently absent in minimal root containers). + const auto home = subos::home_view(); + auto rc = subos::elevation::run(home, {"mount", "-o", "loop", img.string(), mountpoint.string()}, + "mount a SubOS home image"); if (rc != 0) return rc; // ext4 root dir is owned by root after mkfs; chown to real user // so sandbox init can create dirs without sudo. - if (!user.empty()) { - auto chown_cmd = platform::priv_prefix() + "chown " + user + ":" + user - + " " + mountpoint.string(); - std::system(chown_cmd.c_str()); - } + if (!user.empty()) + (void)subos::elevation::run(home, {"chown", user + ":" + user, mountpoint.string()}, + "hand a SubOS home image to its user"); return 0; } int unmount_image_(const fs::path& mountpoint) { if (!is_mounted_(mountpoint)) return 0; - auto cmd = platform::priv_prefix() + "umount " + mountpoint.string() - + " 2>/dev/null"; - return std::system(cmd.c_str()); + return subos::elevation::run(subos::home_view(), {"umount", mountpoint.string()}, + "unmount a SubOS home image"); } // ───────────────────────────────────────────────────────────────────── -// Sandbox subos entry — proot-based fs-isolated session. -// -// Triggered when `subos use ` reads a subos config that has the -// `sandbox-shell` field. Only available on Linux (proot uses ptrace + -// Linux syscall conventions). On non-Linux platforms the sandbox config -// is rejected at create time, so this code path is unreachable there; -// guard with #if defined(__linux__) and stub elsewhere. -// -// See docs/plans/2026-05-09-subos-sandbox-design.md for full rationale. +// Sandbox entry: policy + host capabilities -> spec -> a session (Linux), +// or the home redirect (macOS, Windows). See docs/design/subos-isolation.md. // ───────────────────────────────────────────────────────────────────── -// Probe order for the proot binary: -// 1. /data/xpkgs/xim-x-proot//bin/proot the PAYLOAD -// 2. /runtimedir/proot auto-fetch cache -// 3. /usr/bin/proot, /usr/local/bin/proot the HOST (reported) -// -// PATH is deliberately absent -- see (3) below. The same rule, in the same -// shape, as libxpkg's elfpatch tool lookup: payload, then a named fallback -// that says so. Both implement R6 (internal consumers bind the payload, not -// the view), .agents/docs/2026-08-06-subos-architecture-proposal.md §1.5. +// Translate a failed bwrap probe into what happened and what to do about it. // -// Returns the path to a usable proot, or unexpected with a hint string. -std::expected -locate_proot_(const fs::path& home_dir) { - namespace fs = std::filesystem; - std::error_code ec; - - // (1) xpkg-managed (look at versions DB) - // - // Iterating with explicit directory_iterator + sentinel comparison - // (not range-for over a wrapper) sidesteps a libstdc++ copy-ctor - // visibility issue we hit on gcc 15.1.0 musl-cross when this header - // is exposed via BMI: the range-for path required `directory_iterator - // (directory_iterator const&)` to be visible at the inline expansion - // site inside another module's TU, and the cross-toolchain link - // surfaced it as "undefined reference to __cxx11::directory_iterator - // ctor" in capabilities.cppm. Using sentinel `{}` comparison avoids - // the copy entirely. - auto xpkgs_dir = home_dir / "data" / "xpkgs"; - auto xpkg_proot_root = xpkgs_dir / "xim-x-proot"; - if (fs::is_directory(xpkg_proot_root, ec)) { - // Sentinel form (`it != default_sentinel`) instead of comparing - // two directory_iterators directly. Clang/libc++ only provides - // the sentinel comparison in C++20+; comparing two - // directory_iterators yields "invalid operands to binary - // expression" on macOS clang. Sentinel form works on both - // libstdc++ (Linux gcc) and libc++ (macOS clang). - std::error_code it_ec; - for (auto it = fs::directory_iterator(xpkg_proot_root, it_ec); - !it_ec && it != std::default_sentinel; - it.increment(it_ec)) - { - auto candidate = it->path() / "bin" / "proot"; - if (fs::is_regular_file(candidate, ec)) return candidate; - } - } - - // (2) auto-fetch cache - auto runtime_proot = home_dir / "runtimedir" / "proot"; - if (fs::is_regular_file(runtime_proot, ec)) return runtime_proot; - - // (3) The host's proot, at the two paths a distribution puts it. - // - // There is no PATH step. An earlier version walked PATH and rejected the - // candidates that turned out to be xlings shims; that fixed the symptom - // and left the cause. By R6 the whole step is wrong for internal use: PATH - // is a *view*, it is what the user selected, and letting it decide which - // proot runs makes the sandbox's own identity depend on the environment - // the sandbox was launched from. A shim on PATH is the worst case -- it - // re-enters xlings, anchors to the home that owns it, and re-exports - // XLINGS_HOME to match, so an isolated home with no backend installed - // would silently run against the developer's real home, and every - // measurement taken inside would be of the wrong home while looking - // exactly like a measurement of the right one. - // - // These two paths are different in kind: they are the host's, they are - // named here rather than discovered, and using one is reported. Per §2.8 - // of the architecture proposal, depending on the host is allowed when it - // is DECLARED -- what is not allowed is depending on it by accident. - for (const auto* p : {"/usr/bin/proot", "/usr/local/bin/proot"}) { - auto candidate = fs::path(p); - if (!fs::is_regular_file(candidate, ec)) continue; - // Defensive: a home that installed itself under /usr/local would put - // a shim on one of these paths, and it is still a shim. - if (auto owner = xvm::resolve_owner_home(candidate)) { - log::debug("skipping {}: an xlings shim owned by {}, not the " - "host's proot", candidate.string(), owner->string()); - continue; - } - log::warn("using the host's proot ({}) -- no proot payload in {}. " - "Run `xlings install proot` to make this deterministic.", - candidate.string(), home_dir.string()); - return candidate; - } - - // Naming the home rather than "~/.xlings": with an isolated XLINGS_HOME - // the tilde form points somewhere the caller is not using, and a - // reader who follows it lands on the very home this search excluded. - return std::unexpected(std::format( - "proot not found in {}. Run `xlings install proot`, or place a proot " - "binary at {}/runtimedir/proot. The host's proot at /usr/bin/proot is " - "used when present (`sudo apt install proot`), but PATH is not " - "searched: a `proot` on PATH may be an xlings shim, and running it " - "would move the whole session to whichever home owns it.", - home_dir.string(), home_dir.string())); -} - -std::vector -sandbox_binds_(const fs::path& subos_dir, - const fs::path& host_xlings_home, - const std::string& user, - StorageMode storage = StorageMode::Shared, - const fs::path& mountpoint = {}) -{ - auto etc = subos_dir / "etc"; - auto user_home = "/home/" + user; - - std::error_code ec; - - auto try_ro = [&](std::vector& v, - const char* src, const char* dst) { - if (fs::exists(src, ec)) - v.push_back({src, dst, true}); - }; - - std::vector binds; - - // ── Host RO: POSIX userland (tools + libs + loader) ── - try_ro(binds, "/usr", "/usr"); - try_ro(binds, "/bin", "/bin"); - try_ro(binds, "/usr/lib", "/lib"); - try_ro(binds, "/usr/lib64", "/lib64"); - - // ── Host RO: /etc (by-need) ── - try_ro(binds, "/etc/resolv.conf", "/etc/resolv.conf"); - try_ro(binds, "/etc/ld.so.cache", "/etc/ld.so.cache"); - try_ro(binds, "/etc/ssl", "/etc/ssl"); - try_ro(binds, "/etc/pki", "/etc/pki"); - try_ro(binds, "/etc/alternatives", "/etc/alternatives"); - try_ro(binds, "/etc/localtime", "/etc/localtime"); - - // ── Sandbox RW: private overrides (storage-dependent) ── - if (storage == StorageMode::Shared) { - binds.push_back({(subos_dir / "home").string(), "/home", false}); - binds.push_back({(subos_dir / "tmp").string(), "/tmp", false}); - } else if (storage == StorageMode::Image) { - // home from mounted image; tmp uses bwrap --tmpfs (added in argv) - binds.push_back({mountpoint.string(), "/home", false}); - } - // tmpfs: home and tmp handled by bwrap --tmpfs (no bind needed) - - // xlings shared — always bound regardless of storage mode, and always - // at its OWN absolute path. - // - // xvm alias targets, RPATH and INTERP are absolute host paths baked at - // install time, and nothing rewrites them on the way in; the home has - // to answer to the same spelling in here that it does outside. Mapping - // it to /.xlings instead strands all of them, and mapping it - // to both makes one directory reachable by two real paths — which a - // bind mount does not collapse, so canonicalising does not save you and - // a shim ends up reporting a conflict with itself. For the default home - // this bind IS /.xlings; that coincidence is the only reason - // the remapped form ever appeared to work. - binds.push_back({host_xlings_home.string(), host_xlings_home.string(), false}); - - // ── Sandbox: NSS templates ── - binds.push_back({(etc / "passwd").string(), "/etc/passwd", false}); - binds.push_back({(etc / "group").string(), "/etc/group", false}); - binds.push_back({(etc / "hosts").string(), "/etc/hosts", false}); - binds.push_back({(etc / "nsswitch.conf").string(), "/etc/nsswitch.conf", false}); - - return binds; -} - -// Build proot argv from unified bind list. When `cmd` is non-empty, ends -// in ` -c ` for non-interactive single-command exec (M3); -// otherwise just `` for the standard interactive entry. -std::vector -build_proot_argv_(const fs::path& proot_bin, - const fs::path& subos_dir, - const fs::path& host_xlings_home, - const std::string& user, - const std::string& shell_path, - const std::string& cmd = "") -{ - auto user_home = "/home/" + user; - // Use sandbox-root/ as the chroot root instead of subos_dir itself. - // This avoids the proot detranslate_path bug where host paths under - // subos_dir (e.g. subos//usr) get mis-translated to /usr when - // the rootfs prefix is stripped. See init_sandbox_dirs_ for details. - auto proot_root = subos_dir / "sandbox-root"; - std::vector argv = { - proot_bin.string(), - "-r", proot_root.string(), - "--bind=/proc:/proc", - "--bind=/sys:/sys", - "--bind=/dev:/dev", - }; - - for (auto& b : sandbox_binds_(subos_dir, host_xlings_home, user)) { - argv.push_back(std::format("--bind={}:{}", b.src, b.dst)); - } - - argv.push_back(std::format("--cwd={}", user_home)); - argv.push_back(shell_path); - if (!cmd.empty()) { - argv.push_back("-c"); - argv.push_back(cmd); - } - return argv; -} - -// ── bwrap backend (V5) ──────────────────────────────────────────────── - -// Locate bwrap binary — only searches xim:bwrap pool. -// System-installed bwrap (/usr/bin/bwrap) is intentionally skipped: -// distro packages lack setuid, and AppArmor/kernel restrictions on -// unprivileged user namespaces make system binaries unreliable on -// Ubuntu 24+, Fedora, etc. xim:bwrap is built with -// `-Dsupport_setuid=true` (xlings-res/bwrap mirror) and its install -// hook sets the binary setuid root (chmod 4755) — that combination is -// what makes the xim-managed binary work across distros where unpriv -// userns is restricted. -std::expected -locate_bwrap_(const fs::path& home_dir) { - std::error_code ec; - - auto xpkg_root = home_dir / "data" / "xpkgs" / "xim-x-bwrap"; - if (fs::is_directory(xpkg_root, ec)) { - std::error_code it_ec; - for (auto it = fs::directory_iterator(xpkg_root, it_ec); - !it_ec && it != std::default_sentinel; - it.increment(it_ec)) - { - auto candidate = it->path() / "bin" / "bwrap"; - if (fs::is_regular_file(candidate, ec)) return candidate; - } - } - - return std::unexpected( - "bwrap not installed, run: xlings install bwrap"); -} - -// Probe whether a bwrap binary can actually create a sandbox. -// Returns {ok, output}. `output` captures stdout+stderr (the platform -// helper appends `2>&1` internally) so callers can classify the -// failure cause — see classify_bwrap_probe_error_ for the known modes. -std::pair -probe_bwrap_(const fs::path& bwrap_bin) { - auto cmd = platform::shell_quote(bwrap_bin.string()) - + " --ro-bind / / -- /bin/true"; - auto [status, output] = platform::run_command_capture(cmd); - return { status == 0, std::move(output) }; -} - -// Translate a failed bwrap probe's captured output into an actionable -// hint. Three known modes; anything else falls through to "show the -// raw stderr" so users always see the truth instead of a stale -// "xlings install bwrap" suggestion that won't help. +// The raw output always comes first: "bwrap failed" without bwrap's own words +// is how #640 was misdiagnosed. The remedies are in least-privilege order and +// never include turning off the kernel's user-namespace restriction for the +// whole machine -- that was the old advice, and it trades every other +// program's protection for this one's convenience. std::string classify_bwrap_probe_error_(const std::string& output, const fs::path& bin) { - if (output.find("setuid use of bubblewrap is not supported") - != std::string::npos) - { - return "xim:bwrap binary was built without setuid support; " - "rebuild xlings-res/bwrap mirror with " - "`-Dsupport_setuid=true` and bump the version\n" - " binary: " + bin.string(); + auto first_line = output.substr(0, output.find('\n')); + while (!first_line.empty() && (first_line.back() == '\r' || first_line.back() == ' ')) + first_line.pop_back(); + std::string out = "bwrap cannot create a sandbox here: " + first_line + + "\n binary: " + bin.string(); + + if (output.find("setuid use of bubblewrap is not supported") != std::string::npos) { + out += "\n this bwrap is setuid but was built without setuid support;" + " xlings no longer uses setuid bwrap"; + } else if (output.find("uid map") != std::string::npos + || output.find("Permission denied") != std::string::npos + || output.find("Operation not permitted") != std::string::npos + || output.find("user namespaces are not enabled") != std::string::npos) { + // Name the restriction that is in force, read from the kernel, so the + // reader does not have to guess which of the two it is. + auto read_sysctl = [](const char* path) -> std::string { + std::ifstream in(path); + std::string v; + std::getline(in, v); + return v; + }; + auto apparmor = read_sysctl("/proc/sys/kernel/apparmor_restrict_unprivileged_userns"); + auto clone = read_sysctl("/proc/sys/kernel/unprivileged_userns_clone"); + if (apparmor == "1") + out += "\n cause: AppArmor restricts unprivileged user namespaces for " + "programs without a profile (kernel.apparmor_restrict_unprivileged_userns=1)"; + else if (clone == "0") + out += "\n cause: the kernel disables unprivileged user namespaces " + "(kernel.unprivileged_userns_clone=0)"; + else + out += "\n cause: user namespaces are not available to this user"; } - if (output.find("setting up uid map: Permission denied") - != std::string::npos - || output.find("write to uid_map failed") - != std::string::npos) - { - return "kernel restricts unprivileged user namespaces " - "(LSM/AppArmor)\n" - " check: cat /proc/sys/kernel/" - "apparmor_restrict_unprivileged_userns\n" - " workaround: sudo sysctl -w " - "kernel.apparmor_restrict_unprivileged_userns=0"; - } - if (output.find("clone() failed: Operation not permitted") - != std::string::npos - || output.find("user namespaces are not enabled") - != std::string::npos) - { - return "kernel disables unprivileged_userns_clone\n" - " check: cat /proc/sys/kernel/" - "unprivileged_userns_clone"; - } - auto trimmed = output; - while (!trimmed.empty() && - (trimmed.back() == '\n' || trimmed.back() == '\r')) - trimmed.pop_back(); - return "bwrap probe failed; raw output:\n " + trimmed; -} - -// Build bwrap argv from unified bind list. Uses targeted --ro-bind -// instead of `--ro-bind / /` to match proot's security profile -// (same host paths exposed, same sandbox-private paths). -// When `cmd` is non-empty, ends with ` -c ` for non- -// interactive single-command exec (M3); otherwise interactive shell. -std::vector -build_bwrap_argv_(const fs::path& bwrap_bin, - const fs::path& subos_dir, - const fs::path& host_xlings_home, - const std::string& user, - const std::string& shell_path, - bool interactive_shell, - StorageMode storage = StorageMode::Shared, - const fs::path& mountpoint = {}, - bool gpu = false, - const std::string& cmd = "") -{ - auto user_home = "/home/" + user; - std::vector argv = { - bwrap_bin.string(), - "--dev", "/dev", - "--proc", "/proc", - }; - - for (auto& b : sandbox_binds_(subos_dir, host_xlings_home, user, - storage, mountpoint)) { - if (b.readonly) { - argv.insert(argv.end(), {"--ro-bind", b.src, b.dst}); - } else { - argv.insert(argv.end(), {"--bind", b.src, b.dst}); - } - } - - // GPU passthrough: must come after `--dev /dev` (which creates the - // empty tmpfs we'll mount nodes onto). Each --dev-bind is host-path - // → sandbox-path; missing nodes are silently skipped by gpu.cppm. - if (gpu) { - auto extra = xlings::subos::gpu::passthrough_args(); - argv.insert(argv.end(), extra.begin(), extra.end()); - } - - // tmpfs mode: home and tmp are pure memory (exit = gone) - if (storage == StorageMode::Tmpfs) { - argv.insert(argv.end(), {"--tmpfs", user_home}); - argv.insert(argv.end(), {"--tmpfs", "/tmp"}); - } - // image mode: tmp uses tmpfs (home is bind-mounted from .mountpoint) - if (storage == StorageMode::Image) { - argv.insert(argv.end(), {"--tmpfs", "/tmp"}); - } - - argv.insert(argv.end(), {"--chdir", user_home, "--", shell_path}); - if (!cmd.empty()) { - // Non-interactive single-command exec: shell -c . The -i - // flag would print prompts/job-control warnings to captured - // stdout, so we omit it even if interactive_shell=true. - argv.push_back("-c"); - argv.push_back(cmd); - } else if (interactive_shell) { - argv.push_back("-i"); - } - return argv; -} - -// Show bwrap hint — only on first sandbox use of a subos (detected by -// whether /home/ exists; init_sandbox_dirs_ creates it, so if -// it's missing, this is the first --sandbox entry for this subos). -void maybe_show_bwrap_hint_(const fs::path& subos_dir) { - std::error_code ec; - if (fs::is_directory(subos_dir / "home", ec)) return; - - log::info("bwrap not installed or namespace probe failed"); - log::info(" to enable bwrap (recommended): xlings install bwrap"); - log::info(" using proot fallback for now"); -} - -std::optional -detect_backend_(const fs::path& home_dir, - const fs::path& subos_dir = {}) { - // 1. bwrap (xim pool) — preferred for native compat - if (auto bin = locate_bwrap_(home_dir)) { - if (probe_bwrap_(*bin).first) - return BackendInfo{ SandboxBackend::Bwrap, *bin }; - } - - // bwrap found but probe failed → show hint (first use only) - if (locate_bwrap_(home_dir) && !subos_dir.empty()) { - maybe_show_bwrap_hint_(subos_dir); - } - - // 2. proot — zero-privilege fallback - if (auto bin = locate_proot_(home_dir)) - return BackendInfo{ SandboxBackend::Proot, *bin }; - - return std::nullopt; + out += "\n fix, least privilege first:" + "\n 1. xlings self doctor --isolation --fix" + " (one sudo: a root-owned bwrap with a narrow AppArmor profile)" + "\n 2. --sandbox proot (works anywhere; a view, not a security boundary)"; + return out; } // Should we even try to fetch a sandbox backend for this host? Answered from @@ -635,7 +308,7 @@ std::optional backend_target_unavailable_() { const auto hostArch = xim::host_architecture(); std::string target; - for (const auto* name : {"bwrap", "proot"}) { + for (const auto* name : {"bwrap", "proot"}) { // tool-ok: package names in the index auto match = catalog.resolve_target(name, "linux"); if (!match) return std::nullopt; // cannot say -> let it try auto pkg = catalog.load_package(*match); @@ -655,24 +328,179 @@ std::optional backend_target_unavailable_() { "E_UNSUPPORTED_TARGET: no sandbox backend has a {} artifact", target); } + int auto_install_backend_(const fs::path& home_dir, EventStream& stream) { - // Try bwrap first (build from source, config hook sets setuid) + // bwrap first; when it installs but its probe fails (user namespaces + // restricted), proot is the fallback. log::info("installing sandbox backend..."); - { - std::vector t = {"xim:bwrap"}; - xim::cmd_install(t, /*yes=*/true, /*noDeps=*/false, stream); - auto bin = locate_bwrap_(home_dir); - if (bin && probe_bwrap_(*bin).first) return 0; + auto home = subos::home_view(); + auto ports = subos::make_ports(stream); + (void)home_dir; + if (ports.install_backend("bwrap")) { + if (auto b = caps::payload_bwrap(home)) { + caps::probe_bwrap(*b); + if (b->usable) return 0; + } + } + return ports.install_backend("proot") ? 0 : 1; +} + +namespace { + +spec::Storage to_spec_storage_(StorageMode m) { + switch (m) { + case StorageMode::Image: return spec::Storage::Image; + case StorageMode::Tmpfs: return spec::Storage::Tmpfs; + default: return spec::Storage::Shared; + } +} + +std::map current_env_() { + return platform::environment(); +} + +// The refusal as the one ErrorEvent the entry reports. Wording kept from the +// code this replaced; the remedy is the compiler's. +void emit_refusal_(EventStream& stream, const spec::Refusal& refusal) { + const auto& u = refusal.missing.front(); + if (refusal.missing.size() == 1 && (u.dimension == "backend" || u.dimension == "storage")) { + stream.emit(ErrorEvent{ + .code = u.dimension == "storage" ? ErrorCode::InvalidInput : ErrorCode::NotFound, + .message = u.reason, + .recoverable = false, + .hint = u.fix.empty() ? std::string{} : "run: " + u.fix, + }); + return; + } + // The shared format for a requirement the host cannot meet (design §10): + // one line per missing item, and what to do about each. + std::string message = "cannot enter: the policy requires what this host cannot provide"; + std::string hint; + for (const auto& m : refusal.missing) { + message += std::format("\n \u2717 {}: {}", m.dimension, m.reason); + // Each remedy once: several items often share it. + if (!m.fix.empty() && hint.find(m.fix) == std::string::npos) + hint += (hint.empty() ? "" : "; ") + m.fix; + } + stream.emit(ErrorEvent{ .code = ErrorCode::InvalidInput, .message = message, + .recoverable = false, .hint = hint }); +} + +// `! not in effect: ` -- entered, with less than asked. +void report_degraded_(const spec::SandboxSpec& sb) { + for (const auto& d : sb.degraded) + log::warn("! {} not in effect: {}{}", d.dimension, d.reason, + d.fix.empty() ? std::string{} : " (" + d.fix + ")"); +} + + +// Where session-init (this binary) lives inside every sandbox. +constexpr std::string_view kSessionInitPath = "/run/xlings/xlings"; + +// What a sandbox must be compared on to decide whether a call may join the +// running session: everything that isolates, nothing that varies per call. +std::string spec_digest_(const spec::SandboxSpec& sb, const spec::Request& request, const policy::Policy& pol) { + auto stable = sb; + if (!request.root.empty()) { + std::erase_if(stable.mounts, [&](const spec::MountOp& mount) { + return std::ranges::find(request.root_mounts, mount) != request.root_mounts.end(); + }); + } + auto j = stable.describe(); + if (!request.root.empty()) { + j["root_scope"] = request.instance; + j["root_instance"] = request.instance_dir.generic_string(); + j["root_policy"] = policy::to_json(pol); } + for (auto k : {"argv", "env", "cwd", "degraded"}) j.erase(k); + return std::format("{:016x}", std::hash{}(j.dump())); +} - // bwrap installed but namespace restricted → proot fallback - // (hint already shown by detect_backend_ on first use) - std::vector t = {"xim:proot"}; - return xim::cmd_install(t, /*yes=*/true, /*noDeps=*/false, stream); +// observe >= standard: the host paths a sandbox can write, to list what +// changed in them when the session ends. +std::vector rw_mount_sources_(const policy::Policy& pol) { + std::vector out; + if (pol.observe == policy::Observe::Off || pol.observe == policy::Observe::Basic) return out; + for (auto& m : pol.mounts) if (m.rw) out.push_back(m.src); + return out; +} + +// This binary on the host: the broker runs what it allows with it. +// This binary, on every platform (/proc/self/exe is Linux's alone; macOS +// runs its home redirect's session-init from here too). +std::string host_exe_() { + auto exe = platform::get_executable_path(); + return exe.empty() ? std::string("xlings") : exe.string(); } +// What a brokered command runs with: this host environment, acting on this +// instance. Not the sandbox's markers -- it runs outside. +std::map broker_env_(std::map env, + const fs::path& home, const std::string& name) { + for (auto k : {"XLINGS_SUBOS_MODE", "XLINGS_PROJECT_DIR", "XLINGS_SESSION_FD"}) env.erase(k); + env["XLINGS_HOME"] = home.string(); + env["XLINGS_ACTIVE_SUBOS"] = name; + env["XLINGS_NON_INTERACTIVE"] = "1"; + return env; +} + +// This binary at kSessionInitPath, plus -- for a dynamically linked build -- +// its ELF interpreter's directory and its RUNPATH, read-only at their own +// paths. A static release binary needs neither. +std::vector self_exe_mounts_() { + std::vector out; + if constexpr (!platform::is_linux) return out; + std::error_code ec; + auto exe = fs::read_symlink("/proc/self/exe", ec); + if (ec) return out; + out.push_back({spec::MountKind::RoBind, exe.string(), std::string(kSessionInitPath)}); + auto info = elfread::read(exe); + if (!info) return out; + std::set dirs; + if (!info->interpreter.empty()) dirs.insert(fs::path(info->interpreter).parent_path().string()); + for (auto p : info->searchPaths) { + if (auto pos = p.find("$ORIGIN"); pos != std::string::npos) + p.replace(pos, 7, exe.parent_path().string()); + dirs.insert(p); + } + auto covered = [](const std::string& d) { + for (auto root : {"/usr", "/bin", "/lib", "/lib64"}) + if (d == root || d.starts_with(std::string(root) + "/")) return true; + return false; + }; + for (const auto& d : dirs) { + if (d.empty() || covered(d) || !fs::is_directory(d, ec)) continue; + out.push_back({spec::MountKind::RoBind, d, d}); + } + return out; +} + +} // namespace + int enter(const std::string& name, EventStream& stream, const std::string& preferred_backend, bool gpu, const std::string& cmd) { + return enter(name, stream, EnterOptions{ .backend = preferred_backend, .gpu = gpu, .cmd = cmd }); +} + +int enter(const std::string& name, EventStream& stream, const EnterOptions& opts) { + const auto& preferred_backend = opts.backend; + const bool gpu = opts.gpu; + const auto& cmd = opts.cmd; + // `subos exec` reports a failure before the command started as 125. + const int kFail = opts.exec_codes ? session::kExitSetup : 1; + + // A session that outlives its shell needs a session host (design §16, + // part 3 §6.3): Linux and macOS. On Windows the entry below is an + // interactive shell, the opposite of detached, and waits for input. + if (platform::is_windows && opts.detached) { + stream.emit(ErrorEvent{ + .code = ErrorCode::InvalidInput, + .message = "a detached session (subos start) needs a session host: Linux or macOS", + .recoverable = false, + .hint = std::format("run commands with `xlings subos exec {} -- `", name), + }); + return session::kExitSetup; + } // Refuse nested sandbox entry. if (utils::get_env_or_default("XLINGS_SUBOS_MODE") == "sandbox") { @@ -682,58 +510,124 @@ int enter(const std::string& name, EventStream& stream, const std::string& prefe .recoverable = true, .hint = "type 'exit' first to leave the current one", }); - return 1; + return kFail; } auto& p = Config::paths(); auto subos_dir = p.homeDir / "subos" / name; - - // ── Storage mode (V6) ── auto storage = read_storage_mode_(subos_dir); - // ── Resolve user ── -#if defined(_WIN32) - auto user = utils::get_env_or_default("USERNAME"); - if (user.empty()) user = "user"; -#else - auto user = utils::get_env_or_default("USER"); + auto user = utils::get_env_or_default(platform::is_windows ? "USERNAME" : "USER"); if (user.empty()) user = "user"; -#endif - auto sandbox_home = (subos_dir / "home" / user).string(); - auto sandbox_tmp = (subos_dir / "tmp").string(); - - // Image mountpoint (populated in the Linux block below if image mode) fs::path image_mountpoint; - if (storage == StorageMode::Image) - image_mountpoint = subos_dir / ".mountpoint"; + if (storage == StorageMode::Image) image_mountpoint = subos_dir / ".mountpoint"; - // ── Lazy init sandbox dirs (shared mode only) ── if (storage == StorageMode::Shared) { fs::create_directories(subos_dir / "home" / user); fs::create_directories(subos_dir / "tmp"); } + if constexpr (platform::is_posix) + if (storage == StorageMode::Shared) write_sandbox_rc_(subos_dir / "home" / user); + if constexpr (platform::is_windows) { + fs::create_directories(subos_dir / "home" / user / "AppData" / "Roaming"); + fs::create_directories(subos_dir / "home" / user / "AppData" / "Local"); + } -#if defined(__linux__) || defined(__APPLE__) - // Seed shell rc files so profile is sourced (prompt pill + PATH). - // For image mode, rc files go into the mounted image (deferred until - // after mount in the Linux block below). For shared/tmpfs, seed now. - if (storage == StorageMode::Shared) { - auto user_home_dir = subos_dir / "home" / user; - fs::create_directories(user_home_dir); - write_sandbox_rc_(user_home_dir); + const auto home = subos::home_view(); + const auto ports = subos::make_ports(stream); + + // A rootfs instance (design part 2 §3.1) is entered as its own root, + // always in a sandbox; with no isolation declared it is `dev`. Its + // projection is brought up to date first -- a no-op when nothing moved. + const auto kind = subos_root::read_kind(p.homeDir, name); + if (!kind) { + stream.emit(ErrorEvent{ .code = ErrorCode::InvalidInput, .message = kind.error(), + .recoverable = false }); + return kFail; + } + const bool rootfs = *kind == roles::Kind::Rootfs; + auto preset = opts.preset; + if (rootfs) { + if constexpr (!platform::is_linux) { + stream.emit(ErrorEvent{ + .code = ErrorCode::InvalidInput, + .message = "a rootfs SubOS is entered as a Linux root; this host is not Linux", + .recoverable = false, + .hint = std::format("on Windows: xlings subos export {} --wsl on a Linux " + "machine, then wsl --import", name), + }); + return kFail; + } + if (!preset && !policy_store::has_file(home, name)) preset = policy::Preset::Dev; + if (auto r = subos_root::refresh(p.homeDir, name, "enter"); r && !*r) { + stream.emit(ErrorEvent{ .code = ErrorCode::Internal, + .message = "the root of '" + name + "' could not be prepared: " + + r->error(), + .recoverable = false }); + return kFail; + } + } + + // What this instance may do: its policy file, a stricter preset named on + // this call, this call's (tighten-only) overrides. A policy that does not + // parse, or names what this version cannot enforce, refuses entry. + auto effective = policy_store::effective(home, name, preset, opts.overrides, Info::VERSION); + if (!effective) { + stream.emit(ErrorEvent{ .code = ErrorCode::InvalidInput, .message = effective.error(), + .recoverable = false }); + return kFail; + } + const auto pol = std::move(*effective); + if (policy::fetches_into_layer(pol) && !rootfs) { + stream.emit(ErrorEvent{ + .code = ErrorCode::InvalidInput, + .message = "fetch = layer installs into the root's own system scope, and '" + name + + "' is a view of the host with none", + .recoverable = false, + .hint = std::format("a root: xlings subos new --rootfs --from {}; or " + "xlings subos config {} --fetch auto|ask|deny", name, name), + }); + return kFail; + } + + struct RootViewLifetime { + std::shared_ptr view; + bool transferred { false }; + ~RootViewLifetime() { + if (view && !transferred) + view->close(); + } + } rootView; + + spec::Request request{ + .instance = name, + .instance_dir = subos_dir, + .user = user, + .storage = to_spec_storage_(storage), + .image_mountpoint = image_mountpoint, + .host_env = current_env_(), + }; + { + auto shell = utils::get_env_or_default("SHELL"); + request.shell = shell.empty() ? "/bin/sh" : shell; + } + if (preferred_backend == "bwrap") request.preferred = spec::Backend::Bwrap; + if (preferred_backend == "proot") request.preferred = spec::Backend::Proot; + if (preferred_backend == "landlock") request.preferred = spec::Backend::Landlock; + if (gpu) request.grants.insert("gpu"); + if constexpr (platform::is_posix) request.interactive = platform::stdin_is_terminal(); + if (!cmd.empty()) request.argv = {request.shell, "-c", cmd}; + else if (!opts.argv.empty()) request.argv = opts.argv; + request.publish = opts.publish; + request.explicit_env = opts.env; + request.cwd = opts.cwd; + if (opts.detached) request.interactive = false; + if (rootfs) { + request.root = subos_root::tree_of(p.homeDir, name); + if (!cmd.empty()) request.argv = {"/bin/sh", "-c", cmd}; + request.shell = "/bin/sh"; } -#endif - - // ── Common env (all platforms) ── - platform::set_env_variable("XLINGS_ACTIVE_SUBOS", name); - platform::set_env_variable("XLINGS_SUBOS_MODE", "sandbox"); - // E2a, same declaration as the non-sandbox path. Declared here too and - // not only there: a contract that holds in one of the two ways users - // enter a subos is a contract consumers cannot rely on, and the sandbox - // is the mode the graphics acceptance tooling runs in. - platform::set_env_variable("XLINGS_SUBOS_LIB", - (subos_dir / "lib").string()); nlohmann::json payload; payload["name"] = name; @@ -742,109 +636,81 @@ int enter(const std::string& name, EventStream& stream, const std::string& prefe std::cout.flush(); std::cerr.flush(); -#if defined(__linux__) - // ═══════════════════════════════════════════════════════════════ - // Linux L3: FS view isolation via bwrap/proot - // ═══════════════════════════════════════════════════════════════ - - // image/tmpfs storage requires bwrap (mount namespace needed) - if (storage != StorageMode::Shared && !preferred_backend.empty() - && preferred_backend == "proot") { - stream.emit(ErrorEvent{ - .code = ErrorCode::InvalidInput, - .message = "image/tmpfs storage requires bwrap sandbox backend", - .recoverable = false, - .hint = "run: xlings install bwrap", - }); - return 1; - } - - // ── Image mode: mount image before sandbox entry ── - if (storage == StorageMode::Image) { - auto img = subos_dir / "home.img"; - if (!fs::exists(img)) { + // Under a supervisor wherever there is a session host (Linux, macOS): + // join, --timeout, exit codes and the audit are the same (part 3 §6.3). + if constexpr (platform::is_posix) { + // image/tmpfs storage requires bwrap (mount namespace needed) + if (storage != StorageMode::Shared && preferred_backend == "proot") { stream.emit(ErrorEvent{ - .code = ErrorCode::NotFound, - .message = "home.img not found — was this subos created with --storage image?", + .code = ErrorCode::InvalidInput, + .message = "image/tmpfs storage requires bwrap sandbox backend", .recoverable = false, + .hint = "run: xlings install bwrap", }); - return 1; + return kFail; } - auto rc = mount_image_(img, image_mountpoint, user); - if (rc != 0) { - stream.emit(ErrorEvent{ - .code = ErrorCode::Internal, - .message = "failed to mount home.img", - .recoverable = false, - .hint = "ensure you have sudo permission for mount", - }); - return 1; + + if (storage == StorageMode::Image) { + auto img = subos_dir / "home.img"; + if (!fs::exists(img)) { + stream.emit(ErrorEvent{ + .code = ErrorCode::NotFound, + .message = "home.img not found — was this subos created with --storage image?", + .recoverable = false, + }); + return kFail; + } + if (mount_image_(img, image_mountpoint, user) != 0) { + stream.emit(ErrorEvent{ + .code = ErrorCode::Internal, + .message = "failed to mount home.img", + .recoverable = false, + .hint = "ensure you have sudo permission for mount", + }); + return kFail; + } } - } - // /etc templates for Linux sandbox (NSS) - { - auto uid = ::getuid(); - auto gid = ::getgid(); - init_sandbox_dirs_(subos_dir, user, uid, gid); - // For image mode, init home and seed shell rc files inside the mount + init_sandbox_dirs_(subos_dir, user, platform::user_ids().uid, platform::user_ids().gid); + // The read-only home's tmpfs mount points must exist on the host: bwrap + // cannot create a directory inside a read-only bind. + for (auto dir : {"logs", "run", "state", "subos"}) fs::create_directories(p.homeDir / dir); + // A neutral identity's user and its passwd/group (spec: etc-neutral/). + if (pol.identity == policy::Identity::Neutral) + init_sandbox_dirs_(subos_dir, "user", platform::user_ids().uid, platform::user_ids().gid, "etc-neutral"); if (storage == StorageMode::Image) { auto mp_home = image_mountpoint / user; fs::create_directories(mp_home); write_sandbox_rc_(mp_home); } - } - // Backend selection (the using-declarations that stood here named - // sandbox_detail_:: and are redundant now that this IS that namespace) - std::optional backend; - - if (preferred_backend == "bwrap") { - auto bin = locate_bwrap_(p.homeDir); - if (!bin) { - stream.emit(ErrorEvent{ - .code = ErrorCode::NotFound, - .message = "bwrap not installed", - .recoverable = false, - .hint = "run: xlings install bwrap", - }); - return 1; - } - if (auto probe = probe_bwrap_(*bin); !probe.first) { - stream.emit(ErrorEvent{ - .code = ErrorCode::NotFound, - .message = "bwrap probe failed", - .recoverable = false, - .hint = classify_bwrap_probe_error_( - probe.second, *bin), - }); - return 1; - } - backend = BackendInfo{ SandboxBackend::Bwrap, *bin }; - } else if (preferred_backend == "proot") { - auto bin = locate_proot_(p.homeDir); - if (!bin) { - stream.emit(ErrorEvent{ - .code = ErrorCode::NotFound, - .message = std::move(bin).error(), - .recoverable = false, - .hint = "run: xlings install proot", - }); - return 1; + if (rootfs) { + auto scope = subos_root::store_closure::read_scope(p.homeDir, name, request.root); + if (!scope) { + stream.emit(ErrorEvent{.code = ErrorCode::InvalidInput, .message = scope.error(), + .recoverable = false}); + return kFail; + } + auto prepared = subos_root::root_view::prepare(*scope); + if (!prepared) { + stream.emit(ErrorEvent{.code = ErrorCode::InvalidInput, .message = prepared.error(), + .recoverable = false}); + return kFail; + } + rootView.view = std::move(*prepared); + for (const auto& binding : rootView.view->bindings()) + request.root_mounts.push_back({spec::MountKind::RoBind, binding.source.generic_string(), + binding.destination.generic_string()}); } - backend = BackendInfo{ SandboxBackend::Proot, *bin }; - } else { - backend = detect_backend_(p.homeDir, subos_dir); - if (!backend) { + + auto host_caps = caps::probe(home, ports); + auto compiled = xlings::confine::compile(pol, home, host_caps, request); + + // Nothing usable and nothing asked for: fetch a backend, once. + if (!compiled && !request.preferred && compiled.error().missing.front().dimension == "backend") { // Ask the index whether it ships a backend for this target before - // touching the network, so an unsupported host gets one causal - // error and zero download requests. - // - // This used to be `#if defined(__aarch64__)`, which is a claim - // about the package index frozen into the binary at compile time: - // the day xim-pkgindex publishes an aarch64 bwrap, every client - // already in the field would still refuse. Reading the on-disk - // index costs nothing and is right both before and after that day. + // touching the network, so an unsupported host gets one causal error + // and zero download requests. if (auto unavailable = backend_target_unavailable_(); unavailable) { stream.emit(ErrorEvent{ .code = ErrorCode::InvalidInput, @@ -854,207 +720,475 @@ int enter(const std::string& name, EventStream& stream, const std::string& prefe "bubblewrap package, or `xlings install bwrap` to " "try anyway", }); - return 1; + if (storage == StorageMode::Image) unmount_image_(image_mountpoint); + return kFail; } - auto rc = auto_install_backend_(p.homeDir, stream); - if (rc != 0) { + if (auto_install_backend_(p.homeDir, stream) != 0) { stream.emit(ErrorEvent{ .code = ErrorCode::NotFound, .message = "failed to install sandbox backend", .recoverable = false, .hint = "manually: xlings install bwrap (or: xlings install proot)", }); - return 1; + if (storage == StorageMode::Image) unmount_image_(image_mountpoint); + return kFail; } - backend = detect_backend_(p.homeDir, subos_dir); - if (!backend) { + host_caps = caps::probe(home, ports); + compiled = xlings::confine::compile(pol, home, host_caps, request); + if (!compiled && compiled.error().missing.front().dimension == "backend") { stream.emit(ErrorEvent{ .code = ErrorCode::NotFound, .message = "no sandbox backend available after install attempt", .recoverable = false, }); - return 1; + if (storage == StorageMode::Image) unmount_image_(image_mountpoint); + return kFail; } } - } + if (!compiled) { + auto refusal = compiled.error(); + // A bwrap that is installed and fails its probe: the probe's own + // words, classified, instead of a generic "install bwrap". + if (host_caps.bwrap && !host_caps.bwrap->usable + && (refusal.missing.front().dimension == "storage" + || request.preferred == spec::Backend::Bwrap)) { + auto& u = refusal.missing.front(); + if (request.preferred == spec::Backend::Bwrap) u.reason = "bwrap probe failed"; + u.fix.clear(); + stream.emit(ErrorEvent{ + .code = u.dimension == "storage" ? ErrorCode::InvalidInput : ErrorCode::NotFound, + .message = u.reason, + .recoverable = false, + .hint = classify_bwrap_probe_error_(host_caps.bwrap->probe_output, + host_caps.bwrap->bin), + }); + } else { + emit_refusal_(stream, refusal); + } + if (storage == StorageMode::Image) unmount_image_(image_mountpoint); + return kFail; + } + if (compiled->backend == spec::Backend::HomeRedirect) { + // macOS: the home redirect, now under the same supervisor. Say + // what it is where the person is: a dotfile redirect -- no + // filesystem, network or process boundary (the carrier is). + if (cmd.empty()) + log::warn("sandbox on macOS redirects the home directory only -- it does not contain the " + "filesystem, network or processes. A boundary: --carrier vz, or a VM."); + if (compiled->argv.empty()) { + compiled->argv = {platform::resolve_shell()}; + if (request.interactive) compiled->argv.push_back("-i"); + } + } + const auto& sb = *compiled; + auto trace_spec = sb.describe(); + trace_spec.erase("argv"); + trace_spec["argc"] = sb.argv.size(); + observe::trace("spec", trace_spec.dump()); + if (pol.preset != policy::Preset::Legacy) report_degraded_(sb); + // proot because bwrap is there and cannot make a sandbox (Ubuntu 24.04's + // AppArmor restriction, most often): never silently. Asked for by name, + // it is the user's choice and goes unremarked. + if (sb.backend == spec::Backend::Proot && !request.preferred && host_caps.bwrap + && !host_caps.bwrap->usable) { + auto why = host_caps.bwrap->probe_output; + if (auto nl = why.find('\n'); nl != std::string::npos) why.resize(nl); + log::warn("bwrap cannot make a sandbox here ({}); entering with proot -- a view, " + "not a security boundary. A real sandbox: xlings self doctor --isolation --fix", + why.empty() ? "probe failed" : why); + } + if (sb.backend == spec::Backend::Proot && host_caps.proot && host_caps.proot->source == "system") { + log::warn("using the host's proot ({}) -- no proot payload in {}. " + "Run `xlings install proot` to make this deterministic.", + sb.backend_bin.string(), p.homeDir.string()); + } + + const auto backend_name = std::string(spec::to_string(sb.backend)); + log::debug("sandbox backend: {} storage: {}", backend_name, storage_to_string_(storage)); + const auto digest = spec_digest_(sb, request, pol); - // image/tmpfs requires bwrap — reject proot after auto-detect - if (storage != StorageMode::Shared - && backend->type != SandboxBackend::Bwrap) { - // If bwrap is actually installed but probe failed (the common - // case that triggers the proot fallback), surface the real - // probe error rather than the misleading "xlings install bwrap" - // suggestion that won't help. - std::string hint = "run: xlings install bwrap"; - if (auto bin = locate_bwrap_(p.homeDir)) { - if (auto probe = probe_bwrap_(*bin); - !probe.first) - { - hint = classify_bwrap_probe_error_( - probe.second, *bin); + // One session per instance (design §12.1): a running one is JOINED -- + // same /tmp, processes and network as the terminal that started it. + if (auto live = session::find(home, name)) { + if (storage == StorageMode::Image) unmount_image_(image_mountpoint); + if (live->digest != digest) { + stream.emit(ErrorEvent{ + .code = ErrorCode::InvalidInput, + .message = std::format("'{}' is running with a different isolation; this " + "call would not get what it asked for", name), + .recoverable = true, + .hint = std::format("xlings subos stop {}", name), + }); + return session::kExitSetup; } + payload["backend"] = backend_name; + payload["joined"] = live->id; + if (opts.announce) stream.emit(DataEvent{"subos_entering", payload.dump()}); + std::fflush(nullptr); + if (request.interactive && cmd.empty()) + log::info("joined the running session of '{}' (job control stays with the " + "terminal that started it)", name); + auto r = session::join(home, name, session::ExecRequest{ + .argv = sb.argv, .env = request.host_env, .tty = request.interactive }); + if (!r.phase.empty() && r.phase == "setup") + log::error("{}", r.error); + return r.exit_code; } - stream.emit(ErrorEvent{ - .code = ErrorCode::InvalidInput, - .message = storage_to_string_(storage) - + " storage requires bwrap (proot does not support mount namespace)", - .recoverable = false, - .hint = std::move(hint), - }); - if (storage == StorageMode::Image) - unmount_image_(image_mountpoint); - return 1; - } - auto backend_name = (backend->type == SandboxBackend::Bwrap) ? "bwrap" : "proot"; - log::debug("sandbox backend: {} storage: {}", backend_name, - storage_to_string_(storage)); - - auto user_home = "/home/" + user; - auto shell = utils::get_env_or_default("SHELL"); - if (shell.empty()) shell = "/bin/sh"; - - payload["backend"] = backend_name; - payload["shell"] = shell; - payload["storage"] = storage_to_string_(storage); - stream.emit(DataEvent{"subos_entering", payload.dump()}); - - platform::set_env_variable("HOME", user_home); - // Pin the home explicitly rather than relying on inheritance: it is - // reachable at its own absolute path in here (see sandbox_binds_), and - // every path baked at install time — xvm targets, RPATH, INTERP — - // assumes exactly that spelling. - platform::set_env_variable("XLINGS_HOME", p.homeDir.string()); - // Same spelling as XLINGS_HOME above. A shim decides which home owns it - // by comparing the path it was invoked through against XLINGS_HOME as - // strings; spelling the home two ways here makes it warn about a - // conflict with itself. - platform::set_env_variable("PATH", std::format( - "{0}/subos/{1}/bin:{0}/bin:/usr/local/bin:/usr/bin:/bin", - p.homeDir.string(), name)); - // bwrap `sh -i` prints prompts/job-control warnings into piped CI - // commands; keep `-i` only for real terminal sessions. - const bool interactive_shell = ::isatty(STDIN_FILENO) == 1; - - std::vector argv; - if (backend->type == SandboxBackend::Bwrap) { - argv = build_bwrap_argv_( - backend->binary, subos_dir, p.homeDir, user, shell, - interactive_shell, storage, image_mountpoint, gpu, cmd); - } else { - // proot already exposes the full host /dev and /sys via - // `--bind=/dev:/dev --bind=/sys:/sys`, so GPU devices are - // visible for free. --gpu is a no-op here. - argv = build_proot_argv_( - backend->binary, subos_dir, p.homeDir, user, shell, cmd); - platform::set_env_variable("PROOT_NO_SECCOMP", "1"); - } + payload["backend"] = backend_name; + payload["shell"] = request.shell; + payload["storage"] = storage_to_string_(storage); + if (opts.announce) stream.emit(DataEvent{"subos_entering", payload.dump()}); + std::fflush(nullptr); + + // The terminal-injection filter travels to bwrap on an inherited pipe + // (`--seccomp `); bwrap reads it and installs it for the command. + std::vector keep_fds; + std::optional seccomp_fd; + if (sb.backend == spec::Backend::Bwrap && sb.block_tiocsti) { + auto program = platform::seccomp::block_terminal_injection(); + if (auto fds = platform::make_pipe(); !program.empty() && fds) { + (void)platform::write_fd((*fds)[1], std::string_view( + reinterpret_cast(program.data()), program.size())); + platform::close_fd((*fds)[1]); + seccomp_fd = (*fds)[0]; + keep_fds.push_back((*fds)[0]); + } + } - std::vector c_argv; - c_argv.reserve(argv.size() + 1); - for (auto& s : argv) c_argv.push_back(const_cast(s.c_str())); - c_argv.push_back(nullptr); - - // Image mode: fork+exec+wait so we can unmount after sandbox exits. - // Shared/tmpfs: execvp replaces the process (no cleanup needed). - if (storage == StorageMode::Image) { - auto pid = ::fork(); - if (pid < 0) { - log::error("fork failed: {}", std::strerror(errno)); - unmount_image_(image_mountpoint); - return 1; + // session-init is this very binary, read-only at a fixed path inside; a + // dynamically linked build brings its loader and library directories. + auto launched = sb; + for (auto& mount : self_exe_mounts_()) { + if (rootfs && mount.dst != kSessionInitPath) { + const auto& bindings = rootView.view->bindings(); + const auto path = fs::path(mount.src).lexically_normal(); + const bool permitted = std::ranges::any_of(bindings, [&](const auto& binding) { + if (binding.source != binding.destination) + return false; + const auto relative = path.lexically_relative(binding.source); + return path == binding.source || (!relative.empty() && !relative.is_absolute() && + *relative.begin() != ".."); + }); + if (!permitted) { + stream.emit(ErrorEvent{.code = ErrorCode::InvalidInput, + .message = "the session client needs a runtime directory outside this root's checked closure: " + mount.src, + .recoverable = false, + .hint = "use a static release client, or install its recorded runtime closure into this root"}); + return kFail; + } + continue; + } + launched.mounts.push_back(std::move(mount)); + } + // The broker's socket, made by the supervisor before the backend starts. + const bool brokered = sb.backend == spec::Backend::Bwrap || sb.backend == spec::Backend::Landlock; + if (sb.backend == spec::Backend::Bwrap) + launched.mounts.push_back({spec::MountKind::Bind, home.broker_socket(name).string(), + std::string(broker::kSocketInside)}); + launched.argv = {std::string(kSessionInitPath), "__session-init"}; + if (!opts.detached) { + // A detached session has no main command: it idles until its TTL or + // `subos stop`, and everything in it joins. + launched.argv.push_back("--"); + launched.argv.insert(launched.argv.end(), sb.argv.begin(), sb.argv.end()); + } + // The implementation's command (xlings.confine): bwrap's or proot's + // argv around session-init, or -- Landlock, no view to mount it into + // -- this binary where it is, fencing itself before it starts anything. + const auto argv = xlings::confine::launch_argv(launched, seccomp_fd, host_exe_()); + + if (observe::trace_enabled("provider")) { + std::string line; + line = std::format("{} ({} arguments)", backend_name, argv.size()); + observe::trace("provider", line); + } + std::vector pass(policy::kBaseEnvPass.begin(), policy::kBaseEnvPass.end()); + pass.insert(pass.end(), pol.env_pass.begin(), pol.env_pass.end()); + // The supervisor hosts the session: the sandbox's owner stays outside it, + // watching, and the audit is written where the sandbox cannot reach (F15). + const int rc = session::host(home, session::Launch{ + .instance = name, + .argv = std::move(argv), + .env = xlings::confine::provider::process_env(sb, request.host_env), + .keep_fds = keep_fds, + .backend = backend_name, + .digest = digest, + .spec = sb.describe(), + .audit_required = pol.preset == policy::Preset::Locked, + .exec_env = sb.env, + .env_pass = std::move(pass), + .default_cwd = sb.cwd.string(), + .ttl = opts.ttl, + .detached = opts.detached, + .timeout = opts.timeout, + .pasta = sb.net_nat ? xlings::confine::provider::pasta_args(sb) : std::vector{}, + .proxy_url = sb.net_proxy ? sb.proxy_url : std::string{}, + .trace_net = (sb.net_nat || sb.net_proxy) && (pol.observe == policy::Observe::Standard || pol.observe == policy::Observe::Full), + .broker_policy = brokered ? std::optional(pol) : std::nullopt, + .broker_exe = { host_exe_() }, + .broker_env = broker_env_(request.host_env, p.homeDir, name), + .trace_exec = pol.observe == policy::Observe::Full && sb.backend == spec::Backend::Bwrap, + .rw_paths = rw_mount_sources_(pol), + .refresh_root = rootView.view + ? std::function(std::span)>{ + [view = rootView.view](std::span target) { return view->refresh(target); }} + : std::function(std::span)>{}, + .finalize_root = rootView.view ? std::function{[view = rootView.view] { view->close(); }} + : std::function{}, + }); + if (opts.detached && rc == 0) + rootView.transferred = true; + if (storage == StorageMode::Image) unmount_image_(image_mountpoint); + return rc; + } else { + // Windows: the home redirect (dotfile isolation), its command in a + // Job Object -- the whole tree ends with it, or at --timeout (124). + auto host_caps = caps::probe(home, ports); + auto compiled = xlings::confine::compile(pol, home, host_caps, request); + if (!compiled) { + emit_refusal_(stream, compiled.error()); + return kFail; } - if (pid == 0) { - // Child: enter sandbox - ::execvp(c_argv[0], c_argv.data()); - log::error("failed to exec {}: {}", backend_name, std::strerror(errno)); - ::_exit(127); + // Say it where the person is, not only in the README. A user who reaches + // for `--sandbox` to run something they do not trust is exactly the user + // who did not read the isolation matrix, and on these two platforms this + // is a dotfile redirect -- no filesystem, network or process boundary. + // + // Only on interactive entry: a `--cmd` run is a script, and a warning it + // emits on every invocation is noise nobody reads. `--quiet` silences it. + if (cmd.empty()) { + log::warn("sandbox on Windows redirects the home directory only -- " + "it does not contain the filesystem, network or processes. " + "A boundary: --carrier wsl2."); } - // Parent: wait then unmount - int status = 0; - ::waitpid(pid, &status, 0); - unmount_image_(image_mountpoint); - return WIFEXITED(status) ? WEXITSTATUS(status) : 1; + if (pol.preset != policy::Preset::Legacy) report_degraded_(*compiled); + payload["backend"] = "home-redirect"; + if (opts.announce) stream.emit(DataEvent{"subos_entering", payload.dump()}); + std::fflush(nullptr); + for (const auto& [k, v] : compiled->env) platform::set_env_variable(k, v); + // `subos exec -- argv`: the argv, not a shell (which, with no command, + // is an interactive one -- and waits for input). + if (!opts.argv.empty()) { + std::error_code ec; + fs::current_path(opts.cwd.empty() ? compiled->cwd : fs::path(opts.cwd), ec); + return platform::run_argv_with_timeout(opts.argv, opts.timeout.value_or(std::chrono::milliseconds::max())); + } + return platform::run_shell(cmd, cmd.empty()); } +} - ::execvp(c_argv[0], c_argv.data()); - log::error("failed to exec {} '{}': {}", backend_name, - backend->binary.string(), std::strerror(errno)); - return 127; - -#elif defined(__APPLE__) - // ═══════════════════════════════════════════════════════════════ - // macOS L2: HOME redirect (dotfile isolation) - // ═══════════════════════════════════════════════════════════════ - - // Ask the same resolver the launch is about to use. Deriving the reported - // shell here independently is how `subos_entering` came to announce - // `/bin/zsh` while the process that started was `/bin/sh`: two fallbacks - // for the same question, in two places, that only agree when SHELL is set. - // Say it where the person is, not only in the README. A user who reaches - // for `--sandbox` to run something they do not trust is exactly the user - // who did not read the isolation matrix, and on these two platforms this - // is a dotfile redirect -- no filesystem, network or process boundary. - // - // Only on interactive entry: a `--cmd` run is a script, and a warning it - // emits on every invocation is noise nobody reads. `--quiet` silences it. - if (cmd.empty()) { - log::warn("sandbox on {} redirects the home directory only -- " - "it does not contain the filesystem, network or processes. " - "Use an OS sandbox or a VM for untrusted code.", - "macOS"); +nlohmann::json preview(const std::string& name, const policy::Policy& pol, EventStream& stream) { + const auto home = subos::home_view(); + const auto ports = subos::make_ports(stream); + auto user = utils::get_env_or_default(platform::OS_NAME == "windows" ? "USERNAME" : "USER"); + if (user.empty()) user = "user"; + const auto dir = Config::paths().homeDir / "subos" / name; + spec::Request request{ .instance = name, .instance_dir = dir, .user = user, + .storage = to_spec_storage_(read_storage_mode_(dir)) }; + const auto host_caps = caps::probe(home, ports); + nlohmann::json out; + auto compiled = xlings::confine::compile(pol, home, host_caps, request); + if (compiled) { + out["enters"] = true; + out["spec"] = compiled->describe(); + } else { + out["enters"] = false; + out["missing"] = nlohmann::json::array(); + for (auto& m : compiled.error().missing) + out["missing"].push_back({{"dimension", m.dimension}, {"reason", m.reason}, {"fix", m.fix}}); } + out["gates"] = nlohmann::json::array(); + for (auto& g : xlings::confine::gates::probe(host_caps)) + out["gates"].push_back({{"gate", g.gate}, {"supported", g.supported}, + {"enforced", std::string(xlings::confine::gates::to_string(g.enforced))}, + {"reason", g.reason}, {"route", g.route}}); + return out; +} - payload["backend"] = "home-redirect"; - payload["shell"] = platform::resolve_shell(); - stream.emit(DataEvent{"subos_entering", payload.dump()}); - - platform::set_env_variable("HOME", sandbox_home); - platform::set_env_variable("TMPDIR", sandbox_tmp); - platform::set_env_variable("XDG_CONFIG_HOME", sandbox_home + "/.config"); - platform::set_env_variable("XDG_DATA_HOME", sandbox_home + "/.local/share"); - platform::set_env_variable("XDG_CACHE_HOME", sandbox_home + "/.cache"); - platform::set_env_variable("XDG_STATE_HOME", sandbox_home + "/.local/state"); - - return platform::run_shell(cmd, cmd.empty()); - -#elif defined(_WIN32) - // ═══════════════════════════════════════════════════════════════ - // Windows L2: USERPROFILE redirect (dotfile isolation) - // ═══════════════════════════════════════════════════════════════ - - // Pre-create AppData structure - fs::create_directories(fs::path(sandbox_home) / "AppData" / "Roaming"); - fs::create_directories(fs::path(sandbox_home) / "AppData" / "Local"); - // Say it where the person is, not only in the README. A user who reaches - // for `--sandbox` to run something they do not trust is exactly the user - // who did not read the isolation matrix, and on these two platforms this - // is a dotfile redirect -- no filesystem, network or process boundary. - // - // Only on interactive entry: a `--cmd` run is a script, and a warning it - // emits on every invocation is noise nobody reads. `--quiet` silences it. - if (cmd.empty()) { - log::warn("sandbox on {} redirects the home directory only -- " - "it does not contain the filesystem, network or processes. " - "Use an OS sandbox or a VM for untrusted code.", - "Windows"); +// `xlings self doctor --isolation [--fix]` (design §18, §20; #640 F10, F12). +// +// What this host can isolate with, measured: each bwrap found and what its +// probe said, the kernel's user-namespace settings, pasta, the platform +// interfaces. --fix does the one repair that needs root, and only that: a +// root-owned copy of a bwrap at /usr/lib/xlings/bwrap with an AppArmor +// profile that grants it user namespaces and nothing else -- never turning +// the restriction off for the machine. Every command it runs is printed. +int doctor_isolation(bool fix, bool yes, bool json, EventStream& stream) { + const auto home = subos::home_view(); + const auto ports = subos::make_ports(stream); + auto read_sysctl = [](const char* path) -> std::string { + std::ifstream in(path); + std::string v; + std::getline(in, v); + return v.empty() ? std::string("(absent)") : v; + }; + nlohmann::json report; + report["platform"] = std::string(caps::platform_name()); + std::vector candidates; + if constexpr (platform::is_linux) { + report["sysctl"] = { + {"kernel.apparmor_restrict_unprivileged_userns", + read_sysctl("/proc/sys/kernel/apparmor_restrict_unprivileged_userns")}, + {"kernel.unprivileged_userns_clone", read_sysctl("/proc/sys/kernel/unprivileged_userns_clone")}, + {"user.max_user_namespaces", read_sysctl("/proc/sys/user/max_user_namespaces")}, + }; + candidates = caps::bwrap_candidates(home, ports, /*fresh=*/true); + report["bwrap"] = nlohmann::json::array(); + for (auto& b : candidates) { + auto first = b.probe_output.substr(0, b.probe_output.find('\n')); + report["bwrap"].push_back({{"path", b.bin.string()}, {"source", b.source}, {"usable", b.usable}, + {"probe", b.usable ? std::string("ok") : first}}); + } + } + // Where every external tool would come from (xlings.subos.tools): the + // one table, its answer on this machine, and what brings a missing one. + report["tools"] = nlohmann::json::array(); + for (const auto& tool : subos::tools::known()) { + auto found = subos::tools::first(tool.name, home, ports); + report["tools"].push_back(found + ? nlohmann::json{{"name", tool.name}, {"path", found->bin.string()}, + {"source", subos::tools::to_string(found->source)}} + : nlohmann::json{{"name", tool.name}, {"path", nullptr}, + {"install", subos::tools::install_hint(tool.name)}}); // tool-ok: a JSON key + } + // Where a SubOS can run on this machine (design part 3 §5): each carrier + // this platform has, measured. + report["carriers"] = nlohmann::json::array(); + for (const auto name : carrier::carriers_of(caps::platform_name())) { + const auto* c = carrier::find(name); + const auto p = c ? c->probe(home) : carrier::Probe{.reason = "not in this build"}; + report["carriers"].push_back({{"name", name}, {"supported", p.supported}, {"reason", p.reason}, + {"route", p.route}, {"evidence", p.evidence}}); + } + auto host_caps = caps::probe(home, ports); + report["backend"] = host_caps.bwrap && host_caps.bwrap->usable + ? nlohmann::json{{"name", "bwrap"}, {"path", host_caps.bwrap->bin.string()}, {"source", host_caps.bwrap->source}} + : host_caps.proot ? nlohmann::json{{"name", "proot"}, {"path", host_caps.proot->bin.string()}} + : nlohmann::json(nullptr); + report["pasta"] = host_caps.pasta ? nlohmann::json(host_caps.pasta->string()) + : nlohmann::json(host_caps.pasta_missing); + report["gates"] = nlohmann::json::array(); + for (auto& g : xlings::confine::gates::probe(host_caps)) + report["gates"].push_back({{"gate", g.gate}, {"supported", g.supported}, + {"enforced", std::string(xlings::confine::gates::to_string(g.enforced))}, {"reason", g.reason}}); + const bool ok = host_caps.platform != "linux" || (host_caps.bwrap && host_caps.bwrap->usable); + report["ok"] = ok; + + auto print = [&] { + if (json) { std::println(std::cout, "{}", report.dump()); return; } + std::println(std::cout, "isolation on this host ({})", report["platform"].get()); + if (report.contains("sysctl")) + for (auto it = report["sysctl"].begin(); it != report["sysctl"].end(); ++it) + std::println(std::cout, " {:<46} {}", it.key(), it.value().get()); + if (report.contains("bwrap")) { + if (report["bwrap"].empty()) std::println(std::cout, " bwrap: none found"); + for (auto& b : report["bwrap"]) + std::println(std::cout, " bwrap {:<10} {} -- {}", b["source"].get(), + b["path"].get(), b["probe"].get()); + } + std::println(std::cout, " backend: {}", report["backend"].is_null() ? std::string("none") + : report["backend"]["name"].get() + " " + report["backend"]["path"].get()); + std::println(std::cout, " pasta (net=nat): {}", report["pasta"].get()); + for (auto& g : report["gates"]) + std::println(std::cout, " {:<14} {}", g["gate"].get(), + g["supported"].get() ? g["enforced"].get() : "no -- " + g["reason"].get()); + }; + print(); + if (ok || !fix) { + if (!ok && !json) + std::println(std::cout, "\n fix: xlings self doctor --isolation --fix " + "(one sudo: a root-owned bwrap with a narrow AppArmor profile)"); + return ok ? 0 : 1; } - payload["backend"] = "home-redirect"; - stream.emit(DataEvent{"subos_entering", payload.dump()}); - - platform::set_env_variable("USERPROFILE", sandbox_home); - platform::set_env_variable("APPDATA", sandbox_home + "\\AppData\\Roaming"); - platform::set_env_variable("LOCALAPPDATA", sandbox_home + "\\AppData\\Local"); - platform::set_env_variable("TEMP", sandbox_tmp); - platform::set_env_variable("TMP", sandbox_tmp); - platform::set_env_variable("XDG_CONFIG_HOME", sandbox_home + "\\.config"); - platform::set_env_variable("XDG_DATA_HOME", sandbox_home + "\\.local\\share"); - platform::set_env_variable("XDG_CACHE_HOME", sandbox_home + "\\.cache"); - - return platform::run_shell(cmd, cmd.empty()); -#endif + if constexpr (platform::is_linux) { + // The repair: only for the case it fixes -- AppArmor restricting + // unprivileged user namespaces for unconfined programs. + if (read_sysctl("/proc/sys/kernel/apparmor_restrict_unprivileged_userns") != "1") { + stream.emit(ErrorEvent{ .code = ErrorCode::InvalidInput, + .message = "user namespaces are not restricted by AppArmor here; nothing this repair " + "changes would help", + .recoverable = false, + .hint = "the kernel disables them outright (see the sysctl values above); " + "--sandbox proot works without them" }); + return 1; + } + std::optional source; + for (auto& b : candidates) if (b.source != "root-owned") { source = b.bin; break; } + if (!source) { + stream.emit(ErrorEvent{ .code = ErrorCode::NotFound, + .message = "no bwrap to install", .recoverable = true, + .hint = "xlings install bwrap, then run this again" }); + return 1; + } + const std::string profile = + "# xlings: grant user namespaces to the root-owned bwrap xlings uses for SubOS\n" + "# sandboxes (xlings self doctor --isolation --fix). Nothing else.\n" + "abi ,\n" + "include \n\n" + "profile xlings-bwrap /usr/lib/xlings/bwrap flags=(unconfined) {\n" + " userns,\n\n" + " include if exists \n" + "}\n"; + const auto tmp = fs::temp_directory_path() / std::format("xlings-bwrap-profile-{}", platform::get_pid()); + platform::write_string_to_file(tmp.string(), profile); + // Each step runs through elevation::run below (tool-ok on each line). + const std::vector> steps{ + {"install", "-D", "-o", "root", "-g", "root", "-m", "0755", source->string(), // tool-ok: elevated below + std::string(caps::kRootOwnedBwrap)}, + {"install", "-D", "-o", "root", "-g", "root", "-m", "0644", tmp.string(), // tool-ok: elevated below + "/etc/apparmor.d/xlings-bwrap"}, + {"apparmor_parser", "-r", "/etc/apparmor.d/xlings-bwrap"}, // tool-ok: elevated below + }; + auto spelled = [](const std::vector& argv) { + std::string line; + for (const auto& a : argv) line += (line.empty() ? "" : " ") + platform::shell_quote(a); + return line; + }; + std::string plan = "this runs, as root:"; + for (auto& c : steps) plan += "\n " + spelled(c); + log::info("{}", plan); + auto asked = confirm::ask(stream, "self.doctor.isolation.fix", + "install the root-owned bwrap and its AppArmor profile?", yes, "-y"); + if (asked.outcome != confirm::Outcome::Confirmed) { + std::error_code ec; + fs::remove(tmp, ec); + if (asked.outcome == confirm::Outcome::NobodyToAsk) { + log::error("nothing changed: this needs confirmation -- re-run with -y"); + return 2; + } + log::info("nothing changed"); + return 1; + } + for (auto& c : steps) { + log::info("$ {}", spelled(c)); + if (subos::elevation::run(home, c, "self doctor --isolation --fix") != 0) { + std::error_code ec; + fs::remove(tmp, ec); + log::error("failed: {}", spelled(c)); + return 1; + } + } + std::error_code ec; + fs::remove(tmp, ec); + auto after = caps::locate_bwrap(home, ports); + if (after && after->usable && after->source == "root-owned") { + log::info("sandboxes now use {} (root-owned, AppArmor profile xlings-bwrap)", + after->bin.string()); + return 0; + } + log::error("installed, but the probe still fails: {}", + after ? after->probe_output.substr(0, after->probe_output.find('\n')) : std::string("no bwrap")); + return 1; + } else { + (void)yes; + stream.emit(ErrorEvent{ .code = ErrorCode::InvalidInput, + .message = "this platform's isolation is not implemented yet; there is nothing to repair", + .recoverable = false }); + return 1; + } } } diff --git a/src/core/subos/sandbox.cppm b/src/core/subos/sandbox.cppm index b132ae152..7f6d2856f 100644 --- a/src/core/subos/sandbox.cppm +++ b/src/core/subos/sandbox.cppm @@ -1,22 +1,3 @@ -module; - -// System headers used by the sandbox backends only. `import std;` does not -// pull these in, and the named-module purview forbids including them there. -#include -#if defined(_WIN32) -#define WIN32_LEAN_AND_MEAN -// See src/core/subos.cppm: windows.h's min/max macros break std::min({...}). -// Not yet triggered here, and that is exactly why it is worth closing -- -// the failure only appears on Windows, and only once someone writes the call. -#define NOMINMAX -#include -#else -#include -#include -#include -#include -#endif - export module xlings.core.subos.sandbox; import std; @@ -25,7 +6,9 @@ import xlings.core.config; import xlings.platform; import xlings.runtime; import xlings.core.xim.commands; // auto_install_backend_ needs cmd_install -import xlings.core.subos.gpu; +import xlings.subos.gpu; +import xlings.subos.policy; +import xlings.libs.json; import xlings.core.xvm.shim; // resolve_owner_home: reject another home's shim // Runtime isolation for a subos: proot/bwrap backends, storage images, GPU @@ -80,13 +63,7 @@ export inline StorageMode storage_from_string_(const std::string& s) { export StorageMode read_storage_mode_(const fs::path& subos_dir); -// /etc/* template builders + sandbox dir layout init. uid_t / gid_t -// are POSIX types — Windows MSVC doesn't have them. Sandbox is -// Linux-only by design (proot uses ptrace + Linux syscall semantics); -// the only caller (use_sandbox_mode_) is also Linux-guarded, so we -// guard these helpers too rather than fight the type system with -// platform-portable substitutes. -#if defined(__linux__) || defined(__APPLE__) +// /etc/* templates for POSIX sandboxes (plain data, compiled everywhere). inline constexpr std::string_view kEtcHosts = "127.0.0.1 localhost\n::1 localhost\n"; @@ -157,7 +134,6 @@ inline void write_sandbox_rc_(const fs::path& home_dir) { try_write(fish_dir / "config.fish", kSandboxFishConfig); } -#endif // __linux__ / __APPLE__ // ── Image storage helpers (V6) ──────────────────────────────────── @@ -170,34 +146,7 @@ export bool is_mounted_(const fs::path& path); // Unmount an image file. export int unmount_image_(const fs::path& mountpoint); -// ── Unified bind list (shared by proot + bwrap) ────────────────────── -// -// Both backends use the SAME set of host-RO paths and sandbox-private -// overrides. This ensures identical security profile (same info exposed, -// same paths isolated) regardless of backend. Only the CLI syntax -// differs: proot uses `--bind=src:dst`, bwrap uses `--ro-bind src dst` -// or `--bind src dst`. -// -// Design principle: MINIMAL host exposure. Only bind paths that are -// functionally required. Everything else stays invisible (proot: maps -// to empty /; bwrap: not bound at all). -// -// See .agents/docs/sandbox-v5-dual-backend-design.md for rationale. - -struct SandboxBind { - std::string src; - std::string dst; - bool readonly; // true = host RO; false = sandbox RW override -}; - -// ── Backend detection + auto-install ───────────────────────────────── - -enum class SandboxBackend { Bwrap, Proot }; - -struct BackendInfo { - SandboxBackend type; - fs::path binary; -}; +// ── Backend auto-install ───────────────────────────────────────────── int auto_install_backend_(const fs::path& home_dir, EventStream& stream); @@ -208,6 +157,42 @@ int auto_install_backend_(const fs::path& home_dir, EventStream& stream); // force one via `--sandbox bwrap` or `--sandbox proot`. // // See .agents/docs/sandbox-v5-dual-backend-design.md for full design. +// How an instance is entered (design §12). One entry for every surface: the +// legacy `--cmd` string (run as `sh -c`), an argv (`subos exec -- ...`), an +// interactive shell (neither), and a detached session (`subos start`). +export struct EnterOptions { + std::string backend; // "" | "bwrap" | "proot" + bool gpu { false }; + std::string cmd; // legacy --cmd + std::vector argv; // exec form + std::string cwd; // inside + std::map env; // --env K=V + std::optional timeout; + int ttl { 0 }; // idle seconds (detached / --keep) + bool detached { false }; // `subos start` + // `subos exec`: a failure before the command starts exits 125 (and the + // command's own code otherwise), instead of the CLI's usual 1. + bool exec_codes { false }; + // Report "entering ". A command run from outside (`exec`) is not + // someone entering anything; its own output is the whole story. + bool announce { true }; + // A preset named on this call (--sandbox=dev|private|locked) and its + // overrides; both may only tighten the instance's own policy. + std::optional preset; + policy::Overrides overrides; + std::vector publish; // --publish HOST:SANDBOX (net=nat) +}; + +export int enter(const std::string& name, EventStream& stream, const EnterOptions& options); + +// What entering `name` under `pol` would give on this host, without entering: +// the compiled spec (or the refusal), and the platform matrix. For +// `subos status` and the interface. Nothing is created or mounted. +export nlohmann::json preview(const std::string& name, const policy::Policy& pol, EventStream& stream); + +// `xlings self doctor --isolation [--fix] [--json]`. +export int doctor_isolation(bool fix, bool yes, bool json, EventStream& stream); + export int enter(const std::string& name, EventStream& stream, const std::string& preferred_backend = "", bool gpu = false, diff --git a/src/core/xim/commands.cpp b/src/core/xim/commands.cpp index dabfd6b58..3c97043e6 100644 --- a/src/core/xim/commands.cpp +++ b/src/core/xim/commands.cpp @@ -11,6 +11,7 @@ module; module xlings.core.xim.commands; +import xlings.core.xim.lua_boundary; import std; import xlings.core.xim.index; import xlings.core.xim.libxpkg.types.type; @@ -30,11 +31,12 @@ import xlings.core.xim.overlay; // subos's own declaration here closes no cycle. The alternative was a second // manifest reader living in xim, and this repo has paid for "the same decision // derived in two places" often enough to not add another. -import xlings.core.subos.manifest; +import xlings.subos.manifest; import xlings.core.log; import xlings.core.diag; import xlings.core.xvm.errors; import xlings.core.config; +import xlings.core.home; import xlings.core.profile; import xlings.runtime; import xlings.libs.json; @@ -453,6 +455,30 @@ int install_(std::span targets, bool yes, bool noDeps, auto platform = detect_platform(); std::vector targetVec(targets.begin(), targets.end()); + std::optional relocationTool; + bool injectedRelocationTool = false; + // A payload linked against the xlings glibc is relocated with patchelf at + // install (elfpatch). A host without one -- a fresh root, a minimal + // container -- used to get every such payload installed UNPATCHED and + // reported as installed (design part 2 §2.4 G1b). The static upstream + // build comes first instead. + if constexpr (platform::is_linux) { + const auto asked = std::ranges::find_if(targetVec, [](const std::string& t) { + return t == "patchelf" || t.ends_with(":patchelf") || t.starts_with("patchelf@") + || t.find(":patchelf@") != std::string::npos; + }); + if (!dryRun && !targetVec.empty() && !patchelf_reachable()) { + if (asked == targetVec.end()) { + log::info("payloads are relocated with patchelf and this host has none: " + "installing xim:patchelf first"); + targetVec.insert(targetVec.begin(), "xim:patchelf"); + relocationTool = 0; + injectedRelocationTool = true; + } else { + relocationTool = static_cast(asked - targetVec.begin()); + } + } + } std::vector requestedMatches; // C2 (#366 UX): allow at most one on-demand index refresh per install call. bool refreshedForMissing = false; @@ -719,8 +745,9 @@ int install_(std::span targets, bool yes, bool noDeps, } // One match per request, in request order, so the count so far is // this request's index. - if (report && requestedMatches.size() < report->size()) { - auto& entry = (*report)[requestedMatches.size()]; + if (report && (!injectedRelocationTool || !requestedMatches.empty()) && + requestedMatches.size() - static_cast(injectedRelocationTool) < report->size()) { + auto& entry = (*report)[requestedMatches.size() - static_cast(injectedRelocationTool)]; entry.namespaceName = match->namespaceName; entry.name = match->name; entry.version = match->version; @@ -753,6 +780,18 @@ int install_(std::span targets, bool yes, bool noDeps, return 1; } + if (relocationTool) { + const auto& tool = requestedMatches[*relocationTool]; + auto ordered = require_install_tool(plan, node_key_(tool)); + if (!ordered) { + stream.emit(ErrorEvent{ .code = ErrorCode::InvalidInput, + .message = "cannot bootstrap payload relocation: " + ordered.error(), .recoverable = false }); + return 1; + } + } + if (injectedRelocationTool) + requestedMatches.erase(requestedMatches.begin()); + // -g: register versions/workspace in global scope so tools work outside project dir if (forceGlobal) { bool hasProjectOnly = false; @@ -979,7 +1018,7 @@ int install_(std::span targets, bool yes, bool noDeps, } record_report(); activate_requested_targets(); - xself::sync_shim_tables(); + if (const auto sync = xself::sync_shim_tables(); !sync.root_error.empty()) return 1; return 0; } @@ -1181,7 +1220,8 @@ int install_(std::span targets, bool yes, bool noDeps, // home back to the invoking user so a later non-sudo `xlings install` // isn't locked out by EACCES. No-op for pure root / non-root installs // (lchown is metadata-only, so even large payload trees are cheap). - platform::chown_to_invoker(Config::paths().homeDir); + if (Config::home_context().user_owned()) + platform::chown_to_invoker(Config::paths().homeDir); } // Per-package failures (download / extract / hook) are surfaced via // InstallPhase::Failed callbacks and recorded in `outcomes`; @@ -1229,7 +1269,8 @@ selected_payloadless_config_has_uninstall_( "uninstall recipe is not a local file: {}", recipe.string())); } - auto executor = xpkg::create_executor(recipe); + xpkg::ExecutionContext context; + auto executor = lua_boundary::create_executor(recipe, context); if (!executor) return std::unexpected(executor.error()); return executor->has_hook(xpkg::HookType::Uninstall); } diff --git a/src/core/xim/downloader.cpp b/src/core/xim/downloader.cpp index 4caf5a781..48aa8a658 100644 --- a/src/core/xim/downloader.cpp +++ b/src/core/xim/downloader.cpp @@ -10,6 +10,7 @@ import xlings.core.log; import xlings.core.palette; import xlings.core.compact; import xlings.platform; +import xlings.platform.loopback_http; import xlings.core.config; import xlings.libs.tinyhttps; import xlings.runtime.cancellation; @@ -636,6 +637,22 @@ DownloadResult download_one(const DownloadTask& task, std::functionis_paused() || cancel->is_cancelled(); }; } + opts.loopbackHttp = [&opts](const std::string& url, const fs::path& destination) { + namespace http = platform::loopback_http; + auto wire = http::download(url, destination, std::chrono::seconds(opts.maxTimeSec), + opts.onProgress, opts.isCancelled); + tinyhttps::FailureKind kind = tinyhttps::FailureKind::None; + switch (wire.failure) { + case http::Failure::None: break; + case http::Failure::Source: kind = tinyhttps::FailureKind::Source; break; + case http::Failure::Transfer: kind = tinyhttps::FailureKind::Transfer; break; + case http::Failure::Local: kind = tinyhttps::FailureKind::Local; break; + case http::Failure::Cancelled: kind = tinyhttps::FailureKind::Cancelled; break; + } + return tinyhttps::DownloadFileResult{ + .success = wire.success, .error = std::move(wire.error), .bytesWritten = wire.written, + .expectedBytes = wire.expected, .finalUrl = url, .failure = kind}; + }; // A stalled host is throttled for us right now, and a host that // served bytes failing the integrity check is worse — demote both // for the rest of the session so later downloads skip them. diff --git a/src/core/xim/extract.cpp b/src/core/xim/extract.cpp index 28f124b46..410d0fac3 100644 --- a/src/core/xim/extract.cpp +++ b/src/core/xim/extract.cpp @@ -7,11 +7,26 @@ module; module xlings.core.xim.extract; import std; +import xlings.platform; namespace xlings::xim { namespace detail_ { +// libarchive's "write to disk" sink takes care of file creation, +// permissions, ownership, hardlink/symlink resolution. We only flip on +// the safety bits — no ACLs / extended attributes / fflags. We do NOT +// set SECURE_NOABSOLUTEPATHS here because we deliberately rewrite each +// entry's pathname into the (absolute) destDir below; that flag would +// reject every entry. Absolute paths in the original archive are +// filtered out by check_safe_pathname_() before rebasing. +constexpr int kWriteFlags = + ARCHIVE_EXTRACT_TIME + | ARCHIVE_EXTRACT_PERM + | ARCHIVE_EXTRACT_SECURE_NODOTDOT // reject "../" in entry path + | ARCHIVE_EXTRACT_SECURE_SYMLINKS; // refuse following symlinks during extraction + + std::expected check_safe_pathname_(const char* raw) { if (!raw || !*raw) return std::unexpected("empty pathname"); @@ -106,16 +121,16 @@ copy_entry_data_(struct archive* src, struct archive* dst) { void ensure_archive_locale_() { static std::once_flag once; std::call_once(once, [] { -#ifdef _WIN32 - if (!std::setlocale(LC_CTYPE, ".UTF-8")) { - std::setlocale(LC_CTYPE, ""); - } -#else - if (!std::setlocale(LC_CTYPE, "C.UTF-8") - && !std::setlocale(LC_CTYPE, "en_US.UTF-8")) { - std::setlocale(LC_CTYPE, ""); + if constexpr (platform::is_windows) { + if (!std::setlocale(LC_CTYPE, ".UTF-8")) { + std::setlocale(LC_CTYPE, ""); + } + } else { + if (!std::setlocale(LC_CTYPE, "C.UTF-8") + && !std::setlocale(LC_CTYPE, "en_US.UTF-8")) { + std::setlocale(LC_CTYPE, ""); + } } -#endif }); } @@ -309,3 +324,141 @@ extract_archive(const std::filesystem::path& archive, } } + + +namespace xlings::xim { + +std::expected write_tar_gz(const std::filesystem::path& root, + const std::filesystem::path& output, + ArchiveOwner owner, std::string_view prefix) { + namespace fs = std::filesystem; + struct Handles { + struct archive* disk { ::archive_read_disk_new() }; + struct archive* out { ::archive_write_new() }; + struct archive_entry* entry { ::archive_entry_new() }; + ~Handles() { + if (entry) ::archive_entry_free(entry); + if (disk) { ::archive_read_close(disk); ::archive_read_free(disk); } + if (out) ::archive_write_free(out); + } + } h; + if (!h.disk || !h.out || !h.entry) return std::unexpected("libarchive: failed to allocate handles"); + auto failed = [](struct archive* a, std::string_view what) { + const char* why = ::archive_error_string(a); + return std::unexpected(std::format("{}: {}", what, why ? why : "libarchive error")); + }; + ::archive_read_disk_set_symlink_physical(h.disk); // store links, never follow them + ::archive_read_disk_set_standard_lookup(h.disk); + if (::archive_write_add_filter_gzip(h.out) != ARCHIVE_OK) return failed(h.out, "gzip"); + if (::archive_write_set_format_pax_restricted(h.out) != ARCHIVE_OK) return failed(h.out, "tar format"); + if (::archive_write_open_filename(h.out, output.string().c_str()) != ARCHIVE_OK) + return failed(h.out, "cannot create " + output.string()); + if (::archive_read_disk_open(h.disk, root.string().c_str()) != ARCHIVE_OK) + return failed(h.disk, "cannot read " + root.string()); + + const auto base = root.lexically_normal(); + std::vector buffer(1 << 20); + for (;;) { + ::archive_entry_clear(h.entry); + const int r = ::archive_read_next_header2(h.disk, h.entry); + if (r == ARCHIVE_EOF) break; + if (r != ARCHIVE_OK && r != ARCHIVE_WARN) return failed(h.disk, "reading " + root.string()); + ::archive_read_disk_descend(h.disk); + const fs::path source = ::archive_entry_sourcepath(h.entry); + const auto rel = source.lexically_normal().lexically_relative(base); + std::string name = rel.empty() || rel == "." ? std::string(prefix) + : (fs::path(std::string(prefix)) / rel).generic_string(); + if (::archive_entry_filetype(h.entry) == AE_IFDIR && !name.ends_with('/')) name += '/'; + ::archive_entry_copy_pathname(h.entry, name.c_str()); + if (owner == ArchiveOwner::Root) { + ::archive_entry_set_uid(h.entry, 0); + ::archive_entry_set_gid(h.entry, 0); + ::archive_entry_copy_uname(h.entry, "root"); + ::archive_entry_copy_gname(h.entry, "root"); + } else { + ::archive_entry_copy_uname(h.entry, nullptr); // numeric, as --numeric-owner + ::archive_entry_copy_gname(h.entry, nullptr); + } + if (::archive_write_header(h.out, h.entry) != ARCHIVE_OK) return failed(h.out, "writing " + name); + if (::archive_entry_filetype(h.entry) != AE_IFREG || ::archive_entry_size(h.entry) == 0) continue; + std::ifstream in(source, std::ios::binary); + if (!in) return std::unexpected("cannot open " + source.string()); + while (in) { + in.read(buffer.data(), static_cast(buffer.size())); + const auto n = in.gcount(); + if (n > 0 && ::archive_write_data(h.out, buffer.data(), static_cast(n)) < 0) + return failed(h.out, "writing " + name); + } + if (in.bad()) return std::unexpected("cannot read " + source.string()); + } + if (::archive_write_close(h.out) != ARCHIVE_OK) return failed(h.out, "finishing " + output.string()); + return {}; +} + +std::expected write_tar_gz_entries(const std::filesystem::path& output, + std::span entries) { + struct Handles { + struct archive* out { ::archive_write_new() }; + struct archive_entry* entry { ::archive_entry_new() }; + ~Handles() { + if (entry) ::archive_entry_free(entry); + if (out) ::archive_write_free(out); + } + } h; + if (!h.out || !h.entry) return std::unexpected("libarchive: failed to allocate handles"); + auto failed = [&](std::string_view what) { + const char* why = ::archive_error_string(h.out); + return std::unexpected(std::format("{}: {}", what, why ? why : "libarchive error")); + }; + if (::archive_write_add_filter_gzip(h.out) != ARCHIVE_OK) return failed("gzip"); + if (::archive_write_set_format_pax_restricted(h.out) != ARCHIVE_OK) return failed("tar format"); + if (::archive_write_open_filename(h.out, output.string().c_str()) != ARCHIVE_OK) + return failed("cannot create " + output.string()); + std::vector buffer(1 << 20); + for (const auto& e : entries) { + ::archive_entry_clear(h.entry); + std::string name = "./" + e.path; + if (e.directory && !name.ends_with('/')) name += '/'; + ::archive_entry_copy_pathname(h.entry, name.c_str()); + ::archive_entry_set_uid(h.entry, 0); + ::archive_entry_set_gid(h.entry, 0); + ::archive_entry_copy_uname(h.entry, "root"); + ::archive_entry_copy_gname(h.entry, "root"); + ::archive_entry_set_mtime(h.entry, 0, 0); + std::uintmax_t size = 0; + if (e.directory) { + ::archive_entry_set_filetype(h.entry, AE_IFDIR); + ::archive_entry_set_perm(h.entry, e.mode == 0644 ? 0755 : e.mode); + } else if (!e.link.empty()) { + ::archive_entry_set_filetype(h.entry, AE_IFLNK); + ::archive_entry_set_perm(h.entry, 0777); + ::archive_entry_copy_symlink(h.entry, e.link.c_str()); + } else { + ::archive_entry_set_filetype(h.entry, AE_IFREG); + ::archive_entry_set_perm(h.entry, e.mode); + std::error_code ec; + size = e.from.empty() ? e.content.size() : std::filesystem::file_size(e.from, ec); + if (ec) return std::unexpected("cannot read " + e.from.string() + ": " + ec.message()); + ::archive_entry_set_size(h.entry, static_cast(size)); + } + if (::archive_write_header(h.out, h.entry) != ARCHIVE_OK) return failed("writing " + name); + if (size == 0) continue; + if (e.from.empty()) { + if (::archive_write_data(h.out, e.content.data(), e.content.size()) < 0) return failed("writing " + name); + continue; + } + std::ifstream in(e.from, std::ios::binary); + if (!in) return std::unexpected("cannot open " + e.from.string()); + while (in) { + in.read(buffer.data(), static_cast(buffer.size())); + const auto n = in.gcount(); + if (n > 0 && ::archive_write_data(h.out, buffer.data(), static_cast(n)) < 0) + return failed("writing " + name); + } + if (in.bad()) return std::unexpected("cannot read " + e.from.string()); + } + if (::archive_write_close(h.out) != ARCHIVE_OK) return failed("finishing " + output.string()); + return {}; +} + +} // namespace xlings::xim diff --git a/src/core/xim/extract.cppm b/src/core/xim/extract.cppm index 9007a012e..ae49e472c 100644 --- a/src/core/xim/extract.cppm +++ b/src/core/xim/extract.cppm @@ -1,9 +1,3 @@ -module; - -#include -#include -#include - export module xlings.core.xim.extract; import std; @@ -43,45 +37,33 @@ std::expected extract_archive(const std::filesystem::path& archive, const std::filesystem::path& destDir); -} // namespace xlings::xim - - -// Implementation. Kept in the module unit (not a separate .cpp) for -// brevity and so the libarchive headers stay confined to the global -// module fragment. - -namespace xlings::xim { - -namespace detail_ { - -// libarchive's "write to disk" sink takes care of file creation, -// permissions, ownership, hardlink/symlink resolution. We only flip on -// the safety bits — no ACLs / extended attributes / fflags. We do NOT -// set SECURE_NOABSOLUTEPATHS here because we deliberately rewrite each -// entry's pathname into the (absolute) destDir below; that flag would -// reject every entry. Absolute paths in the original archive are -// filtered out by check_safe_pathname_() before rebasing. -constexpr int kWriteFlags = - ARCHIVE_EXTRACT_TIME - | ARCHIVE_EXTRACT_PERM - | ARCHIVE_EXTRACT_SECURE_NODOTDOT // reject "../" in entry path - | ARCHIVE_EXTRACT_SECURE_SYMLINKS; // refuse following symlinks during extraction - -// Reject archive entries whose pathname is absolute or contains "..". -// libarchive's SECURE_NODOTDOT covers the latter at write time; we -// reject the former here so we can still rebase relative entries onto -// our chosen destDir. Returns the entry's *original* relative pathname. -std::expected -check_safe_pathname_(const char* raw); - -} // namespace detail_ - -std::expected -extract_archive_detailed(const std::filesystem::path& archive, - const std::filesystem::path& destDir); - -std::expected -extract_archive(const std::filesystem::path& archive, - const std::filesystem::path& destDir); +// Who the entries of a written archive belong to. +// AsOnDisk the owner each file has (numeric), as `tar --numeric-owner` +// Root 0:0 for every entry -- a root filesystem's image, written by +// a user who is not root, without a user namespace +enum class ArchiveOwner { AsOnDisk, Root }; + +// Writes the tree under `root` to a gzip-compressed tar at `output`, each +// entry named `prefix/` (prefix "." as `tar -C root +// .`). Symlinks are stored as links, never followed. In-process: the +// replacement for running the host's tar (SubOS design part 3 §6.4). +std::expected write_tar_gz(const std::filesystem::path& root, + const std::filesystem::path& output, + ArchiveOwner owner, + std::string_view prefix = "."); + +// An archive written from a description rather than a directory: what a +// guest image is (part 3 §5.3) on a host that may not be able to make the +// symlinks it holds (Windows without developer mode). Every entry root's. +struct TarEntry { + std::string path; // inside, relative + std::string content; // a regular file's bytes, or... + std::filesystem::path from; // ...the host file streamed as its bytes + unsigned mode { 0644 }; + std::string link; // non-empty: a symbolic link + bool directory { false }; +}; +std::expected write_tar_gz_entries(const std::filesystem::path& output, + std::span entries); } // namespace xlings::xim diff --git a/src/core/xim/index.cpp b/src/core/xim/index.cpp index c3b9012f0..9ff9e0dac 100644 --- a/src/core/xim/index.cpp +++ b/src/core/xim/index.cpp @@ -1,5 +1,6 @@ module xlings.core.xim.index; +import xlings.core.xim.lua_boundary; import std; import mcpplibs.xpkg; import mcpplibs.xpkg.loader; @@ -171,11 +172,11 @@ CacheResult load_index_cache(const std::filesystem::path& cacheFile, namespace xlings::xim { std::expected -load_native_recipe(const std::filesystem::path& path) { - return xpkg::load_package(path, { +load_native_recipe(const std::filesystem::path& path, const std::string& formal_provider) { + return lua_boundary::metadata(path, { .platform = std::string(platform::build_os()), .arch = std::string(platform::build_arch()), - }); + }, formal_provider); } std::optional parse_bracketed_step(std::string_view line) { @@ -314,7 +315,9 @@ std::expected IndexManager::rebuild() { const auto label = defaultNamespace_.empty() ? repoDir_.filename().string() : defaultNamespace_; BuildTranscript_ transcript(label); - auto result = xpkg::build_index(repoDir_, defaultNamespace_, + auto result = lua_boundary::build_index(repoDir_, defaultNamespace_, + {.platform = std::string(platform::build_os()), + .arch = std::string(platform::build_arch())}, [&transcript](std::string_view text) { transcript.feed(text); }); transcript.finish(); if (transcript.steps() > 0) { @@ -404,7 +407,7 @@ std::expected IndexManager::load_package(const std:: if (it == index_.entries.end()) { return std::unexpected(std::format("package '{}' not found in index", name)); } - return load_native_recipe(it->second.path); + return load_native_recipe(it->second.path, it->second.canonicalName); } const xpkg::IndexEntry* IndexManager::find_entry(const std::string& name) const { diff --git a/src/core/xim/index.cppm b/src/core/xim/index.cppm index d552398fd..5f00b4d6b 100644 --- a/src/core/xim/index.cppm +++ b/src/core/xim/index.cppm @@ -34,7 +34,7 @@ export namespace xlings::xim { // Recipes describe the ABI of the client process, including under emulation. // Metadata resolution and installation use the same loader context. std::expected -load_native_recipe(const std::filesystem::path& path); +load_native_recipe(const std::filesystem::path& path, const std::string& formal_provider = {}); // One recognised shape for a line a package index's build script writes: // "[i/n] ", the self-refreshing progress line of diff --git a/src/core/xim/installer.cpp b/src/core/xim/installer.cpp index 05df8efbf..dd88808a5 100644 --- a/src/core/xim/installer.cpp +++ b/src/core/xim/installer.cpp @@ -1,5 +1,6 @@ module xlings.core.xim.installer; +import xlings.core.xim.lua_boundary; import std; import mcpplibs.xpkg; import mcpplibs.xpkg.loader; @@ -8,6 +9,7 @@ import mcpplibs.xpkg.executor; import xlings.core.xim.libxpkg.types.type; import xlings.core.xim.compatibility; import xlings.core.xim.payload; +import xlings.core.xim.retention; import xlings.core.xim.install_state; import xlings.core.xim.index; import xlings.core.xim.catalog; @@ -17,6 +19,10 @@ import xlings.core.log; import xlings.platform; import xlings.platform.target; import xlings.core.config; +import xlings.core.home; +import xlings.core.home.layers; +import xlings.core.home.evidence; +import xlings.core.home.domain_producer_source; import xlings.core.semver; import xlings.core.entry_binary; import xlings.core.elf_same_source; @@ -29,8 +35,10 @@ import xlings.core.xvm.owner; import xlings.core.xvm.bindings; import xlings.core.xvm.removal; import xlings.core.xvm.registration; +import xlings.core.xvm.materialize; +import xlings.core.xvm.switch_plan; import xlings.core.xvm.errors; -import xlings.core.subos.manifest; +import xlings.subos.manifest; import xlings.core.profile; import xlings.core.xvm.commands; import xlings.core.xvm.shim; @@ -40,6 +48,19 @@ import xlings.runtime.cancellation; namespace xlings::xim { +namespace { +void normalize_scope_metadata(xvm::Workspace& active, xvm::WorkspaceInstalled& installed); + +std::filesystem::path node_payload_path(const PlanNode& node, + const std::filesystem::path& dataDir) { + if (!node.borrowedPayload.empty()) { + return node.borrowedPayload; + } + return (node.storeRoot.empty() ? dataDir / "xpkgs" : node.storeRoot) / + package_store_name(node.namespaceName, node.name) / node.version; +} +} + XpkgSpecSupport xpkg_spec_support(std::string_view spec) { if (spec.empty()) return {.supported = true, .declared = 1}; int value = 0; @@ -192,10 +213,7 @@ normalize_xpkg_registration_plan( } return version; }; - const auto fallbackPath = - (node.storeRoot.empty() ? dataDir / "xpkgs" : node.storeRoot) - / package_store_name(node.namespaceName, node.name) - / node.version; + const auto fallbackPath = node_payload_path(node, dataDir); XpkgRegistrationPlan plan{ .batch = { .provider = node.canonicalName.empty() @@ -213,6 +231,16 @@ normalize_xpkg_registration_plan( for (std::size_t index = 0; index < operations.size(); ++index) { const auto& operation = operations[index]; + if (operation.op == "add" && operation.name == "xlings" && + plan.batch.provider != "xim:xlings" && plan.batch.provider != "xlings") { + return std::unexpected(XpkgRegistrationError{ + .kind = XpkgRegistrationErrorKind::InvalidBinding, + .operationIndex = index, + .target = operation.name, + .version = operation.version, + .message = "only the xlings package may register the shared client", + }); + } if (operation.op == "headers") { plan.batch.headers.push_back({ .sourceDir = operation.includedir, @@ -570,6 +598,7 @@ apply_xpkg_xvm_metadata_batch(xvm::VersionDB& db, xvm::Workspace& workspace, xvm registered = std::move(*registrationResult); } + normalize_scope_metadata(candidateWorkspace, candidateInstalled); db.swap(candidateDb); workspace.swap(candidateWorkspace); installed.swap(candidateInstalled); @@ -581,118 +610,113 @@ apply_xpkg_xvm_metadata_batch(xvm::VersionDB& db, xvm::Workspace& workspace, xvm }; } -void cleanup_removed_xvm_library_artifacts( - const std::filesystem::path& libDir, - const xvm::VersionDB& dbBeforeRemoval, - const xvm::VersionDB& currentDb, - const xvm::RemovalBatchResult& removalResult) { - auto destinationName = []( - const std::string& target, - const xvm::VInfo& info, - const xvm::VData& data) { +namespace { +void normalize_scope_metadata(xvm::Workspace& active, xvm::WorkspaceInstalled& installed) { + xvm::SubosWorkspace scope; + scope.active = active; + scope.installed = installed; + const auto saved = xvm::subos_workspace_from_json(xvm::subos_workspace_to_json(scope)); + active = saved.active; + installed = saved.installed; +} +} + +std::expected cleanup_removed_xvm_library_artifacts( + const std::filesystem::path& libDir, const xvm::VersionDB& dbBeforeRemoval, + const xvm::VersionDB& currentDb, const xvm::RemovalBatchResult& removalResult, + std::span claims) { + auto destinationName = [](const std::string& target, const xvm::VInfo& info, + const xvm::VData& data) { if (!data.destinationName.empty()) { return data.destinationName; } - if (!info.filename.empty()) return info.filename; + if (!info.filename.empty()) + return info.filename; return target; }; std::set destinations; for (const auto& removed : removalResult.removed) { auto targetIt = dbBeforeRemoval.find(removed.target); - if (targetIt == dbBeforeRemoval.end()) continue; - auto versionIt = - targetIt->second.versions.find(removed.version); - if (versionIt == targetIt->second.versions.end()) continue; + if (targetIt == dbBeforeRemoval.end()) + continue; + auto versionIt = targetIt->second.versions.find(removed.version); + if (versionIt == targetIt->second.versions.end()) + continue; const auto& data = versionIt->second; - const auto kind = data.kind.empty() - ? targetIt->second.type - : data.kind; - if (kind != "lib") continue; - destinations.insert(destinationName( - removed.target, targetIt->second, data)); + const auto kind = data.kind.empty() ? targetIt->second.type : data.kind; + if (kind != "lib") + continue; + destinations.insert(destinationName(removed.target, targetIt->second, data)); } for (const auto& name : destinations) { - const auto hasSurvivingOwner = std::ranges::any_of( - currentDb, - [&](const auto& targetEntry) { - return std::ranges::any_of( - targetEntry.second.versions, - [&](const auto& versionEntry) { - const auto& data = versionEntry.second; - const auto kind = data.kind.empty() - ? targetEntry.second.type - : data.kind; - return kind == "lib" - && destinationName( - targetEntry.first, - targetEntry.second, - data) == name; - }); + const auto hasSurvivingOwner = std::ranges::any_of(currentDb, [&](const auto& targetEntry) { + return std::ranges::any_of(targetEntry.second.versions, [&](const auto& versionEntry) { + const auto& data = versionEntry.second; + const auto kind = data.kind.empty() ? targetEntry.second.type : data.kind; + return kind == "lib" && + destinationName(targetEntry.first, targetEntry.second, data) == name; }); - if (hasSurvivingOwner) continue; + }); + if (hasSurvivingOwner) + continue; const auto destination = libDir / name; - std::error_code ec; - if (std::filesystem::exists(destination, ec) - || std::filesystem::is_symlink(destination, ec)) { - ec.clear(); - std::filesystem::remove(destination, ec); - } + const auto claim = std::ranges::find_if( + claims, [&](const auto& proof) { return proof.destination == destination; }); + std::vector changes{ + {claim == claims.end() ? std::filesystem::path{} : claim->source, destination, true}}; + auto prepared = + xvm::materialize::preflight_materialization(changes, claims, libDir.parent_path()); + if (!prepared) + return std::unexpected(prepared.error()); + auto applied = prepared->execute(); + if (!applied) + return std::unexpected(applied.error()); + if (auto committed = applied->commit(); !committed) + return committed; } + return {}; } -void cleanup_removed_xvm_program_artifacts( - const std::filesystem::path& binDir, - const xvm::VersionDB& dbBeforeRemoval, - const xvm::VersionDB& currentDb, - const xvm::WorkspaceInstalled& installed, - const xvm::RemovalBatchResult& removalResult) { +std::expected cleanup_removed_xvm_program_artifacts( + const std::filesystem::path& binDir, const xvm::VersionDB& dbBeforeRemoval, + const xvm::VersionDB& currentDb, const xvm::WorkspaceInstalled& installed, + const xvm::RemovalBatchResult& removalResult) { std::set removedPrograms; for (const auto& removed : removalResult.removed) { auto targetIt = dbBeforeRemoval.find(removed.target); - if (targetIt == dbBeforeRemoval.end()) continue; - auto versionIt = - targetIt->second.versions.find(removed.version); - if (versionIt == targetIt->second.versions.end()) continue; - const auto kind = versionIt->second.kind.empty() - ? targetIt->second.type - : versionIt->second.kind; + if (targetIt == dbBeforeRemoval.end()) + continue; + auto versionIt = targetIt->second.versions.find(removed.version); + if (versionIt == targetIt->second.versions.end()) + continue; + const auto kind = + versionIt->second.kind.empty() ? targetIt->second.type : versionIt->second.kind; if (kind == "program") { removedPrograms.insert(removed.target); } } for (const auto& target : removedPrograms) { - if (xvm::has_usable_workspace_version( - currentDb, installed, target)) { + if (xvm::has_usable_workspace_version(currentDb, installed, target)) { continue; } -#ifdef _WIN32 - constexpr std::string_view shimExtension = ".exe"; -#else - constexpr std::string_view shimExtension = ""; -#endif + constexpr std::string_view shimExtension = platform::exe_suffix; std::string shimName = target; - if (!shimExtension.empty() - && !shimName.ends_with(shimExtension)) { + if (!shimExtension.empty() && !shimName.ends_with(shimExtension)) { shimName += shimExtension; } const auto shimPath = binDir / shimName; std::error_code ec; - if (!std::filesystem::exists(shimPath, ec) - && !std::filesystem::is_symlink(shimPath, ec)) { + if (!std::filesystem::exists(shimPath, ec) && !std::filesystem::is_symlink(shimPath, ec)) { continue; } - ec.clear(); - std::filesystem::remove(shimPath, ec); - if (ec) { - log::warn("could not remove shim {}: {}; will be replaced " - "on next install/use", - shimPath.string(), ec.message()); - } + return std::unexpected("program shim cleanup requires sync_shim_tables: " + + shimPath.string()); } + return {}; } // The third member of the cleanup family, missing since declared assets @@ -714,28 +738,30 @@ void cleanup_removed_xvm_program_artifacts( // What to do with each destination is `reclaim_declared_assets`'s decision, // shared with the detach and `use` paths. All this adds is the question of // which destinations are being given up. -void cleanup_removed_xvm_file_artifacts( - const std::filesystem::path& subosDir, - const std::filesystem::path& payloadRoot, - const xvm::VersionDB& dbBeforeRemoval, - const xvm::VersionDB& currentDb, - const xvm::Workspace& currentWorkspace, - const xvm::RemovalBatchResult& removalResult) { +std::expected cleanup_removed_xvm_file_artifacts( + const std::filesystem::path& subosDir, const std::filesystem::path& payloadRoot, + const xvm::VersionDB& dbBeforeRemoval, const xvm::VersionDB& currentDb, + const xvm::Workspace& currentWorkspace, const xvm::RemovalBatchResult& removalResult, + std::span claims) { std::set removedDestinations; for (const auto& removed : removalResult.removed) { auto targetIt = dbBeforeRemoval.find(removed.target); - if (targetIt == dbBeforeRemoval.end()) continue; + if (targetIt == dbBeforeRemoval.end()) + continue; auto versionIt = targetIt->second.versions.find(removed.version); - if (versionIt == targetIt->second.versions.end()) continue; + if (versionIt == targetIt->second.versions.end()) + continue; const auto& data = versionIt->second; - if (xvm::effective_kind(targetIt->second, data) != "files") continue; + if (xvm::effective_kind(targetIt->second, data) != "files") + continue; // Re-checked rather than trusted: a hand-edited record must not be // able to steer a delete outside the subos. - if (!xvm::is_permitted_file_destination(data.fileDst)) continue; + if (!xvm::is_permitted_file_destination(data.fileDst)) + continue; removedDestinations.insert(data.fileDst); } - xvm::reclaim_declared_assets(subosDir, payloadRoot, removedDestinations, - currentDb, currentWorkspace); + return xvm::reclaim_declared_assets(subosDir, payloadRoot, removedDestinations, currentDb, + currentWorkspace, claims); } // What an extraction failure means on the wire. @@ -1115,10 +1141,7 @@ std::string effective_version_namespace_( const xvm::VersionDB& db, const PlanNode& node, const std::filesystem::path& dataDir) { - const auto payloadDir = - (node.storeRoot.empty() ? dataDir / "xpkgs" : node.storeRoot) - / package_store_name(node.namespaceName, node.name) - / node.version; + const auto payloadDir = node_payload_path(node, dataDir); return effective_version_namespace_( db, node.namespaceName, node.name, node.canonicalName, node.version, payloadDir); @@ -1188,6 +1211,56 @@ std::filesystem::path xpkg_snapshot_file_(const std::filesystem::path& installDi return installDir / ".xpkg.lua"; } +std::expected +save_resolution_(const PlanNode& node, const mcpplibs::xpkg::ExecutionContext& ctx) { + namespace fs = std::filesystem; + try { + for (const auto& spec : node.runtime_deps) { + if (!ctx.resolved_deps.contains(spec)) { + return std::unexpected("missing resolved runtime dependency: " + spec); + } + } + std::error_code ec; + const auto payload = fs::symlink_status(ctx.install_dir, ec); + if (ec || !fs::is_directory(payload)) { + return std::unexpected("cannot record resolution in a non-directory payload: " + + ctx.install_dir.string()); + } + const auto path = ctx.install_dir / ".xlings-resolution.json"; + const auto status = fs::symlink_status(path, ec); + if (ec && ec != std::errc::no_such_file_or_directory) { + return std::unexpected("cannot inspect resolution record: " + ec.message()); + } + if (status.type() != fs::file_type::not_found && !fs::is_regular_file(status)) { + return std::unexpected("resolution record is not a regular file: " + path.string()); + } + nlohmann::json record; + const auto identity = node.canonicalName.empty() + ? configured_identity(node.namespaceName, node.name, node.version) + : std::format("{}@{}", node.canonicalName, node.version); + record["package"] = identity; + record["deps"] = nlohmann::json::array(); + std::vector specs; + for (const auto& [spec, dep] : ctx.resolved_deps) { + specs.push_back(spec); + } + std::ranges::sort(specs); + for (const auto& spec : specs) { + const auto& dep = ctx.resolved_deps.at(spec); + record["deps"].push_back({{"spec", dep.spec}, + {"name", dep.name}, + {"version", dep.version}, + {"install_dir", dep.install_dir}, + {"libdirs", dep.libdirs}, + {"source", dep.source}}); + } + platform::write_file_atomic(path.string(), record.dump(2) + "\n"); + return {}; + } catch (const std::exception& error) { + return std::unexpected("cannot record payload resolution: " + std::string(error.what())); + } +} + std::expected save_xpkg_snapshot_(const std::filesystem::path& sourcePkgFile, const std::filesystem::path& installDir) { @@ -1236,7 +1309,8 @@ std::filesystem::path uninstall_xpkg_file_(const std::filesystem::path& indexPkg bool is_archive_(const std::filesystem::path& path) { auto filename = path.filename().string(); - return filename.ends_with(".tar.gz") + return filename.ends_with(".tar") + || filename.ends_with(".tar.gz") || filename.ends_with(".tar.xz") || filename.ends_with(".tar.bz2") || filename.ends_with(".tgz") @@ -1620,62 +1694,6 @@ bool is_version_referenced_anywhere_(PackageScope scope, const std::string& targ return true; } -void remove_target_shims_(const std::string& target, const std::string& version) { - namespace fs = std::filesystem; - auto db = Config::versions(); - auto* vinfo = xvm::get_vinfo(db, target); - auto& binDir = Config::paths().binDir; - std::error_code ec; - - // A program's shim is a generic dispatcher to the bootstrap; the actual - // version it routes to is the workspace pointer (read at runtime). So - // the shim is only stale when **the current subos** has dropped its - // last version of the name. With surviving versions still in this - // subos's installed[], the shim must stay — auto-fallback in - // detach_current_subos_ updates `active` to the highest remaining - // version and the shim resolves through that. - // - // 0.4.19+: this check is now scoped to the **current subos's - // installed[]**, not the global versions DB. Pre-0.4.19 the gate - // was `is_last_version_for(global)`, which under-removed: subos A - // could drain its installed[] for a target while subos B still had - // some other version of it, leaving a stale shim in A's bin/. With - // C2 schema each subos's bin/ is independent — so is each subos's - // shim-lifetime decision. - // - // Caller contract: detach_current_subos_ has already pruned - // `version` from this subos's installed[] before calling us. So - // checking "is the post-removal installed[] empty?" gives the - // correct answer. - const auto& wsi = Config::workspace_installed(); - auto subos_has_any_version_of = [&](const std::string& name) { - auto it = wsi.find(name); - return it != wsi.end() && !it->second.empty(); - }; - - auto mainName = (vinfo && !vinfo->filename.empty()) ? vinfo->filename : target; - if (!subos_has_any_version_of(target)) { - auto mainShim = binDir / mainName; - if (fs::exists(mainShim, ec) || fs::is_symlink(mainShim, ec)) { - ec.clear(); - fs::remove(mainShim, ec); - } - } - - if (!vinfo) return; - for (auto& [bindingName, vermap] : vinfo->bindings) { - auto vit = vermap.find(version); - if (vit == vermap.end()) continue; - if (subos_has_any_version_of(bindingName)) continue; // still in use here - auto bindPath = binDir / bindingName; - ec.clear(); - if (fs::exists(bindPath, ec) || fs::is_symlink(bindPath, ec)) { - ec.clear(); - fs::remove(bindPath, ec); - } - } -} - // Members of the release `target@version` belongs to, plus the coordinate // itself. // @@ -1695,157 +1713,136 @@ release_members_(const xvm::VersionDB& db, return members; } -void detach_current_subos_(const std::string& target, const std::string& version, bool persist) { - auto& ws = Config::workspace_mut(); - auto& wsi = Config::workspace_installed_mut(); - const auto db = Config::versions(); - - // The RELEASE, not the name the user typed. - // - // Every sysroot artifact registers on a member: libraries as their own - // soname targets (`libglib-2.0.so.0`), declared assets as generated ones - // (`glib.files.274`). The package's own entry carries none of them. So - // asking about `target` alone -- which is what this function did until - // 2026.8.26.1 -- reclaimed nothing at all on any modern home, and left - // the whole release's footprint pointing at a payload this subos had just - // opted out of. Not dangling, which is what made it invisible: the links - // still worked, so a compiler kept using headers from a package the user - // had removed (openxlings/xlings#423). +void detach_selection_(const xvm::VersionDB& db, xvm::Workspace& active, + xvm::WorkspaceInstalled& installed, const std::string& target, + const std::string& version) { const auto members = release_members_(db, target, version); - - // Step 1 (0.4.19+): always drop the release from this subos's - // installed[] set, even when it isn't the currently active version. - // The set is the per-subos opt-in list; an explicit `xlings remove - // foo@X` always means "this subos no longer wants X". - // - // Members included since 2026.8.26.1. Dropping only `target` left the - // subos claiming 140 of its 268 workspace entries for a release it had - // detached, and the entries were swept later by `self doctor --fix` -- - // which is another command finishing this one's work, not this one - // working. - bool installedChanged = false; - for (const auto& [memberTarget, memberVersion] : members) { - auto it = wsi.find(memberTarget); - if (it == wsi.end()) continue; - auto& list = it->second; - const auto before = list.size(); - std::erase(list, memberVersion); - if (list.size() == before) continue; - installedChanged = true; - if (list.empty()) wsi.erase(it); + for (const auto& [name, key] : members) { + if (auto it = installed.find(name); it != installed.end()) { + std::erase(it->second, key); + if (it->second.empty()) + installed.erase(it); + } + } + const auto selected = active.find(target); + if (selected == active.end() || selected->second != version) + return; + std::string fallback; + if (const auto it = installed.find(target); it != installed.end() && !it->second.empty()) + fallback = it->second.back(); + for (const auto& [name, key] : members) { + if (const auto it = active.find(name); it != active.end() && it->second == key) + active.erase(it); } + if (!fallback.empty()) { + for (const auto& [name, key] : release_members_(db, target, fallback)) + active[name] = key; + } +} - // Step 2: if `version` was the active pointer, do the actual subos - // teardown (remove headers / libs / assets / shims) and then either fall - // back to another installed version or clear the pointer entirely. - auto wit = ws.find(target); - bool wasActive = wit != ws.end() && wit->second == version; - - if (wasActive) { - const auto& p = Config::paths(); - const auto sysroot_include = p.subosDir / "usr" / "include"; - const auto sysroot_lib = p.libDir; - const auto payloadRoot = p.dataDir / "xpkgs"; - - // Auto-fallback: when the user removes the active version but - // still has other versions installed in this subos, switch - // active to the highest remaining (installed[] is stored - // sorted ascending by subos_workspace_to_json — `back()` is - // the lexicographically-highest, which approximates "newest" - // for typical semver-ish version strings). - std::string fallbackVersion; - if (auto sit = wsi.find(target); - sit != wsi.end() && !sit->second.empty()) { - fallbackVersion = sit->second.back(); - } - const auto fallbackMembers = fallbackVersion.empty() - ? std::map{} - : release_members_(db, target, fallbackVersion); - - // What `active` will say once this lands, computed BEFORE anything is - // torn down. Reconciling against the before-view would preserve links - // belonging to the very release being taken away. - auto activeAfter = ws; - for (const auto& [memberTarget, _] : members) { - activeAfter.erase(memberTarget); - } - for (const auto& [memberTarget, memberVersion] : fallbackMembers) { - activeAfter[memberTarget] = memberVersion; - } - - std::set destinations; - std::set outgoingLibNames; - for (const auto& [memberTarget, memberVersion] : members) { - for (const auto& asset : - xvm::group_header_assets(db, memberTarget, memberVersion)) { - xvm::remove_headers(asset, sysroot_include); - } - if (const auto placement = xvm::library_placement( - db, memberTarget, memberVersion, - Config::paths().homeDir.string()); - !placement.empty()) { - outgoingLibNames.insert(placement.name); - } - } - for (const auto& placement : xvm::release_file_placements( - db, target, version, Config::paths().homeDir.string())) { - if (!xvm::is_permitted_file_destination(placement.destination)) { +std::expected +publish_removal_state_(xvm::VersionDB candidateDb, xvm::Workspace candidateWorkspace, + xvm::WorkspaceInstalled candidateInstalled, + const std::vector& operations = {}) { + normalize_scope_metadata(candidateWorkspace, candidateInstalled); + namespace mat = xvm::materialize; + const auto beforeDb = Config::versions(); + const auto beforeWorkspace = Config::workspace(); + const auto beforeInstalled = Config::workspace_installed(); + const auto root = Config::xvm_artifact_subos_dir(); + const auto homeDir = Config::paths().homeDir.string(); + auto claims = mat::collect_claims(beforeDb, beforeInstalled, root, root / "lib", homeDir); + if (!claims) + return std::unexpected(claims.error()); + xvm::WorkspaceInstalled activeOnly; + for (const auto& [name, key] : candidateWorkspace) + activeOnly[name] = {key}; + auto desired = mat::collect_claims(candidateDb, activeOnly, root, root / "lib", homeDir, mat::ClaimSource::Present); + if (!desired) + return std::unexpected(desired.error()); + auto obsolete = mat::obsolete_assets(*claims, *desired); + if (!obsolete) return std::unexpected(obsolete.error()); + auto changes = std::move(*obsolete); + for (const auto& next : *desired) + changes.push_back({next.source, next.destination, false}); + for (const auto& op : operations) { + if (op.op != "remove_headers") + continue; + const auto source = std::filesystem::path(xvm::expand_path(op.includedir, homeDir)); + std::vector selected; + for (const auto& old : *claims) { + const auto relative = old.source.lexically_relative(source); + if (relative.empty() || *relative.begin() == "..") continue; - } - destinations.insert(placement.destination); - } - - // Same rule the libraries get from the uninstall path, and the same - // rule the assets get below: a name an active release still claims is - // re-pointed at that release rather than deleted. Without it, - // detaching musl's release would take `libc.so.6` away from the glibc - // release still active here. - std::map activeLibSources; - for (const auto& [activeTarget, activeVersion] : activeAfter) { - const auto placement = xvm::library_placement( - db, activeTarget, activeVersion, - Config::paths().homeDir.string()); - if (placement.empty()) continue; - if (!outgoingLibNames.contains(placement.name)) continue; - activeLibSources.emplace(placement.name, placement.source); - } - for (const auto& name : outgoingLibNames) { - if (const auto it = activeLibSources.find(name); - it != activeLibSources.end()) { - xvm::place_library(it->second, name, sysroot_lib); - } else { - xvm::remove_library(name, sysroot_lib); - } - } - - xvm::reclaim_declared_assets(p.subosDir, payloadRoot, destinations, - db, activeAfter); - - // The release being fallen back to may declare assets the outgoing - // one did not; `reclaim_declared_assets` only ever visits the - // outgoing set, so those would be missing. Placing is idempotent, so - // this is a stat for everything already correct. - if (!fallbackVersion.empty()) { - for (const auto& placement : xvm::release_file_placements( - db, target, fallbackVersion, - Config::paths().homeDir.string())) { - xvm::place_asset(placement.source, - p.subosDir / placement.destination); - } + selected.push_back(old); } - - remove_target_shims_(target, version); - - // The whole release moves or none of it does. Leaving members - // pointing at the outgoing version while `target` moved to the - // fallback is the mixed state INV-2 reports and the binding-group - // model exists to prevent. - ws = std::move(activeAfter); + auto removedHeaders = mat::obsolete_assets(selected, *desired); + if (!removedHeaders) return std::unexpected(removedHeaders.error()); + changes.insert(changes.end(), removedHeaders->begin(), removedHeaders->end()); } - - if (persist && (wasActive || installedChanged)) { + auto prepared = mat::preflight_materialization(changes, *claims, root); + if (!prepared) + return std::unexpected(prepared.error()); + const auto versionPath = &Config::versions_mut() == &Config::global_versions() + ? Config::paths().homeDir / ".xlings.json" + : Config::project_state_path(); + const auto workspacePath = Config::workspace_config_path(false); + auto oldVersions = home::read_json_for_update(versionPath); + auto oldWorkspace = home::read_json_for_update(workspacePath); + if (!oldVersions || !oldWorkspace) + return std::unexpected("cannot observe metadata before removal"); + auto applied = prepared->execute(); + if (!applied) + return std::unexpected(applied.error()); + Config::versions_mut() = std::move(candidateDb); + Config::workspace_mut() = std::move(candidateWorkspace); + Config::workspace_installed_mut() = std::move(candidateInstalled); + try { + Config::save_versions(); Config::save_workspace(); + auto versions = home::read_json_for_update(versionPath); + auto workspace = home::read_json_for_update(workspacePath); + if (!versions || !workspace || + versions->value("versions", nlohmann::json::object()) != + xvm::versions_to_json(Config::versions())) + throw std::runtime_error("version metadata was not saved"); + const auto persisted = xvm::subos_workspace_from_json(workspace->at("workspace")); + if (persisted.active != Config::workspace() || + persisted.installed != Config::workspace_installed()) + throw std::runtime_error("scope metadata was not saved"); + if (const auto sync = xself::sync_shim_tables(); !sync.root_error.empty()) + throw std::runtime_error(sync.root_error); + } catch (const std::exception& error) { + std::string failure = error.what(); + Config::versions_mut() = beforeDb; + Config::workspace_mut() = beforeWorkspace; + Config::workspace_installed_mut() = beforeInstalled; + try { + platform::write_file_atomic(versionPath.string(), oldVersions->dump(2)); + if (workspacePath != versionPath) + platform::write_file_atomic(workspacePath.string(), oldWorkspace->dump(2)); + } catch (const std::exception& rollback) { + failure += "; metadata rollback failed: " + std::string(rollback.what()); + } + if (const auto rollback = applied->rollback(); !rollback) + failure += "; asset rollback failed: " + rollback.error(); + if (const auto sync = xself::sync_shim_tables(); !sync.root_error.empty()) + failure += "; root rollback failed: " + sync.root_error; + return std::unexpected(std::move(failure)); } + auto committed = applied->commit(); + if (!committed) return committed; + for (const auto& change : changes) + if (change.remove) xvm::prune_empty_asset_dirs(change.destination, root); + return {}; +} + +std::expected detach_current_subos_(const std::string& target, + const std::string& version) { + auto active = Config::workspace(); + auto installed = Config::workspace_installed(); + detach_selection_(Config::versions(), active, installed, target, version); + return publish_removal_state_(Config::versions(), std::move(active), std::move(installed)); } bool apply_subos_env_ops_(const std::vector& operations, @@ -2090,512 +2087,278 @@ bool apply_subos_env_ops_(const std::vector& operations, return true; } -bool process_xvm_operations_(const PlanNode& node, - const std::filesystem::path& dataDir, - mcpplibs::xpkg::PackageExecutor& executor, - bool useAfterInstall) { - auto xvm_ops = executor.xvm_operations(); - - // Before the early return below. A package that declares only env and - // registers nothing with xvm has an empty registration batch, and would - // otherwise install cleanly with its declarations dropped on the floor -- - // the exact shape where "nothing happened" and "it worked" look alike. - if (!apply_subos_env_ops_(xvm_ops, node)) return false; - - auto& paths = Config::paths(); - auto& scopedDb = Config::versions_mut(); - auto& scopedWorkspace = Config::workspace_mut(); - auto& scopedInstalled = Config::workspace_installed_mut(); - const auto artifactSubosDir = - Config::xvm_artifact_subos_dir(); - const auto artifactBinDir = artifactSubosDir / "bin"; - const auto sysroot_lib = artifactSubosDir / "lib"; - const auto sysroot_include = - artifactSubosDir / "usr" / "include"; - - // Locate xlings binary for shim creation -#ifdef _WIN32 - auto xlings_bin = paths.homeDir / "bin" / "xlings.exe"; - constexpr std::string_view shim_ext = ".exe"; -#else - auto xlings_bin = paths.homeDir / "bin" / "xlings"; - constexpr std::string_view shim_ext = ""; -#endif - if (!std::filesystem::exists(xlings_bin)) - xlings_bin = paths.homeDir / "xlings"; - - // Default-index versions keep their historical bare keys, other repos - // their namespace -- and a release already on disk keeps whichever it - // was written with (effective_version_namespace_), so install and exact - // removal resolve the same keys. - const auto versionNamespace = - effective_version_namespace_(scopedDb, node, dataDir); - - auto registration = normalize_xpkg_registration_plan( - node, xvm_ops, versionNamespace, dataDir, useAfterInstall); +bool process_xvm_operations_(const PlanNode& node, const std::filesystem::path& dataDir, + mcpplibs::xpkg::PackageExecutor& executor, bool useAfterInstall, + const home::layers::BorrowPlan* borrowed) { + namespace mat = xvm::materialize; + const auto xvmOps = executor.xvm_operations(); + const auto dbBefore = Config::versions(); + const auto workspaceBefore = Config::workspace(); + const auto installedBefore = Config::workspace_installed(); + auto candidateDb = dbBefore; + auto candidateWorkspace = workspaceBefore; + auto candidateInstalled = installedBefore; + const auto scope = Config::xvm_artifact_subos_dir(); + const auto homeDir = Config::paths().homeDir.string(); + const auto versionNamespace = effective_version_namespace_(candidateDb, node, dataDir); + auto registration = + normalize_xpkg_registration_plan(node, xvmOps, versionNamespace, dataDir, useAfterInstall); if (!registration) { - log::warn( - "xvm registration normalization failed for {}@{}: {} " - "(operation={}, target='{}', version='{}')", - node.canonicalName.empty() - ? canonical_package_name(node.namespaceName, node.name) - : node.canonicalName, - node.version, - registration.error().message, - registration.error().operationIndex, - registration.error().target, - registration.error().version); + log::error("xvm registration refused for {}@{}: {}", node.name, node.version, + registration.error().message); return false; } - const bool hasRemoval = std::ranges::any_of( - xvm_ops, - [](const auto& operation) { - return operation.op == "remove" - || operation.op == "remove_all"; - }); - if (!hasRemoval - && registration->batch.nodes.empty() - && registration->batch.headers.empty() - && registration->effects.empty()) { - return true; + const bool declarativeBorrow = + borrowed && xvmOps.empty() && (node.pkgType == 1 || node.pkgType == 4); + if (borrowed) { + auto source = home::layers::read_source_snapshot(node.sourceHome, node.sourceScope); + if (!source) { + log::error("{}", source.error()); + return false; + } + auto checked = home::layers::plan_borrow_package( + *source, dbBefore, registration->batch.provider, node.version); + if (!checked || !*checked || (**checked).requestedPayload != node.borrowedPayload) { + log::error("{}: the checked system release changed before configuration", node.name); + return false; + } + for (const auto& [target, imported] : borrowed->registrations) { + const bool requested = borrowed->requestedMembers.contains(target); + if (requested && !declarativeBorrow) + continue; + auto& into = candidateDb[target]; + into.type = imported.type; + into.filename = imported.filename; + for (const auto& [key, value] : imported.versions) + into.versions[key] = value; + for (const auto& [peer, edges] : imported.bindings) + for (const auto& [key, linked] : edges) + into.bindings[peer][key] = linked; + } + for (const auto& [target, key] : borrowed->members) { + if (borrowed->requestedMembers.contains(target) && !declarativeBorrow) + continue; + auto& versions = candidateInstalled[target]; + if (std::ranges::find(versions, key) == versions.end()) + versions.push_back(key); + if (useAfterInstall || !candidateWorkspace.contains(target)) + candidateWorkspace[target] = key; + } } - - xvm::RemovalContext removalContext; - if (hasRemoval) { - auto snapshot = snapshot_xpkg_removal_context( - scopedDb, scopedWorkspace, xvm_ops, - registration->batch.provider, - registration->batch.providerVersion); - if (!snapshot) { - // Selection runs before any mutation, so nothing has moved yet. - log::error("{}", xvm::render( - xvm::describe( - snapshot.error(), - std::format("{}@{}", - registration->batch.provider, - registration->batch.providerVersion)), - /*nothingChanged=*/true)); + xvm::RemovalContext removal; + if (std::ranges::any_of( + xvmOps, [](const auto& op) { return op.op == "remove" || op.op == "remove_all"; })) { + auto selected = snapshot_xpkg_removal_context(candidateDb, candidateWorkspace, xvmOps, + registration->batch.provider, + registration->batch.providerVersion); + if (!selected) { + log::error("{}", xvm::render(xvm::describe(selected.error(), node.name), true)); return false; } - removalContext = std::move(*snapshot); + removal = std::move(*selected); } - - const auto dbBeforeRemoval = scopedDb; - const auto workspaceBeforeRemoval = scopedWorkspace; auto metadata = apply_xpkg_xvm_metadata_batch( - scopedDb, - scopedWorkspace, - scopedInstalled, - xvm_ops, - removalContext, - *registration); + candidateDb, candidateWorkspace, candidateInstalled, xvmOps, removal, *registration); if (!metadata) { - const auto owner = std::format("{}@{}", - registration->batch.provider, - registration->batch.providerVersion); - // apply_xpkg_xvm_metadata_batch works on copies and only swaps them - // in on success, so a failure here has left the stored state alone. - if (std::holds_alternative( - metadata.error())) { - log::error("{}", xvm::render( - xvm::describe( - std::get(metadata.error()), owner), - /*nothingChanged=*/true)); - } else { - log::error("{}", xvm::render( - xvm::describe( - std::get(metadata.error()), owner), - /*nothingChanged=*/true)); - } + if (std::holds_alternative(metadata.error())) + log::error( + "{}", + xvm::render(xvm::describe(std::get(metadata.error()), node.name), + true)); + else + log::error("{}", + xvm::render(xvm::describe(std::get(metadata.error()), + node.name), + true)); return false; } - - cleanup_removed_xvm_library_artifacts( - sysroot_lib, - dbBeforeRemoval, - scopedDb, - metadata->removal); - // Re-registration replaces the release's declarations wholesale, so a - // destination the new version no longer declares is exactly as removed as - // one from an uninstalled package -- and was exactly as leaked. This is - // also what makes a granularity change converge: when a package that used - // to claim a whole directory starts declaring its contents per file, the - // old directory link is reclaimed here and `place_asset` unwraps whatever - // is left into a real directory, in either install order. - cleanup_removed_xvm_file_artifacts( - artifactSubosDir, - Config::paths().dataDir / "xpkgs", - dbBeforeRemoval, - scopedDb, - scopedWorkspace, - metadata->removal); - - // Removal takes the removed release's headers out of the sysroot. When - // it then falls back to a surviving release, nothing put that release's - // headers back -- the sysroot ended up with none at all, and `xlings use` - // could not repair it because switching to an already-active version is - // a no-op. Re-materialize whatever the fallback made active. - for (const auto& [target, version] : scopedWorkspace) { - auto beforeIt = workspaceBeforeRemoval.find(target); - if (beforeIt != workspaceBeforeRemoval.end() - && beforeIt->second == version) { - continue; // unchanged - } - auto infoIt = scopedDb.find(target); - if (infoIt == scopedDb.end()) continue; - auto dataIt = infoIt->second.versions.find(version); - if (dataIt == infoIt->second.versions.end()) continue; - for (const auto& asset : - xvm::group_header_assets(scopedDb, target, version)) { - xvm::install_headers(asset, sysroot_include); - } - if (const auto placement = xvm::library_placement( - scopedDb, target, version, Config::paths().homeDir.string()); - !placement.empty()) { - xvm::place_library(placement.source, placement.name, sysroot_lib); - } - if (const auto file = xvm::file_placement( - scopedDb, target, version, Config::paths().homeDir.string()); - !file.empty()) { - xvm::place_asset(file.source, - Config::paths().subosDir / file.destination); + if (!node.sourceHome.empty()) { + for (const auto& member : metadata->registered) { + auto target = candidateDb.find(member.target); + if (target == candidateDb.end()) + return false; + auto version = target->second.versions.find(member.version); + if (version == target->second.versions.end()) + return false; + version->second.sourceHome = node.sourceHome.string(); + version->second.sourceScope = node.sourceScope; } } - - // Written after the batch, not inside it: the batch owns the group model - // and must not be taught a legacy field. This runs against the committed - // scopedDb, so a package whose own name is not a registered target simply - // gets no marker rather than a phantom entry. - if (const auto headerVersion = - xvm::make_ns_version(versionNamespace, node.version); - attach_legacy_header_dir( - scopedDb, node.name, headerVersion, metadata->effects) == 0) { - const bool declaresHeaders = std::ranges::any_of( - metadata->effects, [](const XpkgFilesystemEffect& effect) { - return effect.kind == XpkgFilesystemEffectKind::InstallHeaders; - }); - if (declaresHeaders) { - log::debug( - "[xim] headers declared but '{}@{}' is not a registered " - "target; `xlings use` will not swap them", - node.name, headerVersion); - } + attach_legacy_header_dir(candidateDb, node.name, + xvm::make_ns_version(versionNamespace, node.version), + metadata->effects); + xvm::Workspace selectedFiles; + for (const auto& member : metadata->registered) { + const auto active = candidateWorkspace.find(member.target); + if (active != candidateWorkspace.end() && active->second == member.version) + selectedFiles[member.target] = member.version; } - - // Places this batch's filesystem effects (headers, file assets, - // libraries, shims) into ONE subos's sysroot. Extracted so a - // registration rewrite can be replayed against every OTHER subos that - // pins this version too (#586) without duplicating the per-effect-kind - // logic -- the call below for the CURRENT subos is unchanged from - // before the extraction. - // - // `ws` is what gates every `resolved->active` check inside - // `resolve_xpkg_filesystem_effect` and the shim branch's own lookup: - // passing another subos's workspace here is what makes this only ever - // place what THAT subos has made active, never something of ours. - // `scopedDb` (the version DB), `xlings_bin` and `shim_ext` stay captured - // from the enclosing scope -- they describe the shared payload store and - // the one home-level entry binary, neither of which is per-subos. - // No `installed[]` parameter: no effect kind placed here needs it, - // unlike the caller's own per-subos state (`Config::workspace_ - // installed()`), which activation elsewhere in this function does read. - auto place_effects_into = [&](const std::filesystem::path& subosDir, - const xvm::Workspace& ws) { - const auto binDir = subosDir / "bin"; - const auto libDir = subosDir / "lib"; - const auto includeDir = subosDir / "usr" / "include"; - - for (const auto& effect : metadata->effects) { - auto resolved = resolve_xpkg_filesystem_effect( - scopedDb, ws, effect); - if (!resolved) { - log::warn( - "validated xvm effect target disappeared or changed kind: " - "{}@{}", - effect.target, effect.version); - continue; - } - if (resolved->kind - == XpkgFilesystemEffectKind::InstallHeaders) { - if (!resolved->active) { - // Installing a non-active version must not disturb the - // sysroot: `xlings use` is what moves headers, and it cannot - // undo this because switching to an already-active version - // is a no-op. - log::debug("[xim] headers for {}@{} not installed: not the " - "active version", resolved->target, resolved->version); - continue; - } - xvm::install_headers( - resolved->sourceDir, includeDir); - continue; - } - if (resolved->kind - == XpkgFilesystemEffectKind::RemoveHeaders) { - xvm::remove_headers( - resolved->sourceDir, includeDir); - continue; - } - if (resolved->kind - == XpkgFilesystemEffectKind::ProgramShim) { - // A shim is only meaningful for a name that has an active - // version -- that is what shim_dispatch resolves against, and - // without one the file can only ever print "no active version - // of 'X' in current subos". Writing it anyway is what produced - // doctor's `orphan shim`, an error the user could not fix: - // `--fix` deleted the file and the next install recreated it. - // - // Registration deliberately withholds activation from a release - // whose group already has an active member (see - // registration.cppm, `activateGroup`), so every name that is new - // in that release lands here. The sibling effects below already - // guard on activation; this one did not. - // - // The question is about the NAME, not this version: a second - // version of an active program must not delete or skip the shim - // its active sibling needs. And a name activated later still - // gets its file -- `cmd_use` creates shims for every member of - // the release it switches to (xvm/commands.cppm). - const auto activeIt = ws.find(resolved->target); - const bool nameHasActiveVersion = - activeIt != ws.end() - && !activeIt->second.empty(); - if (!nameHasActiveVersion) { - log::debug( - "[xim] shim for {}@{} not created: no active version of " - "'{}' in this subos", - resolved->target, effect.version, resolved->target); - continue; - } - // The file itself is not written here any more. - // - // `xself::sync_shim_tables()` below derives the whole routing - // table from the workspace as soon as this node's registrations - // are saved -- per node, not once per plan: a later node's hook - // may run this one's command by name (see the PATH note at the - // elfpatch step). That is what removed the second half of this - // block: a project-scope - // mirror into the global bin that nothing recorded and nothing - // could reclaim. Reaching this point still means "this name is - // active here", which is exactly what the table will conclude. - - if (resolved->active - && xvm::is_xlings_binary(resolved->target) - && std::filesystem::exists(xlings_bin) - && !resolved->path.empty() - && !resolved->sourceName.empty()) { - auto activeName = resolved->sourceName; - if (!shim_ext.empty() - && !activeName.ends_with(shim_ext)) { - activeName += shim_ext; - } - const auto activeBin = - std::filesystem::path(resolved->path) - / activeName; - if (std::filesystem::exists(activeBin)) { - // The same writer `xlings use xlings ` goes through. - // Two independent replacements of the one file every shim - // dispatches through is how a home ends up running a - // client nobody chose -- see entry_binary.cppm. - xself::replace_entry_binary( - activeBin, xlings_bin, - std::format("{}@{}", resolved->target, effect.version), - effect.version); - } - xself::compat::v0_4_8::cleanup_legacy_alias_shims( - binDir, xlings_bin); - } - continue; - } - if (resolved->kind == XpkgFilesystemEffectKind::FileAsset) { - if (!resolved->active) { - log::debug("[xim] file asset {}@{} not placed: not the " - "active version", resolved->target, - resolved->version); - continue; - } - if (const auto file = xvm::file_placement( - scopedDb, resolved->target, resolved->version, - Config::paths().homeDir.string()); - !file.empty()) { - xvm::place_asset(file.source, - subosDir / file.destination); - } else { - log::warn("[xim] file asset {}@{} declares no usable " - "destination; nothing placed", - resolved->target, resolved->version); - } - continue; - } - if (resolved->kind != XpkgFilesystemEffectKind::Library - || resolved->path.empty()) { - continue; - } - if (!resolved->active) { - // Installing a version that does not become active must not - // disturb the sysroot. `InstallHeaders` has been gated this way - // since 0.4.70; `Library` was not, so installing a second version - // of a package overwrote the active version's library while its - // headers stayed put -- the sysroot then held a library from one - // release beside headers from another, which compiles and fails - // at run time. `xlings use` is what moves libraries. - log::debug("[xim] library {}@{} not placed: not the active " - "version", resolved->target, resolved->version); - continue; - } - - const auto source = - std::filesystem::path(resolved->path) - / resolved->sourceName; - const auto destination = - libDir / resolved->destinationName; - std::filesystem::create_directories(libDir); - std::error_code ec; - if (std::filesystem::exists(destination, ec) - || std::filesystem::is_symlink(destination, ec)) { - std::filesystem::remove(destination, ec); - } - ec.clear(); - if (std::filesystem::exists(source, ec)) { - std::filesystem::create_symlink( - source, destination, ec); - } - } + xvm::reclaim_conflicting_file_bindings(candidateDb, candidateWorkspace, selectedFiles, homeDir); + + auto changes_for = [&](const std::filesystem::path& root, const xvm::Workspace& active, + const xvm::WorkspaceInstalled& oldInstalled) + -> std::expected { + auto claims = mat::collect_claims(dbBefore, oldInstalled, root, root / "lib", homeDir); + if (!claims) + return std::unexpected(claims.error()); + xvm::WorkspaceInstalled activeOnly; + for (const auto& [target, key] : active) + activeOnly[target] = {key}; + auto desired = mat::collect_claims(candidateDb, activeOnly, root, root / "lib", homeDir, mat::ClaimSource::Present); + if (!desired) + return std::unexpected(desired.error()); + auto obsolete = mat::obsolete_assets(*claims, *desired); + if (!obsolete) return std::unexpected(obsolete.error()); + auto changes = std::move(*obsolete); + for (const auto& next : *desired) + changes.push_back({next.source, next.destination, false}); + return mat::preflight_materialization(changes, *claims, root); }; - - place_effects_into(artifactSubosDir, scopedWorkspace); - - // A registration rewrite (e.g. `remove` + `install` for the same - // version, or a recipe update that changes a release's declared paths) - // must not leave every OTHER subos that pins this version with dangling - // sysroot links -- the loop above only ever touched the subos this - // command is running in. `find_subos_pinning_version` is the same - // predicate `xlings remove`'s "pinned by" reporting already uses - // (xim/commands.cpp): active OR installed[], either pins the payload - // just as hard. It only ever considers `/subos/*` (see - // `load_subos_snapshots`), never a project-scoped subos, so resolving - // each name against `homeDir / "subos" / name` below matches exactly - // what produced it. - // - // Deliberately NOT `ScopedSubosOverride` / `Config::set_active_subos_ - // override` here, and not just because xim.commands (which owns the - // guard) imports xim.installer, making that a module cycle. Measured - // directly: the override's own reload (`Config::reload_state()`, needed - // on every transition or `Config::workspace()` reads one subos stale -- - // see its comment in config.cpp) re-reads `globalVersions_` from - // `~/.xlings.json` on disk. That clobbers `scopedDb` -- a REFERENCE to - // that same member -- with whatever was there before this call, because - // `Config::save_versions()` for THIS batch has not run yet (it happens - // once, after this whole loop). The library branch below then resolves - // `resolved->path` from the stale pre-batch entry, silently placing - // nothing for every other subos while headers still moved (their source - // path rides on `effect.sourceDir`, captured before the DB write, not - // looked up through `scopedDb`). So: read each other subos's own - // workspace file directly -- read-only, and the version DB it is - // resolved against stays the one this batch just registered. - // `unreadable` names subos this scan could not even open to check -- - // a subos whose file was already corrupted BEFORE this batch ran, so it - // never had the chance to be named as pinning anything (load_subos_ - // snapshots skips it entirely, per its own doc comment). It might be - // the one pinning this exact version -- there is no way to tell -- so - // warn about it too, the same way the loop below warns about a file - // that turns out unreadable mid-scan. - std::vector unreadableSubos; - auto pinningSubos = xlings::profile::find_subos_pinning_version( - Config::paths().homeDir, node.name, node.version, &unreadableSubos); - for (const auto& name : unreadableSubos) { - if (name == Config::paths().activeSubos) continue; - const auto configPath = - Config::paths().homeDir / "subos" / name / ".xlings.json"; - log::warn( - "{}: its workspace file ({}) could not be read, so it is not " - "known whether it pins {}@{} -- its sysroot was NOT refreshed; " - "run `xlings self doctor --subos {}` to repair it", - name, configPath.string(), node.name, node.version, name); + auto prepared = changes_for(scope, candidateWorkspace, installedBefore); + if (!prepared) { + log::error("{}: sysroot update refused before metadata changed: {}", node.name, + prepared.error()); + return false; } - for (const auto& otherSubos : pinningSubos) { - if (otherSubos == Config::paths().activeSubos) continue; - const auto otherSubosDir = - Config::paths().homeDir / "subos" / otherSubos; - const auto otherSubosConfig = otherSubosDir / ".xlings.json"; - auto sws = load_workspace_file_checked_(otherSubosConfig); - if (!sws) { - // `find_subos_pinning_version` just said this subos pins - // `node.name`@`node.version`, which only happens by reading a - // valid workspace object out of this exact file -- so a failure - // reading it again here, moments later, means the file is - // unreadable (missing, truncated, invalid JSON), not that the - // subos legitimately has an empty workspace. Silently - // continuing would leave that subos's sysroot on whatever it - // pointed at before -- possibly dangling, per #586 -- with - // nothing in the log to say so. `self doctor --subos ` - // is the same repair `xlings remove`'s cross-subos reporting - // already points at for a broken payload; it lands in this - // same release. - log::warn( - "{}: pins {}@{}, but its workspace file could not be read " - "({}) -- its sysroot was not refreshed; run `xlings self " - "doctor --subos {}` to repair it", - otherSubos, node.name, node.version, - otherSubosConfig.string(), otherSubos); - continue; - } - place_effects_into(otherSubosDir, sws->active); + std::vector applied; + auto current = prepared->execute(); + if (!current) { + log::error("{}", current.error()); + return false; } + applied.push_back(std::move(*current)); - cleanup_removed_xvm_program_artifacts( - artifactBinDir, - dbBeforeRemoval, - scopedDb, - scopedInstalled, - metadata->removal); - - // Announce any owner-less entry this install took over. - // - // Adoption replaces the contents recorded for a name@version that an - // older client wrote without ownership (xvm/registration.cppm). It is the - // right thing to do — the refusal it replaced left the user with no exit - // (#422) — but it is still a payload changing behind a name, so it is - // said out loud rather than applied silently. - for (const auto& member : metadata->registered) { - if (!member.adoptedLegacy) continue; - log::warn("[xvm] adopted a pre-ownership registration: {}@{} ('{}' changed)", - member.target, member.version, member.adoptedLegacyField); + std::vector unreadable; + const auto pinning = profile::find_subos_pinning_version(Config::paths().homeDir, node.name, + node.version, &unreadable); + for (const auto& name : unreadable) + log::warn("{}: unreadable workspace; its sysroot was not refreshed; run xlings self doctor --subos {}", name, name); + for (const auto& name : pinning) { + if (name == Config::paths().activeSubos) + continue; + const auto root = Config::paths().homeDir / "subos" / name; + if (root == scope) + continue; + auto workspace = load_workspace_file_checked_(root / ".xlings.json"); + if (!workspace) { + log::error("{}: unreadable workspace", name); + return false; + } + auto other = changes_for(root, workspace->active, workspace->installed); + if (!other) { + log::error("{}: {}", name, other.error()); + return false; + } + auto changed = other->execute(); + if (!changed) { + log::error("{}: {}", name, changed.error()); + return false; + } + applied.push_back(std::move(*changed)); } + if (!apply_subos_env_ops_(xvmOps, node)) + return false; - // Announce anything the batch left behind rather than refused over. - // - // These are names an older client registered for this same payload on a - // platform whose recipe produces them and this one's does not (a Windows - // llvm leaves `cl`, `lib`, `link`, `rc`). They keep their record and lose - // their edge to this release; `self doctor` reports them and can prune - // them. Silence here would make a release quietly shed members. - for (const auto& member : metadata->detachedLegacy) { - log::warn("[xvm] detached a legacy entry this platform no longer " - "registers: {} (run `xlings self doctor --fix` to clear it)", - xvm::display_coordinate(member.target, member.version)); + const auto versionPath = &Config::versions_mut() == &Config::global_versions() + ? Config::paths().homeDir / ".xlings.json" + : Config::project_state_path(); + const auto workspacePath = Config::workspace_config_path(false); + auto oldVersionDocument = home::read_json_for_update(versionPath); + auto oldWorkspaceDocument = home::read_json_for_update(workspacePath); + if (!oldVersionDocument || !oldWorkspaceDocument) { + log::error("cannot observe metadata before publishing {}", node.name); + return false; } - - if (!metadata->removal.removed.empty() - || !metadata->registered.empty()) { + auto restore = [&]() { + Config::versions_mut() = dbBefore; + Config::workspace_mut() = workspaceBefore; + Config::workspace_installed_mut() = installedBefore; + try { + platform::write_file_atomic(versionPath.string(), oldVersionDocument->dump(2)); + if (workspacePath != versionPath) + platform::write_file_atomic(workspacePath.string(), oldWorkspaceDocument->dump(2)); + } catch (const std::exception& error) { + log::error("metadata rollback failed: {}", error.what()); + } + }; + Config::versions_mut() = std::move(candidateDb); + Config::workspace_mut() = std::move(candidateWorkspace); + Config::workspace_installed_mut() = std::move(candidateInstalled); + try { Config::save_versions(); Config::save_workspace(); - // The routing table is derived from the workspace, so it is written - // in the same breath. In project scope this also carries the - // project's command names into the global subos's table -- the job - // `mirror_shim_to_global_bin` used to do, now with an owner - // (`knownProjects`) and a reclamation path. - xself::sync_shim_tables(); + auto versions = home::read_json_for_update(versionPath); + auto workspace = home::read_json_for_update(workspacePath); + if (!versions || !workspace || + versions->value("versions", nlohmann::json::object()) != + xvm::versions_to_json(Config::versions())) + throw std::runtime_error("version metadata was not saved"); + const auto persisted = xvm::subos_workspace_from_json(workspace->at("workspace")); + if (persisted.active != Config::workspace() || + persisted.installed != Config::workspace_installed()) + throw std::runtime_error("scope metadata was not saved"); + if (const auto sync = xself::sync_shim_tables(); !sync.root_error.empty()) + throw std::runtime_error(sync.root_error); + } catch (const std::exception& error) { + log::error("{}: metadata publication failed: {}", node.name, error.what()); + restore(); + for (auto& changed : applied) { + if (const auto rollback = changed.rollback(); !rollback) + log::error("derived rollback failed: {}", rollback.error()); + } + if (const auto sync = xself::sync_shim_tables(); !sync.root_error.empty()) + log::error("root rollback refresh failed: {}", sync.root_error); + return false; + } + for (const auto& effect : metadata->effects) { + if (effect.kind != XpkgFilesystemEffectKind::ProgramShim) + continue; + const auto resolved = + resolve_xpkg_filesystem_effect(Config::versions(), Config::workspace(), effect); + if (!resolved || !resolved->active || !xvm::is_xlings_binary(resolved->target)) + continue; + const auto entry = Config::paths().homeDir / "bin" / ("xlings" + std::string(platform::exe_suffix)); + auto sourceName = resolved->sourceName; + if (!sourceName.ends_with(platform::exe_suffix)) + sourceName += platform::exe_suffix; + const auto source = std::filesystem::path(resolved->path) / sourceName; + if (std::filesystem::exists(source) && + !xself::replace_entry_binary(source, entry, resolved->target + "@" + effect.version, + effect.version, + {registration->batch.provider, node.sourceHome})) { + restore(); + for (auto& changed : applied) { + if (const auto rollback = changed.rollback(); !rollback) + log::error("derived rollback failed: {}", rollback.error()); + } + if (const auto sync = xself::sync_shim_tables(); !sync.root_error.empty()) + log::error("root rollback refresh failed: {}", sync.root_error); + return false; + } } + for (auto& changed : applied) { + if (const auto committed = changed.commit(); !committed) { + log::error("{}: derived backup cleanup failed: {}", node.name, committed.error()); + return false; + } + } + for (const auto& member : metadata->registered) + if (member.adoptedLegacy) + log::warn("[xvm] adopted a pre-ownership registration: {}@{}", member.target, + member.version); + for (const auto& member : metadata->detachedLegacy) + log::warn("[xvm] detached legacy registration {}; run `xlings self doctor`", member.target); return true; } -bool run_config_hook_(const PlanNode& node, const std::filesystem::path& dataDir, mcpplibs::xpkg::PackageExecutor& executor, mcpplibs::xpkg::ExecutionContext& ctx, std::function onStatus, bool useAfterInstall, std::string* failureMessage) { - if (!executor.has_hook(mcpplibs::xpkg::HookType::Config)) return true; +bool run_config_hook_(const PlanNode& node, const std::filesystem::path& dataDir, mcpplibs::xpkg::PackageExecutor& executor, mcpplibs::xpkg::ExecutionContext& ctx, std::function onStatus, bool useAfterInstall, std::string* failureMessage, + const home::layers::BorrowPlan* borrowed) { + if (!executor.has_hook(mcpplibs::xpkg::HookType::Config)) + return process_xvm_operations_(node, dataDir, executor, useAfterInstall, borrowed); if (onStatus) { onStatus({ node.name, InstallPhase::Configuring, 0.8f, "" }); } - ScopedCurrentDir_ configCwd(ctx.install_dir); + ScopedCurrentDir_ configCwd(borrowed ? std::filesystem::path{} : ctx.install_dir); ctx.hook_log = hook_log_path_( node.canonicalName.empty() ? node.name : node.canonicalName, node.version, "config"); @@ -2612,7 +2375,7 @@ bool run_config_hook_(const PlanNode& node, const std::filesystem::path& dataDir } return false; } - if (!process_xvm_operations_(node, dataDir, executor, useAfterInstall)) { + if (!process_xvm_operations_(node, dataDir, executor, useAfterInstall, borrowed)) { if (failureMessage) *failureMessage = "config hook failed"; return false; } @@ -2717,12 +2480,12 @@ std::filesystem::path Installer::locate_dep_install_dir_(const InstallPlan& plan std::string_view depRef) { const auto* n = detail_::dep_node_for_(plan, consumer, depRef); if (!n) return {}; - auto root = n->storeRoot.empty() ? (dataDir / "xpkgs") : n->storeRoot; - return root / detail_::effective_store_name_(*n) / n->version; + return node_payload_path(*n, dataDir); } -std::expected Installer::execute(const InstallPlan& plan, const DownloaderConfig& dlConfig, std::function onStatus, InstallRequestHandler onInstallRequests, DownloadProgressSink onProgressState, CancellationToken* cancel, bool useAfterInstall, bool reconfig) { +std::expected Installer::execute(const InstallPlan& requestedPlan, const DownloaderConfig& dlConfig, std::function onStatus, InstallRequestHandler onInstallRequests, DownloadProgressSink onProgressState, CancellationToken* cancel, bool useAfterInstall, bool reconfig) { + auto plan = requestedPlan; if (plan.has_errors()) { return std::unexpected( std::format("plan has errors: {}", plan.errors[0])); @@ -2758,6 +2521,55 @@ std::expected Installer::execute(const InstallPlan& plan, con auto platform = detect_platform_(); + std::optional systemSnapshot; + std::map borrowedPlans; + auto systemLayer = home::read_system_layer(); + if (!systemLayer) return std::unexpected(systemLayer.error()); + if (*systemLayer) { + std::error_code ec; + if (!std::filesystem::equivalent(**systemLayer, Config::paths().homeDir, ec)) { + auto physicalHome = home::domain_producer_source::persisted_home(**systemLayer); + if (!physicalHome) return std::unexpected(physicalHome.error()); + auto snapshot = home::layers::read_source_snapshot(*physicalHome); + if (!snapshot) return std::unexpected(snapshot.error()); + systemSnapshot.emplace(std::move(*snapshot)); + } + } + if (systemSnapshot) { + for (auto& node : plan.nodes) { + if (node.isSystemPM || node.pkgType == 3) continue; + const auto localPayload = node_payload_path(node, dataDir); + std::error_code ec; + const auto localStatus = std::filesystem::symlink_status(localPayload, ec); + if (ec && ec != std::errc::no_such_file_or_directory) + return std::unexpected(localPayload.string() + ": " + ec.message()); + auto sourceMount = home::domain_producer_source::borrowed_mount(localPayload); + if (!sourceMount) return std::unexpected(sourceMount.error()); + if (localStatus.type() != std::filesystem::file_type::not_found && !*sourceMount) continue; + const auto provider = node.canonicalName.empty() + ? canonical_package_name(node.namespaceName, node.name) : node.canonicalName; + auto choice = home::layers::plan_borrow_package( + *systemSnapshot, Config::versions(), provider, node.version); + if (!choice) return std::unexpected(choice.error()); + if (!*choice) continue; + auto& selected = **choice; + if (const auto revision = payload_revision_verdict(selected.requestedPayload, node.revision); + revision.stale) { + return std::unexpected(provider + "@" + node.version + ": system payload " + + revision.reason() + "; its owner must reinstall with --reconfig"); + } + if (classify_payload_platform(selected.requestedPayload) == PayloadPlatform::Foreign) + return std::unexpected(provider + ": system payload is for another platform"); + if (stamped_incomplete(selected.requestedPayload)) + return std::unexpected(provider + ": system payload has a recorded failure; its owner must --reconfig"); + node.borrowedPayload = selected.requestedPayload; + node.sourceHome = systemSnapshot->sourceHome; + node.sourceScope = systemSnapshot->scope; + node.storeRoot = selected.requestedPayload.parent_path().parent_path(); + borrowedPlans.emplace(detail_::plan_key_(node), std::move(selected)); + } + } + // Phase 1: Collect download tasks for non-installed packages log::debug("installer: {} node(s) in plan, dataDir={}", plan.nodes.size(), dataDir.string()); std::vector dlTasks; @@ -2770,11 +2582,11 @@ std::expected Installer::execute(const InstallPlan& plan, con std::unordered_set refusedNodes; for (auto& node : plan.nodes) { currentPlanKey = detail_::plan_key_(node); - if (node.alreadyInstalled) continue; + if (node.alreadyInstalled || !node.borrowedPayload.empty()) continue; const std::string hostArch = mcpplibs::xpkg::normalize_arch(detail_::detect_arch_()); - auto pkg = load_native_recipe(node.pkgFile); + auto pkg = load_native_recipe(node.pkgFile, node.canonicalName); if (!pkg) { log::warn("skipping {}: {}", node.name, pkg.error()); continue; @@ -2954,28 +2766,14 @@ std::expected Installer::execute(const InstallPlan& plan, con onStatus({ node.name, InstallPhase::Installing, 0.5f, "" }); } - // Create executor and run hooks - auto execResult = mcpplibs::xpkg::create_executor(node.pkgFile); - if (!execResult) { - log::error("failed to create executor for {}: {}", - node.name, execResult.error()); - if (onStatus) { - onStatus({ node.name, InstallPhase::Failed, 0.0f, - execResult.error() }); - } - continue; - } - - auto& executor = *execResult; - executor.set_log_level(std::string(log::level_string())); - // Build execution context mcpplibs::xpkg::ExecutionContext ctx; ctx.pkg_name = node.name; ctx.version = node.version; ctx.platform = platform; auto targetRoot = node.storeRoot.empty() ? (dataDir / "xpkgs") : node.storeRoot; - ctx.install_dir = targetRoot / detail_::effective_store_name_(node) / node.version; + ctx.install_dir = node_payload_path(node, dataDir); + const bool borrowedPayload = !node.sourceHome.empty(); detail_::configure_xpkg_execution_artifact_paths_( ctx); detail_::configure_dependency_store_roots_(ctx, targetRoot); @@ -3037,10 +2835,7 @@ std::expected Installer::execute(const InstallPlan& plan, con const auto* chosen = detail_::dep_node_for_(plan, node, dep_spec); if (!chosen) continue; const auto& depNode = *chosen; - auto depRoot = depNode.storeRoot.empty() - ? (dataDir / "xpkgs") : depNode.storeRoot; - auto depInstallDir = depRoot - / detail_::effective_store_name_(depNode) / depNode.version; + auto depInstallDir = node_payload_path(depNode, dataDir); // Recorded for EVERY runtime dep, declared exports or not. // @@ -3101,6 +2896,49 @@ std::expected Installer::execute(const InstallPlan& plan, con } auto planKey = detail_::plan_key_(node); + if (borrowedPayload) { + const auto& selected = borrowedPlans.at(planKey); + auto record = home::read_json_for_update(ctx.install_dir / ".xlings-resolution.json"); + if (!record) return std::unexpected(record.error()); + try { + ctx.resolved_deps.clear(); + ctx.deps_exports.clear(); + ctx.runtime_deps_list.clear(); + for (const auto& entry : record->at("deps")) { + mcpplibs::xpkg::ResolvedDep dep; + dep.spec = entry.at("spec").get(); + dep.name = entry.at("name").get(); + dep.version = entry.at("version").get(); + dep.install_dir = entry.at("install_dir").get(); + dep.libdirs = entry.at("libdirs").get>(); + dep.source = "system-layer-recorded"; + std::error_code ec; + const auto actual = std::filesystem::canonical(dep.install_dir, ec); + if (ec || std::ranges::find(selected.payloads, actual) == selected.payloads.end()) + return std::unexpected("recorded dependency escaped the checked system closure"); + mcpplibs::xpkg::DepExport exports; + exports.libdirs = dep.libdirs; + const auto matching = std::ranges::find_if(plan.nodes, [&](const auto& candidate) { + return candidate.canonicalName == dep.name && candidate.version == dep.version; + }); + if (matching != plan.nodes.end()) { + exports.abi = matching->exports.abi; + if (!matching->exports.loader.empty()) + exports.loader = (actual / matching->exports.loader).string(); + } + if (!exports.loader.empty() || !exports.libdirs.empty()) + ctx.deps_exports[dep.spec] = std::move(exports); + ctx.runtime_deps_list.push_back(dep.spec); + ctx.resolved_deps[dep.spec] = std::move(dep); + } + ctx.deps_list = ctx.runtime_deps_list; + for (const auto& spec : ctx.build_deps_list) + if (std::ranges::find(ctx.deps_list, spec) == ctx.deps_list.end()) ctx.deps_list.push_back(spec); + } catch (const std::exception& error) { + return std::unexpected("cannot read checked system dependency context: " + + std::string(error.what())); + } + } auto dlIt = downloadResults.find(planKey); if (plannedDownloads.contains(planKey) && dlIt == downloadResults.end()) { @@ -3120,6 +2958,28 @@ std::expected Installer::execute(const InstallPlan& plan, con // relative paths and determining project install locations. ctx.run_dir = std::filesystem::current_path(); + if (dlIt != downloadResults.end()) ctx.install_file = dlIt->second.localFile; + // Create executor and run hooks + std::vector hookLogs; + for (const auto hook : {"installed", "build", "install", "config", "uninstall"}) { + hookLogs.push_back(detail_::hook_log_path_( + node.canonicalName.empty() ? node.name : node.canonicalName, + node.version, hook)); + } + auto execResult = lua_boundary::create_executor(node.pkgFile, ctx, hookLogs, borrowedPayload); + if (!execResult) { + log::error("failed to create executor for {}: {}", + node.name, execResult.error()); + if (onStatus) { + onStatus({ node.name, InstallPhase::Failed, 0.0f, + execResult.error() }); + } + continue; + } + + auto& executor = *execResult; + executor.set_log_level(std::string(log::level_string())); + // Whether install() will run for this node. Extracting beside the // archive in the shared runtime directory is the install hook's // contract, not a general one: the example at xpkg-manifest-v1 §8.2 @@ -3134,15 +2994,14 @@ std::expected Installer::execute(const InstallPlan& plan, con executor.has_hook(mcpplibs::xpkg::HookType::Install); if (dlIt != downloadResults.end()) { - ctx.install_file = dlIt->second.localFile; if (hasInstallHook && detail_::is_archive_(dlIt->second.localFile)) { if (onStatus) { onStatus({ node.name, InstallPhase::Extracting, 0.35f, "" }); } // Extract into the same runtime dir as the download. - auto runtimeDir = dlIt->second.localFile.parent_path(); + auto runtimeDir = ctx.install_file.parent_path(); auto extracted = extract_archive_detailed( - dlIt->second.localFile, runtimeDir); + ctx.install_file, runtimeDir); if (!extracted) { auto error = std::move(extracted).error(); if (evict_invalid_archive_cache_( @@ -3174,7 +3033,7 @@ std::expected Installer::execute(const InstallPlan& plan, con // of the package index, destroying the index entry. // Ensure install_dir exists so hooks can mv/cp into it - { + if (!borrowedPayload) { std::error_code ec; std::filesystem::create_directories(ctx.install_dir, ec); // A genuine failure here (commonly EACCES on a root-owned @@ -3194,6 +3053,11 @@ std::expected Installer::execute(const InstallPlan& plan, con classify_payload_platform(ctx.install_dir); const bool foreignPayload = payloadVerdict == PayloadPlatform::Foreign; + if (foreignPayload && borrowedPayload) { + const auto message = "system-layer payload is for another platform; reinstall it in its owner home"; + if (onStatus) onStatus({node.name, InstallPhase::Failed, 0.0f, message}); + continue; + } if (foreignPayload) { log::warn("{}: installed payload is not for {} — reinstalling", node.name, host_platform_tag()); @@ -3201,7 +3065,7 @@ std::expected Installer::execute(const InstallPlan& plan, con Config::display_path(ctx.install_dir)); } - bool payloadInstalled = node.alreadyInstalled && !foreignPayload; + bool payloadInstalled = (node.alreadyInstalled || borrowedPayload) && !foreignPayload; // Check if already installed via hook if (foreignPayload) { @@ -3310,7 +3174,7 @@ std::expected Installer::execute(const InstallPlan& plan, con // Positive evidence only -- a stamp that predates the `registered` // field yields no verdict, so a home installed by an older client // does not start reinstalling itself. See install_state.cppm. - if (payloadInstalled && !foreignPayload) { + if (payloadInstalled && !foreignPayload && !borrowedPayload) { const auto state = installation_state( ledgerIndex, node.namespaceName, node.name, node.version, ctx.install_dir); @@ -3335,7 +3199,7 @@ std::expected Installer::execute(const InstallPlan& plan, con // `replacement`, which removes the partial tree and puts the old one // back (PayloadReplacement, payload.cppm). std::optional replacement; - if (const auto revision = foreignPayload + if (const auto revision = foreignPayload || borrowedPayload ? RevisionVerdict{} : payload_revision_verdict(ctx.install_dir, node.revision); revision.stale) { @@ -3407,7 +3271,7 @@ std::expected Installer::execute(const InstallPlan& plan, con // would delete the CWD and cause subsequent getcwd() calls // to fail. auto hookCwd = (dlIt != downloadResults.end()) - ? dlIt->second.localFile.parent_path() + ? ctx.install_file.parent_path() : detail_::runtime_dir_(node, dataDir); // fallback to runtime dir detail_::ScopedCurrentDir_ installCwd(hookCwd); @@ -3640,6 +3504,19 @@ std::expected Installer::execute(const InstallPlan& plan, con platform::set_env_variable("PATH", binDir + std::string(1, platform::PATH_SEPARATOR) + curPath); } + // Fail closed (part 2 §2.4 G1b): a payload that needs relocating + // and no patchelf to do it is not installed, whatever elfpatch + // would have said about skipping. + if (!ctx.resolved_deps.empty() && !xim::patchelf_reachable() + && std::ranges::any_of(ctx.resolved_deps, [](const auto& kv) { + return kv.first.find("glibc") != std::string::npos; })) { + const std::string why = "it links the xlings glibc and must be relocated with " + "patchelf, which this host does not have (xlings install " + "xim:patchelf)"; + write_payload_failure_marker(ctx.install_dir, node.version, why); + if (onStatus) onStatus({ node.name, InstallPhase::Failed, 0.0f, why }); + continue; + } auto epResult = executor.apply_elfpatch_auto(); if (epResult.success && !epResult.output.empty()) { log::debug("{}: elfpatch auto: {}", node.name, epResult.output); @@ -3656,41 +3533,6 @@ std::expected Installer::execute(const InstallPlan& plan, con // it replaces is a segmentation fault in the user's terminal // days later, reported as an undefined GLIBC_PRIVATE symbol // that names neither a package nor a version. - // The decision, kept as data next to what it produced. - // - // A log line answers "why is it 2.39 here" only for as long - // as someone still has the log and the store has not moved - // on. Anything that has to be reproduced to be inspected is - // not traceable — and reproducing THIS means recreating a - // store state, which is the thing that varies. - if (!ctx.resolved_deps.empty()) { - nlohmann::json rec; - rec["package"] = std::format("{}@{}", node.name, node.version); - rec["deps"] = nlohmann::json::array(); - // Sorted, so two installs of the same plan produce the - // same bytes and a diff between homes means something. - std::vector specs; - for (const auto& [spec, _] : ctx.resolved_deps) - specs.push_back(spec); - std::ranges::sort(specs); - for (const auto& spec : specs) { - const auto& r = ctx.resolved_deps.at(spec); - rec["deps"].push_back({ - {"spec", r.spec}, - {"name", r.name}, - {"version", r.version}, - {"install_dir", r.install_dir}, - {"libdirs", r.libdirs}, - {"source", r.source}, - }); - } - std::error_code wec; - if (std::filesystem::is_directory(ctx.install_dir, wec)) { - std::ofstream out(ctx.install_dir / ".xlings-resolution.json"); - if (out) out << rec.dump(2) << '\n'; - } - } - if (auto bad = elfcheck::scan_payload(ctx.install_dir); !bad.empty()) { for (const auto& f : bad) { @@ -3723,6 +3565,18 @@ std::expected Installer::execute(const InstallPlan& plan, con } } + if (!borrowedPayload && node.pkgType != 3 /* Config */ && (!payloadInstalled || reconfig)) { + if (auto evidence = detail_::save_resolution_(node, ctx); !evidence) { + log::error("failed to record resolution for {}: {}", node.name, evidence.error()); + write_payload_failure_marker(ctx.install_dir, node.version, evidence.error()); + if (onStatus) { + onStatus({node.name, InstallPhase::Failed, 0.0f, evidence.error()}); + } + continue; + } + } + + std::size_t appliedInstallRequests = 0; // Process deferred pkgmanager.install()/remove() requests synchronously // before config hook, so config can access sub-dependencies { @@ -3732,6 +3586,7 @@ std::expected Installer::execute(const InstallPlan& plan, con log::debug("[{}] deferred {}: {}", node.name, req.op, req.target); } onInstallRequests(reqs); + appliedInstallRequests = reqs.size(); } } @@ -3742,7 +3597,7 @@ std::expected Installer::execute(const InstallPlan& plan, con // NOT take over a workspace slot or get a PATH shim. log::debug("[{}] kind=Build: skipping config hook / workspace activation", node.name); - } else if (!executor.has_hook(mcpplibs::xpkg::HookType::Config) && node.pkgType == 1 /* Script */) { + } else if (!executor.has_hook(mcpplibs::xpkg::HookType::Config) && node.pkgType == 1 /* Script */ && !borrowedPayload) { if (!script::default_config( node, dataDir, @@ -3754,7 +3609,7 @@ std::expected Installer::execute(const InstallPlan& plan, con } continue; } - } else if (!executor.has_hook(mcpplibs::xpkg::HookType::Config) && node.pkgType == 4 /* Subos */) { + } else if (!executor.has_hook(mcpplibs::xpkg::HookType::Config) && node.pkgType == 4 /* Subos */ && !borrowedPayload) { if (!subos::default_config( node, dataDir, @@ -3770,14 +3625,16 @@ std::expected Installer::execute(const InstallPlan& plan, con std::string configFailure; if (!detail_::run_config_hook_( node, dataDir, executor, ctx, onStatus, - useAfterInstall, &configFailure)) { + useAfterInstall, &configFailure, + borrowedPayload ? &borrowedPlans.at(planKey) : nullptr)) { // Same reason as the install hook above, and this one // matters more: config is where registration happens, so // its failure is exactly "payload on disk, ledger empty" -- // the state that used to be indistinguishable from a // package that legitimately registers nothing. - write_payload_failure_marker( - ctx.install_dir, node.version, configFailure); + if (!borrowedPayload) { + write_payload_failure_marker(ctx.install_dir, node.version, configFailure); + } if (onStatus) { onStatus({ node.name, InstallPhase::Failed, 0.0f, std::move(configFailure) }); @@ -3786,7 +3643,18 @@ std::expected Installer::execute(const InstallPlan& plan, con } } - if (node.pkgType != 3 /* Config */) { + const auto allRequests = executor.install_requests(); + if (allRequests.size() < appliedInstallRequests) { + return std::unexpected("hook install request snapshot regressed"); + } + if (onInstallRequests && allRequests.size() > appliedInstallRequests) { + std::vector pendingRequests( + allRequests.begin() + static_cast(appliedInstallRequests), + allRequests.end()); + onInstallRequests(pendingRequests); + } + + if (!borrowedPayload && node.pkgType != 3 /* Config */) { // TODO(config): same soft policy as the install stamp. A // config package with no author-created payload must not // become installed solely because xlings copied metadata into @@ -3849,8 +3717,10 @@ std::expected Installer::execute(const InstallPlan& plan, con const auto registered = count_ledger_registrations( Config::versions(), Config::paths().homeDir.string(), node.namespaceName, node.name, node.version); - write_payload_stamp(ctx.install_dir, node.version, registered, - node.revision); + if (!borrowedPayload) { + write_payload_stamp(ctx.install_dir, node.version, registered, + node.revision); + } // Only here, after every step that can fail: the record is what // lets the next install skip this node, so it must never say @@ -4059,6 +3929,38 @@ std::expected Installer::uninstall(con : (recipeUnavailable ? recipeUnavailableProviderVersion : xvm::strip_namespace(detachVersion)); + bool borrowedRemoval = false; + for (const auto& [registeredTarget, info] : Config::versions()) { + for (const auto& [key, data] : info.versions) { + const auto owner = xvm::recorded_owner(Config::versions(), registeredTarget, key); + if (!owner || (owner->ns.empty() ? owner->package : owner->ns + ":" + owner->package) != + executingProvider || owner->version != executingProviderVersion || + data.sourceHome.empty()) + continue; + auto executionHome = home::domain_producer_source::execution_home(data.sourceHome); + if (!executionHome) return std::unexpected(executionHome.error()); + auto physical = home::evidence::physical_store_root( + *executionHome, xvm::expand_path(data.path, executionHome->string())); + if (!physical) + return std::unexpected(physical.error()); + auto source = home::layers::read_source_snapshot(data.sourceHome, data.sourceScope); + if (!source) + return std::unexpected(source.error()); + auto proof = home::layers::plan_borrow_package( + *source, Config::versions(), executingProvider, executingProviderVersion); + if (!proof || !*proof || (**proof).requestedPayload != physical->root) + return std::unexpected(proof ? "borrowed payload source no longer matches its evidence" + : proof.error()); + installDir = physical->root; + detachTarget = registeredTarget; + detachVersion = key; + borrowedRemoval = true; + break; + } + if (borrowedRemoval) + break; + } + auto removalSnapshot = snapshot_xpkg_removal_context( Config::versions_mut(), Config::workspace(), {}, executingProvider, executingProviderVersion, @@ -4110,13 +4012,13 @@ std::expected Installer::uninstall(con detachVersion = memberIt->second; } - auto stillReferenced = !detachVersion.empty() + auto stillReferenced = borrowedRemoval || (!detachVersion.empty() && detail_::is_version_referenced_anywhere_( resolvedMatch ? resolvedMatch->scope : PackageScope::Global, detachTarget, detachVersion, currentWorkspacePath, - force); + force)); // The `configured` record (#632) describes a binding in THIS scope, so it // goes with the binding on both paths below: a later install here must @@ -4129,7 +4031,8 @@ std::expected Installer::uninstall(con }; if (stillReferenced) { - detail_::detach_current_subos_(detachTarget, detachVersion); + if (auto detached = detail_::detach_current_subos_(detachTarget, detachVersion); !detached) + return std::unexpected(detached.error()); if (forget_configured_here()) Config::save_workspace(); log::debug("{}@{} detached from current subos; payload retained", detachTarget, detachVersion); @@ -4160,14 +4063,6 @@ std::expected Installer::uninstall(con log::debug("using xpkg snapshot for uninstall: {}", executorPkgFile.string()); } - auto execResult = mcpplibs::xpkg::create_executor(executorPkgFile); - if (!execResult) { - return std::unexpected(execResult.error()); - } - - auto& executor = *execResult; - executor.set_log_level(std::string(log::level_string())); - ctx.pkg_name = resolvedMatch ? resolvedMatch->name : name; ctx.version = resolvedMatch ? resolvedMatch->version : std::string{}; auto selectedStore = resolvedMatch @@ -4180,6 +4075,20 @@ std::expected Installer::uninstall(con ctx.xpkg_dir = pkgFile.parent_path(); ctx.pkgindex_dir = detail_::pkgindex_root_for_(pkgFile); + + auto execResult = lua_boundary::create_executor(executorPkgFile, ctx, { + detail_::hook_log_path_( + resolvedMatch && !resolvedMatch->canonicalName.empty() + ? resolvedMatch->canonicalName : name, + ctx.version, "uninstall")}); + if (!execResult) { + return std::unexpected(execResult.error()); + } + + auto& executor = *execResult; + executor.set_log_level(std::string(log::level_string())); + + if (executor.has_hook(mcpplibs::xpkg::HookType::Uninstall)) { log::debug("uninstalling {}...", name); ctx.hook_log = detail_::hook_log_path_( @@ -4232,21 +4141,20 @@ std::expected Installer::uninstall(con // Process xvm operations collected by the uninstall hook, or synthesise // the default removal op when there was no hook to ask (script/subos // type, or no recipe at all), or the one there was threw. - if (useDefaultRemoval) { + if (useDefaultRemoval && removalContext.hasSelection) { xvm_ops.push_back({ .op = "remove", .name = detachTarget, .version = detachVersion, }); } - auto sysroot_include = - artifactSubosDir / "usr" / "include"; - - const auto dbBeforeRemoval = Config::versions_mut(); + auto candidateDb = Config::versions(); + auto candidateWorkspace = Config::workspace(); + auto candidateInstalled = Config::workspace_installed(); auto removalResult = apply_xpkg_removal_operations( - Config::versions_mut(), - Config::workspace_mut(), - Config::workspace_installed_mut(), + candidateDb, + candidateWorkspace, + candidateInstalled, xvm_ops, removalContext, xvm::RemovalBatchOptions{ @@ -4262,6 +4170,27 @@ std::expected Installer::uninstall(con removalResult.error().version)); } + bool detachedByBatch = false; + for (const auto& removed : removalResult->removed) { + if (removed.target == detachTarget && removed.version == detachVersion) + detachedByBatch = true; + } + if (!detachedByBatch && !detachVersion.empty()) + detail_::detach_selection_(candidateDb, candidateWorkspace, candidateInstalled, + detachTarget, detachVersion); + auto published = detail_::publish_removal_state_( + std::move(candidateDb), std::move(candidateWorkspace), std::move(candidateInstalled), xvm_ops); + if (!published) + return std::unexpected(published.error()); + if (forget_configured_here()) { + try { + Config::save_workspace(); + } catch (const std::exception& error) { + return std::unexpected(std::string("removal committed; configured record update failed: ") + + error.what()); + } + } + // Drop this package's subos env section. Keyed by binding, so it takes // exactly what the package added and leaves every other provider's // alone -- the recipe's uninstall() writes nothing for this. @@ -4306,59 +4235,24 @@ std::expected Installer::uninstall(con } } - for (const auto& op : xvm_ops) { - if (op.op == "remove_headers") { - xvm::remove_headers(op.includedir, sysroot_include); - } - } - - bool detachedByBatch = false; - for (const auto& removed : removalResult->removed) { - if (removed.target == detachTarget - && removed.version == detachVersion) { - detachedByBatch = true; - } - } - cleanup_removed_xvm_library_artifacts( - artifactSubosDir / "lib", - dbBeforeRemoval, - Config::versions_mut(), - *removalResult); - cleanup_removed_xvm_program_artifacts( - artifactSubosDir / "bin", - dbBeforeRemoval, - Config::versions_mut(), - Config::workspace_installed(), - *removalResult); - // Third of three. Before this line a removal reclaimed the release's - // programs and libraries and left every declared asset on disk -- see the - // note on the function (openxlings/xlings#423). - cleanup_removed_xvm_file_artifacts( - artifactSubosDir, - Config::paths().dataDir / "xpkgs", - dbBeforeRemoval, - Config::versions_mut(), - Config::workspace(), - *removalResult); - if (!detachedByBatch && !detachVersion.empty()) { - detail_::detach_current_subos_( - detachTarget, detachVersion, false); - } - forget_configured_here(); - - Config::save_versions(); - Config::save_workspace(); - // Same reason as the install path: a removal that took a name out of the - // workspace must take it out of the table too, or the file stays behind - // as exactly the kind of unreachable entry this design removes. - xself::sync_shim_tables(); - if (catalog_) { catalog_->mark_installed(*resolvedMatch, false); } else { index_->mark_installed(name, false); } std::error_code ec; + // A retained root generation that links into this payload holds it too + // (SubOS design part 3 §7.1): rolling back to that generation must find + // its files. Everything else of the removal is done; the directory waits + // in the retained ledger until the last such generation is pruned. + if (const auto pins = retention::generation_pins(installDir); pins.held()) { + if (auto kept = retention::retain(installDir, detachTarget, detachVersion); !kept) + log::warn("[xim] {}", kept.error()); + log::info("{}@{} removed; its files stay for rollback until {} {} pruned", + name, resolvedMatch ? resolvedMatch->version : detachVersion, + retention::describe(pins), + pins.holders.size() + pins.unreadable.size() == 1 ? "is" : "are"); + } else { // Sweep first: whatever an earlier removal had to park is dead weight, // and clearing it here is the "removed on a later run" this strategy // promises. Cheap -- an empty or absent trash directory is one stat. @@ -4378,6 +4272,7 @@ std::expected Installer::uninstall(con "for an install; re-run `xlings remove {}` once that process " "has gone", name); } + } // installDir is the version directory (e.g. .../xim-x-node/22.17.1). // Its parent is the per-package directory (.../xim-x-node) which @@ -4428,15 +4323,28 @@ std::expected Installer::uninstall(con } std::string Installer::detect_platform_() { - #if defined(__linux__) - return "linux"; - #elif defined(__APPLE__) - return "macosx"; - #elif defined(_WIN32) - return "windows"; - #else - return "unknown"; - #endif + return std::string(host_platform_tag()); } } // namespace xlings::xim + +namespace xlings::xim { + +bool patchelf_reachable() { + namespace fs = std::filesystem; + if constexpr (!platform::is_linux) return true; + std::error_code ec; + if (fs::exists(Config::paths().binDir / "patchelf", ec)) return true; + const char* path = std::getenv("PATH"); + std::string_view rest = path ? path : ""; + while (!rest.empty()) { + const auto c = rest.find(platform::PATH_SEPARATOR); + const auto dir = rest.substr(0, c); + if (!dir.empty() && fs::exists(fs::path(std::string(dir)) / "patchelf", ec)) return true; + if (c == std::string_view::npos) break; + rest.remove_prefix(c + 1); + } + return false; +} + +} // namespace xlings::xim diff --git a/src/core/xim/installer.cppm b/src/core/xim/installer.cppm index 828ffb4bb..b542e8735 100644 --- a/src/core/xim/installer.cppm +++ b/src/core/xim/installer.cppm @@ -17,6 +17,7 @@ import xlings.core.log; import xlings.platform; import xlings.platform.target; import xlings.core.config; +import xlings.core.home.layers; import xlings.core.semver; import xlings.core.entry_binary; import xlings.core.elf_same_source; @@ -28,8 +29,9 @@ import xlings.core.xvm.bindings; import xlings.core.xvm.removal; import xlings.core.xvm.registration; import xlings.core.xvm.errors; -import xlings.core.subos.manifest; +import xlings.subos.manifest; import xlings.core.xvm.commands; +import xlings.core.xvm.materialize; import xlings.core.xvm.shim; import xlings.core.xim.libxpkg.types.script; import xlings.core.xim.libxpkg.types.subos; @@ -37,6 +39,10 @@ import xlings.runtime.cancellation; export namespace xlings::xim { +// Whether elfpatch has a patchelf to run: the scope's bin/ or PATH. Always +// true off Linux, where nothing is patched. +bool patchelf_reachable(); + enum class XpkgRegistrationErrorKind { InvalidVersion, InvalidBinding, @@ -197,26 +203,28 @@ apply_xpkg_xvm_metadata_batch( const XpkgRegistrationPlan& registration, const xvm::RemovalBatchOptions& removalOptions = {}); -void cleanup_removed_xvm_library_artifacts( +std::expected cleanup_removed_xvm_library_artifacts( const std::filesystem::path& libDir, const xvm::VersionDB& dbBeforeRemoval, const xvm::VersionDB& currentDb, - const xvm::RemovalBatchResult& removalResult); + const xvm::RemovalBatchResult& removalResult, + std::span claims = {}); -void cleanup_removed_xvm_program_artifacts( +std::expected cleanup_removed_xvm_program_artifacts( const std::filesystem::path& binDir, const xvm::VersionDB& dbBeforeRemoval, const xvm::VersionDB& currentDb, const xvm::WorkspaceInstalled& installed, const xvm::RemovalBatchResult& removalResult); -void cleanup_removed_xvm_file_artifacts( +std::expected cleanup_removed_xvm_file_artifacts( const std::filesystem::path& subosDir, const std::filesystem::path& payloadRoot, const xvm::VersionDB& dbBeforeRemoval, const xvm::VersionDB& currentDb, const xvm::Workspace& currentWorkspace, - const xvm::RemovalBatchResult& removalResult); + const xvm::RemovalBatchResult& removalResult, + std::span claims = {}); bool evict_invalid_archive_cache_( const std::filesystem::path& archive, @@ -506,11 +514,8 @@ bool is_version_referenced_anywhere_(PackageScope scope, const std::filesystem::path& excludePath = {}, bool force = false); -void remove_target_shims_(const std::string& target, const std::string& version); - -void detach_current_subos_(const std::string& target, - const std::string& version, - bool persist = true); +std::expected detach_current_subos_(const std::string& target, + const std::string& version); // Record a package's `subos.env` declarations into the subos it installs into. // @@ -528,7 +533,8 @@ bool apply_subos_env_ops_(const std::vector& operations, bool process_xvm_operations_(const PlanNode& node, const std::filesystem::path& dataDir, mcpplibs::xpkg::PackageExecutor& executor, - bool useAfterInstall); + bool useAfterInstall, + const home::layers::BorrowPlan* borrowed = nullptr); bool run_config_hook_(const PlanNode& node, const std::filesystem::path& dataDir, @@ -536,7 +542,8 @@ bool run_config_hook_(const PlanNode& node, mcpplibs::xpkg::ExecutionContext& ctx, std::function onStatus, bool useAfterInstall, - std::string* failureMessage = nullptr); + std::string* failureMessage = nullptr, + const home::layers::BorrowPlan* borrowed = nullptr); // A package that promised programs and delivered none of them. // diff --git a/src/core/xim/libxpkg/types/script.cpp b/src/core/xim/libxpkg/types/script.cpp index 2511178b6..97779d7ed 100644 --- a/src/core/xim/libxpkg/types/script.cpp +++ b/src/core/xim/libxpkg/types/script.cpp @@ -1,6 +1,7 @@ module xlings.core.xim.libxpkg.types.script; import std; +import xlings.platform; import xlings.core.xim.libxpkg.types.type; import xlings.core.xim.catalog; import xlings.core.config; @@ -55,8 +56,7 @@ bool default_config(const PlanNode& node, // The routing table follows the workspace this hook just wrote. It // replaces a hand-rolled create + mirror-to-global pair: the mirror was // a project-scope write into the global bin that nothing recorded. - xself::sync_shim_tables(); - return true; + return xself::sync_shim_tables().root_error.empty(); } bool default_uninstall(const std::string& name, const std::string& version) { @@ -84,11 +84,7 @@ bool default_uninstall(const std::string& name, const std::string& version) { if (!removal) return false; auto paths = Config::paths(); -#ifdef _WIN32 - constexpr std::string_view shim_ext = ".exe"; -#else - constexpr std::string_view shim_ext = ""; -#endif + constexpr std::string_view shim_ext = platform::exe_suffix; std::string shim_name = name; if (!shim_ext.empty() && !shim_name.ends_with(shim_ext)) shim_name += std::string(shim_ext); diff --git a/src/core/xim/libxpkg/types/type.cppm b/src/core/xim/libxpkg/types/type.cppm index a8149212b..f62a0109c 100644 --- a/src/core/xim/libxpkg/types/type.cppm +++ b/src/core/xim/libxpkg/types/type.cppm @@ -101,6 +101,10 @@ struct PlanNode { std::string repoName; std::filesystem::path pkgFile; std::filesystem::path storeRoot; + // A checked system-layer choice, never a writable installation target. + std::filesystem::path borrowedPayload; + std::filesystem::path sourceHome; + std::string sourceScope; // Effective union of runtime + build deps (kept for legacy callers // that don't yet distinguish; populated by resolver as `runtime ∪ // build`). @@ -144,7 +148,7 @@ struct PlanNode { // Explicit special members to work around GCC 15 module linker bug PlanNode() = default; -#if defined(_MSC_VER) +#if defined(_MSC_VER) // platform-if-ok: compiler, not platform: GCC/MSVC module special members ~PlanNode() = default; PlanNode(const PlanNode&) = default; PlanNode& operator=(const PlanNode&) = default; @@ -159,7 +163,7 @@ struct PlanNode { #endif }; -#if !defined(_MSC_VER) +#if !defined(_MSC_VER) // platform-if-ok: compiler, not platform: GCC/MSVC module special members // Out-of-line definitions force GCC to emit symbols in this TU PlanNode::~PlanNode() = default; PlanNode::PlanNode(const PlanNode&) = default; @@ -177,7 +181,7 @@ struct InstallPlan { std::size_t pending_count() const; InstallPlan() = default; -#if defined(_MSC_VER) +#if defined(_MSC_VER) // platform-if-ok: compiler, not platform: GCC/MSVC module special members ~InstallPlan() = default; InstallPlan(const InstallPlan&) = default; InstallPlan& operator=(const InstallPlan&) = default; @@ -192,7 +196,7 @@ struct InstallPlan { #endif }; -#if !defined(_MSC_VER) +#if !defined(_MSC_VER) // platform-if-ok: compiler, not platform: GCC/MSVC module special members InstallPlan::~InstallPlan() = default; InstallPlan::InstallPlan(const InstallPlan&) = default; InstallPlan& InstallPlan::operator=(const InstallPlan&) = default; diff --git a/src/core/xim/lua_boundary.cpp b/src/core/xim/lua_boundary.cpp new file mode 100644 index 000000000..81a2cc57f --- /dev/null +++ b/src/core/xim/lua_boundary.cpp @@ -0,0 +1,1009 @@ +module xlings.core.xim.lua_boundary; +import std; +import mcpplibs.xpkg; +import mcpplibs.xpkg.loader; +import mcpplibs.xpkg.executor; +import xlings.libs.json; +import xlings.platform; +import xlings.platform.worker; +import xlings.platform.target; +import xlings.core.config; +import xlings.core.home; +import xlings.core.home.layers; +import xlings.core.elfread; +import xlings.core.log; +import xlings.observe; +import xlings.core.xim.payload; +import xlings.core.xim.lua_protocol; +import xlings.core.xvm.db; +import xlings.core.xvm.owner; +import xlings.subos.home_view; +import xlings.subos.policy; +import xlings.subos.policy_store; +import xlings.subos.caps; +import xlings.subos.ports; +import xlings.subos.spec; +import xlings.confine.provider; +import xlings.confine; +import xlings.subos.network; +namespace xlings::xim::lua_boundary { +namespace xpkg = mcpplibs::xpkg; +namespace fs = std::filesystem; +using Json = nlohmann::json; +namespace { +std::expected, std::string> policy() { + const auto& p = Config::paths(); + return subos::policy_store::read({p.homeDir}, p.activeSubos, Info::VERSION); +} +subos::policy::Policy minimum_layer_policy() { + auto declared = subos::policy::preset(subos::policy::Preset::Dev); + declared.needs["fs"] = subos::policy::Need::Must; + declared.needs["pid"] = subos::policy::Need::Must; + return declared; +} +std::expected needs_layer_boundary(const std::string& provider = {}) { + auto layer = home::read_system_layer(); + if (!layer) + return std::unexpected(layer.error()); + if (!*layer) + return false; + std::error_code ec; + if (fs::equivalent(**layer, Config::paths().homeDir, ec)) + return false; + auto snapshot = home::layers::read_snapshot(**layer); + if (!snapshot) + return std::unexpected(snapshot.error()); + if (provider.empty()) + return true; + for (const auto& [target, info] : snapshot->versions) { + for (const auto& [key, data] : info.versions) { + const auto owner = xvm::recorded_owner(snapshot->versions, target, key); + if (!owner) + continue; + const auto canonical = + owner->ns.empty() ? owner->package : owner->ns + ":" + owner->package; + if (canonical == provider) + return true; + } + } + return false; +} +struct AuditState { + fs::path path; + bool required{false}; + std::atomic warning{false}; + std::atomic failed{false}; +}; +struct Worker { + fs::path scratch; + fs::path payload; + fs::path shadow_parent; + fs::path recipe_logs; + xpkg::ExecutionContext effect_context; + bool readonly_payload{false}; + std::set hook_logs; + Json effects = Json::object(); + std::shared_ptr audit_state = std::make_shared(); + bool audit(Json event) { + if (observe::append_checked(audit_state->path, + {.kind = observe::Kind::Ops, .fields = std::move(event)})) + return true; + if (audit_state->required) { + failure = "locked Lua worker audit could not be persisted"; + process.reset(); + return false; + } + if (!audit_state->warning.exchange(true)) { + log::warn("Lua worker audit could not be persisted: {}", audit_state->path.string()); + } + return true; + } + std::expected validate_effects(xpkg::HookResponse& response, + bool pending_payload = false) const { + try { + std::vector roots{fs::weakly_canonical(scratch)}; + if (!effect_context.install_dir.empty()) + roots.push_back(fs::weakly_canonical(effect_context.install_dir)); + for (const auto& [spec, dep] : effect_context.resolved_deps) + roots.push_back(fs::canonical(dep.install_dir)); + auto under = [](const fs::path& path, const fs::path& root) { + const auto relative = path.lexically_relative(root); + return path == root || + (!relative.empty() && !relative.is_absolute() && *relative.begin() != ".."); + }; + auto permitted = [&](const fs::path& path) { + const auto canonical = fs::weakly_canonical(path); + return std::ranges::any_of( + roots, [&](const auto& root) { return under(canonical, root); }); + }; + auto source = [&](const std::string& text, const fs::path& base) { + fs::path path(xvm::expand_path(text, Config::paths().homeDir.string())); + if (path.empty()) + throw std::runtime_error("empty host effect source"); + for (const auto& component : path) + if (component == "..") + throw std::runtime_error("host effect source contains upward traversal"); + if (path.is_relative()) + path = base / path; + if (!pending_payload && under(path.lexically_normal(), scratch)) { + if (effect_context.install_dir.empty() || readonly_payload) + throw std::runtime_error( + "scratch host effects require a user-owned persistent payload"); + const auto directory = effect_context.install_dir / ".xlings-hook-effects"; + std::error_code ec; + if (!fs::create_directory(directory, ec) && + (ec || !fs::is_directory(fs::symlink_status(directory)))) + throw std::runtime_error( + "cannot prepare owned hook effect snapshot directory"); + std::random_device random; + const auto stage = directory / std::format("{:x}-{:x}", random(), random()); + if (!fs::create_directory(stage)) + throw std::runtime_error("cannot create exclusive hook effect snapshot"); + const auto captured = stage / "source"; + const auto relative = path.lexically_relative(scratch); + auto copied = platform::copy_out_of_beneath(scratch, relative, captured); + if (!copied) + throw std::runtime_error("cannot snapshot scratch host effect: " + + copied.error()); + path = captured; + } + auto lookup = path; + if (pending_payload && !payload.empty() && under(path.lexically_normal(), payload)) + lookup = shadow_parent / payload.filename() / path.lexically_relative(payload); + if (!permitted(lookup)) + throw std::runtime_error( + "host effect source escapes the recorded payload closure: " + + path.string()); + auto stable = [&](const fs::path& item) { + const auto canonical = fs::weakly_canonical(item); + const bool shadow = pending_payload && !payload.empty() && + under(canonical, shadow_parent / payload.filename()); + return permitted(item) && + (under(path, scratch) || !under(canonical, scratch) || shadow); + }; + if (!stable(lookup)) + throw std::runtime_error( + "host effect source points into mutable worker scratch"); + std::error_code ec; + const auto status = fs::symlink_status(lookup, ec); + if (ec && ec != std::errc::no_such_file_or_directory) + throw std::runtime_error("cannot inspect host effect source: " + ec.message()); + if (fs::is_symlink(status) && !fs::exists(lookup)) + throw std::runtime_error("host effect source is a dangling symlink"); + if (fs::is_directory(lookup)) { + for (const auto& entry : fs::recursive_directory_iterator(lookup)) { + if (!stable(entry.path())) + throw std::runtime_error( + "host effect source contains a symlink outside " + "the recorded payload closure: " + + entry.path().string()); + if (entry.is_symlink() && !fs::exists(entry.path())) + throw std::runtime_error( + "host effect source contains a dangling symlink"); + } + } + return path; + }; + const auto coordinate = + xvm::coordinate_from_payload_path(effect_context.install_dir.generic_string()); + const bool is_xlings = coordinate && coordinate->package == "xlings" && + (coordinate->ns == "xim" || coordinate->ns.empty()); + for (auto& operation : response.xvm_ops) { + if (operation.op == "add") { + if (operation.name.empty() || operation.name == "." || operation.name == ".." || + operation.name.find_first_of("/\\") != std::string::npos) + throw std::runtime_error("invalid host effect target name"); + if (operation.name == "xlings" && !is_xlings) + throw std::runtime_error( + "only the xlings package may register the shared client"); + if (effect_context.install_dir.empty()) + throw std::runtime_error("host registration requires a package payload"); + const auto base = + source(operation.bindir.empty() ? effect_context.install_dir.string() + : operation.bindir, + effect_context.install_dir); + operation.bindir = base.string(); + if (operation.type == "files") { + const fs::path relative(operation.src); + if (relative.is_absolute()) + throw std::runtime_error( + "file asset source must be relative to its payload"); + (void)source(operation.src, base); + } else if (operation.type != "group" && operation.alias.empty()) { + const auto name = + operation.filename.empty() ? operation.name : operation.filename; + const fs::path relative(name); + if (relative.is_absolute()) + throw std::runtime_error("artifact source name must be relative"); + (void)source(name, base); + } + } else if (operation.op == "headers" || operation.op == "remove_headers") { + operation.includedir = + source(operation.includedir, effect_context.install_dir).string(); + } + } + return {}; + } catch (const std::exception& error) { + return std::unexpected("unsafe Lua host effect: " + std::string(error.what())); + } + } + std::expected refresh_payload() { + if (payload.empty()) + return {}; + try { + std::error_code status_error; + const auto status = fs::symlink_status(payload, status_error); + if (status_error && status_error != std::errc::no_such_file_or_directory) + return std::unexpected(status_error.message()); + if (status.type() != fs::file_type::directory && + status.type() != fs::file_type::not_found) + return std::unexpected("hook payload must be a real directory"); + const auto target = shadow_parent / payload.filename(); + std::error_code ec; + fs::remove_all(target, ec); + if (ec) + return std::unexpected(ec.message()); + if (status.type() == fs::file_type::directory) + fs::copy(payload, target, + fs::copy_options::recursive | fs::copy_options::copy_symlinks); + else + fs::create_directories(target); + return {}; + } catch (const std::exception& e) { + return std::unexpected(e.what()); + } + } + std::expected publish_payload() { + if (payload.empty()) + return {}; + try { + std::error_code payload_error; + const auto previous_status = fs::symlink_status(payload, payload_error); + if (payload_error && payload_error != std::errc::no_such_file_or_directory) + return std::unexpected("cannot inspect hook payload destination: " + + payload_error.message()); + if (previous_status.type() != fs::file_type::directory && + previous_status.type() != fs::file_type::not_found) + return std::unexpected("hook payload destination must be a real directory"); + const auto source = shadow_parent / payload.filename(); + std::error_code ec; + const auto status = fs::symlink_status(source, ec); + if (ec && ec != std::errc::no_such_file_or_directory) + return std::unexpected(ec.message()); + if (status.type() != fs::file_type::directory && + status.type() != fs::file_type::not_found) + return std::unexpected("hook returned a non-directory payload"); + if (status.type() == fs::file_type::not_found) { + if (remove_payload_dir(payload) != RemoveOutcome::Removed) + return std::unexpected("cannot withdraw hook payload"); + return {}; + } + std::random_device random; + const auto trash = payload_trash_root(payload); + if (trash.empty()) + return std::unexpected("hook payload is outside a managed store"); + fs::create_directories(trash); + const auto stage = trash / std::format("hook-{:x}-{:x}", random(), random()); + if (!fs::create_directory(stage)) + return std::unexpected("cannot create hook publication staging directory"); + struct Cleanup { + fs::path directory; + ~Cleanup() { + std::error_code ignored; + fs::remove_all(directory, ignored); + } + } cleanup{stage}; + const auto ready = stage / "payload"; + auto copied = platform::copy_out_of_beneath(shadow_parent, payload.filename(), ready); + if (!copied) + return std::unexpected(copied.error()); + if (previous_status.type() == fs::file_type::not_found) { + if (fs::is_empty(ready)) + return {}; + return platform::rename_no_replace(ready, payload); + } + auto previous = set_aside_payload(payload); + if (!previous) + return std::unexpected(previous.error()); + if (remove_payload_dir(payload) != RemoveOutcome::Removed) + return std::unexpected("cannot prepare hook payload destination"); + auto published = platform::rename_no_replace(ready, payload); + if (!published) + return std::unexpected(published.error()); + previous->commit(); + return {}; + } catch (const std::exception& e) { + return std::unexpected(e.what()); + } + } + + std::optional process; + std::optional failure; + ~Worker() { + process.reset(); + if (!scratch.empty()) { + std::error_code ec; + fs::remove_all(scratch, ec); + } + } + std::expected call(Json request) { + if (audit_state->failed.load()) { + failure = "locked Lua worker exec audit could not be persisted"; + process.reset(); + } + if (failure) + return std::unexpected(*failure); + const auto operation = request.at("operation").get(); + const bool capture = operation != "executor" || + request.at("invocation").at("action") == "load" || + request.at("invocation").at("action") == "set_log_level"; + if (capture) { + auto name = operation; + if (request.contains("invocation")) + name += "." + request.at("invocation").at("action").get(); + request["capture_log"] = (scratch / (name + ".log")).generic_string(); + } + Json event = {{"event", "recipe-worker-action"}, {"operation", request.at("operation")}}; + if (request.contains("package")) + event["package"] = request.at("package"); + if (request.contains("invocation")) { + event["package"] = request.at("invocation").at("package"); + event["action"] = request.at("invocation").at("action"); + event["hook"] = request.at("invocation").at("hook"); + } + if (!audit(event)) + return std::unexpected(*failure); + request["protocol"] = xpkg::kHookBoundaryProtocol; + auto wire = process->exchange(request.dump()); + if (audit_state->failed.load()) { + failure = "locked Lua worker exec audit could not be persisted"; + process.reset(); + return std::unexpected(*failure); + } + if (!wire) { + failure = wire.error(); + return std::unexpected(*failure); + } + try { + auto answer = Json::parse(*wire); + if (answer.at("protocol").get() != xpkg::kHookBoundaryProtocol) + throw std::runtime_error("Lua worker protocol mismatch"); + if (capture) { + const auto source = fs::path(request.at("capture_log").get()); + auto captured = platform::worker::read_log(scratch, source.filename().string()); + if (captured) { + const auto& contents = *captured; + const auto published = recipe_logs / (scratch.filename().string() + "." + + source.filename().string()); + try { + platform::write_file_atomic(published.string(), contents); + } catch (const std::exception& error) { + failure = "cannot publish recipe log " + published.string() + ": " + + error.what(); + return std::unexpected(*failure); + } + if (!answer.at("ok").get()) { + std::istringstream lines(contents); + std::deque tail; + std::string line; + while (std::getline(lines, line)) { + tail.push_back(line); + if (tail.size() > 20) + tail.pop_front(); + } + std::string message = answer.at("error").get() + + "\nrecipe log: " + published.string(); + for (const auto& text : tail) + message += "\n" + text; + answer["error"] = std::move(message); + } + } else if (answer.at("ok").get()) { + throw std::runtime_error("recipe output unavailable: " + captured.error()); + } + } + if (!answer.at("ok").get()) { + failure = answer.at("error").get(); + return std::unexpected(*failure); + } + event["event"] = "recipe-worker-result"; + if (!audit(event)) + return std::unexpected(*failure); + return answer.at("value"); + } catch (const std::exception& e) { + failure = "invalid Lua worker response: " + std::string(e.what()); + return std::unexpected(*failure); + } + } +}; +std::expected, std::string> +launch(const subos::policy::Policy& declared, const fs::path& package, + const xpkg::ExecutionContext& context, bool build = false, + const std::vector& hook_logs = {}, bool readonly_payload = false) { + try { + const auto& paths = Config::paths(); + subos::HomeView home{paths.homeDir}; + auto w = std::make_shared(); + w->effect_context = context; + w->readonly_payload = readonly_payload; + w->audit_state->path = home.logs_dir(paths.activeSubos) / "events.ndjson"; + w->recipe_logs = fs::canonical(paths.homeDir); + for (const auto directory : {"logs", "recipes"}) { + w->recipe_logs /= directory; + std::error_code error; + const auto status = fs::symlink_status(w->recipe_logs, error); + if (error && error != std::errc::no_such_file_or_directory) + return std::unexpected("cannot inspect recipe log directory: " + error.message()); + if (fs::exists(status) && !fs::is_directory(status)) + return std::unexpected("recipe log directory is not an owned directory: " + + w->recipe_logs.string()); + if (!fs::exists(status)) + fs::create_directory(w->recipe_logs); + if (fs::canonical(w->recipe_logs) != w->recipe_logs) + return std::unexpected("recipe log directory traverses a symlink: " + + w->recipe_logs.string()); + } + w->audit_state->required = declared.preset == subos::policy::Preset::Locked; + auto parent = home.run_dir(paths.activeSubos) / "lua"; + fs::create_directories(parent); + std::random_device random; + for (int attempt = 0; attempt < 32; ++attempt) { + auto candidate = + parent / std::format("{}-{:x}-{:x}", platform::get_pid(), random(), random()); + if (fs::create_directory(candidate)) { + w->scratch = candidate; + break; + } + } + if (w->scratch.empty()) + return std::unexpected("cannot create private Lua worker directory"); + fs::permissions(w->scratch, fs::perms::owner_all, fs::perm_options::replace); + auto executable = platform::get_executable_path(); + std::vector command{executable.string(), "__xpkg-worker", + std::string(platform::worker::kReadToken), + std::string(platform::worker::kWriteToken)}; + const bool full_trace = + platform::is_linux && declared.observe == subos::policy::Observe::Full; + if (full_trace) + command.push_back(std::string(platform::worker::kTraceToken)); + auto capabilities = subos::caps::probe(home, {}); + // Identity files belong to this worker, rather than changing user instance data. + const auto identity = w->scratch / "etc"; + fs::create_directory(identity); + const auto ids = platform::user_ids(); + const auto login = std::string("user"); + platform::write_string_to_file( + (identity / "passwd").string(), + std::format("{}:x:{}:{}::/home/{}:/bin/sh\n", login, ids.uid, ids.gid, login)); + platform::write_string_to_file((identity / "group").string(), + std::format("{}:x:{}:\n", login, ids.gid)); + platform::write_string_to_file((identity / "hosts").string(), + "127.0.0.1 localhost\n::1 localhost\n"); + platform::write_string_to_file((identity / "nsswitch.conf").string(), + "passwd: files\ngroup: files\nhosts: files dns\n"); + + auto p = declared; + // A hook is an install action, never a second user session with desktop grants. + p.grants.clear(); + p.grants_allowed.clear(); + p.mounts.clear(); + auto inherited = platform::environment(); + subos::spec::Request request{.instance = paths.activeSubos, + .instance_dir = home.instance(paths.activeSubos), + .user = "user", + .argv = command, + .host_env = inherited, + .cwd = w->scratch.string()}; + if (platform::is_linux) + request.preferred = subos::spec::Backend::Bwrap; + auto compiled = xlings::confine::compile(p, home, capabilities, request); + if (!compiled) { + std::string reason = "Lua worker isolation unavailable"; + for (const auto& missing : compiled.error().missing) + reason += "; " + missing.dimension + ": " + missing.reason; + return std::unexpected(reason); + } + auto sandbox = std::move(*compiled); + if (sandbox.backend != subos::spec::Backend::Bwrap) + for (const auto& path : hook_logs) + if (!path.empty()) + w->hook_logs.insert(path.lexically_normal()); + if (sandbox.backend == subos::spec::Backend::Bwrap) { + // No compiled instance mount is writable by default for a recipe. + for (auto& mount : sandbox.mounts) { + if (mount.kind == subos::spec::MountKind::Bind) + mount.kind = subos::spec::MountKind::RoBind; + for (const auto file : {"passwd", "group", "hosts", "nsswitch.conf"}) + if (mount.dst == std::string("/etc/") + file) + mount.src = (identity / file).generic_string(); + if (mount.dst == "/home" || mount.dst == "/tmp") { + auto private_dir = w->scratch / (mount.dst == "/home" ? "home" : "tmp"); + fs::create_directories(private_dir); + mount.kind = subos::spec::MountKind::Bind; + mount.src = private_dir.generic_string(); + } + } + + auto ro = [&](const fs::path& path) { + if (path.empty() || !fs::exists(path)) + return; + const auto destination = fs::absolute(path).lexically_normal(); + const auto source = fs::canonical(destination); + // The read-only home already exposes its index symlinks. Make + // their exact canonical targets visible in private /tmp/home; + // binding again through the alias cannot create a destination + // beneath that read-only symlink. + sandbox.mounts.push_back({subos::spec::MountKind::RoBind, source.generic_string(), + source.generic_string()}); + }; + auto rw = [&](const fs::path& path) { + if (path.empty()) + return; + fs::create_directories(path); + auto canonical = fs::canonical(path); + sandbox.mounts.push_back({subos::spec::MountKind::Bind, canonical.generic_string(), + canonical.generic_string()}); + }; + auto runtime_ro = [&](const fs::path& path) { + const auto destination = fs::absolute(path).lexically_normal(); + const auto source = fs::canonical(destination); + ro(source); + if (source == destination) + return; + // Keep aliases already provided by a home/userland mount. A + // masked runtime directory instead needs its literal INTERP + // or RPATH spelling, as well as the canonical library target. + for (const auto& mount : sandbox.mounts | std::views::reverse) { + const auto relative = destination.lexically_relative(fs::path(mount.dst)); + if (relative.empty() || relative.is_absolute() || *relative.begin() == "..") + continue; + if (mount.kind == subos::spec::MountKind::RoBind || + mount.kind == subos::spec::MountKind::Bind) { + std::error_code error; + if (fs::equivalent(fs::path(mount.src) / relative, source, error)) + return; + } + break; + } + sandbox.mounts.push_back({subos::spec::MountKind::RoBind, source.generic_string(), + destination.generic_string()}); + }; + ro(executable); + if (auto runtime = elfread::read(executable)) { + std::set runtimeDirectories; + if (!runtime->interpreter.empty()) { + if (!fs::is_regular_file(runtime->interpreter)) + return std::unexpected("Lua worker ELF interpreter is unavailable: " + + runtime->interpreter); + runtimeDirectories.insert(fs::path(runtime->interpreter).parent_path()); + } + for (auto path : runtime->searchPaths) { + for (const auto token : + {std::string_view("${ORIGIN}"), std::string_view("$ORIGIN")}) { + std::size_t position{}; + while ((position = path.find(token, position)) != std::string::npos) { + const auto origin = executable.parent_path().generic_string(); + path.replace(position, token.size(), origin); + position += origin.size(); + } + } + runtimeDirectories.insert(fs::path(path)); + } + for (const auto& directory : runtimeDirectories) { + if (!directory.is_absolute() || !fs::is_directory(directory)) + continue; + // A dev build's managed loader and libraries can be outside + // host_userland(), under the home that this worker masks. + runtime_ro(directory); + } + if (!runtime->interpreter.empty()) + runtime_ro(runtime->interpreter); + } else if (elfread::is_elf(executable)) { + return std::unexpected("cannot read the Lua worker ELF runtime: " + + executable.string()); + } + ro(package); + ro(context.xpkg_dir); + ro(context.project_data_dir); + if (!Config::project_dir().empty()) + ro(Config::project_dir()); + if (!context.pkgindex_dir.empty()) + ro(context.pkgindex_dir); + for (const auto& path : context.dependency_store_roots) + ro(path); + rw(w->scratch); + if (build) + rw(package); + else if (readonly_payload) { + ro(context.install_dir); + } else if (!context.install_dir.empty()) { + w->payload = fs::absolute(context.install_dir).lexically_normal(); + const auto parent = w->payload.parent_path(); + const auto store = parent.parent_path(); + const bool declared_store = std::ranges::any_of( + context.dependency_store_roots, [&](const auto& root) { + return fs::absolute(root).lexically_normal() == store; + }); + if (!declared_store || store.filename() != "xpkgs" || + !xvm::coordinate_from_payload_path(w->payload.string())) + return std::unexpected("hook payload is outside its declared managed store"); + const auto expected_parent = fs::weakly_canonical(store) / parent.filename(); + if (fs::weakly_canonical(parent) != expected_parent) + return std::unexpected("hook payload parent traverses a symlink"); + fs::create_directories(parent); + if (fs::canonical(parent) != expected_parent) + return std::unexpected("hook payload parent traverses a symlink"); + w->shadow_parent = w->scratch / "payload"; + fs::create_directory(w->shadow_parent); + auto refreshed = w->refresh_payload(); + if (!refreshed) + return std::unexpected(refreshed.error()); + sandbox.mounts.push_back({subos::spec::MountKind::Bind, + w->shadow_parent.generic_string(), + w->payload.parent_path().generic_string()}); + } + const auto log_directory = home.home / "logs" / "hooks"; + for (const auto& path : hook_logs) { + if (path.empty()) + continue; + if (path.parent_path().lexically_normal() != log_directory.lexically_normal()) + return std::unexpected("hook log is outside the owned hook log directory"); + fs::create_directories(log_directory); + if (fs::canonical(log_directory) != fs::canonical(home.home) / "logs" / "hooks") + return std::unexpected("hook log directory traverses a symlink"); + if (!platform::worker::prepare_log(path)) + return std::unexpected("hook log is not a writable regular file: " + + path.string()); + w->hook_logs.insert(path.lexically_normal()); + sandbox.mounts.push_back( + {subos::spec::MountKind::Bind, path.generic_string(), path.generic_string()}); + } + command = xlings::confine::provider::bwrap_argv(sandbox); + } else { + // compile() refused all Must dimensions; this is the documented advisory + // platform path, still an external worker with no in-process fallback. + log::warn("Lua worker isolation is advisory on {}", capabilities.platform); + } + auto env = xlings::confine::provider::process_env(sandbox, inherited); + env.erase("XLINGS_BROKER_SOCKET"); + env.erase("XLINGS_SUBOS_MODE"); + platform::worker::Network network; + if (sandbox.net_nat) { + network.pasta = xlings::confine::provider::pasta_args(sandbox); + network.pid_file = w->scratch / "pasta.pid"; + } + if (sandbox.net_proxy) { + auto resolved = subos::network::resolve_proxy(sandbox.proxy_url); + if (!resolved) + return std::unexpected(resolved.error()); + auto relay = std::make_shared(std::move(*resolved)); + network.proxy = true; + network.gateway_port = subos::network::GATEWAY_PORT; + network.gateway = [](int channel, int listener) { + return subos::network::gateway_run(channel, listener); + }; + network.accept_client = [relay](int client) { return relay->add(client); }; + network.relay_fds = [relay] { return relay->fds(); }; + network.relay_tick = [relay, state = w->audit_state] { + relay->tick([state](const subos::network::Event& event) { + bool stored = observe::append_checked( + state->path, {.kind = observe::Kind::Net, + .fields = {{"event", event.event}, + {"worker", "lua"}, + {"connection", event.connection}, + {"target", event.target}, + {"port", event.port}, + {"remote_dns", event.remote_dns}, + {"reason", event.reason}}}); + if (!stored && state->required) { + state->failed.store(true); + return false; + } + if (!stored && !state->warning.exchange(true)) + log::warn("Lua proxy audit could not be persisted: {}", + state->path.string()); + return true; + }); + }; + } + platform::worker::Trace trace; + trace.enabled = full_trace; + trace.audit = [state = w->audit_state](int pid, std::string_view path) { + const bool stored = + observe::append_checked(state->path, {.kind = observe::Kind::Exec, + .fields = {{"event", "exec"}, + {"worker", "lua"}, + {"pid", pid}, + {"path", std::string(path)}}}); + if (stored) + return true; + if (state->required) { + state->failed.store(true); + return false; + } + if (!state->warning.exchange(true)) + log::warn("Lua worker exec audit could not be persisted: {}", state->path.string()); + return true; + }; + trace.net_audit = [state = + w->audit_state](const platform::net_notify::Notice& notification) { + const bool stored = observe::append_checked( + state->path, {.kind = observe::Kind::Net, + .fields = {{"event", "net-attempt"}, + {"worker", "lua"}, + {"pid", notification.pid}, + {"syscall", notification.syscall}, + {"address", notification.address}, + {"port", notification.port}, + {"address_readable", notification.address_readable}, + {"result", "unknown"}}}); + if (stored) + return true; + if (state->required) { + state->failed.store(true); + return false; + } + if (!state->warning.exchange(true)) + log::warn("Lua worker network audit could not be persisted: {}", + state->path.string()); + return true; + }; + auto child = platform::worker::Process::launch(command, env, network, trace); + if (!child) + return std::unexpected(child.error()); + w->process.emplace(std::move(*child)); + return w; + } catch (const std::exception& e) { + return std::unexpected("cannot launch Lua worker: " + std::string(e.what())); + } +} +} // namespace +std::expected metadata(const fs::path& package, + const xpkg::LoaderContext& context, + const std::string& formal_provider) { + auto p = policy(); + if (!p) + return std::unexpected(p.error()); + const auto layer_required = needs_layer_boundary(formal_provider); + if (!layer_required) + return std::unexpected(layer_required.error()); + if (!*p && !*layer_required) + return xpkg::load_package(package, context); + if (!*p) + p->emplace(minimum_layer_policy()); + if (*layer_required) { + (**p).needs["fs"] = subos::policy::Need::Must; + (**p).needs["pid"] = subos::policy::Need::Must; + } + const auto absolute_package = fs::absolute(package); + auto w = launch(**p, absolute_package, {}); + if (!w) + return std::unexpected(w.error()); + return xpkg::load_package(absolute_package, context, + [worker = *w](const xpkg::MetadataInvocation& invocation) + -> std::expected { + auto answer = worker->call( + {{"operation", "metadata"}, + {"package", invocation.package.generic_string()}, + {"platform", invocation.context.platform}, + {"arch", invocation.context.arch}}); + if (!answer) + return std::unexpected(answer.error()); + try { + return lua_protocol::package(*answer); + } catch (const std::exception& e) { + return std::unexpected(e.what()); + } + }); +} +std::expected build_index(const fs::path& repo, + const std::string& ns, + const xpkg::LoaderContext& context, + const xpkg::BuildOutput& output) { + auto p = policy(); + if (!p) + return std::unexpected(p.error()); + const auto layer_required = needs_layer_boundary(); + if (!layer_required) + return std::unexpected(layer_required.error()); + if (!*p && !*layer_required) + return xpkg::build_index(repo, ns, output); + if (!*p) + p->emplace(minimum_layer_policy()); + if (*layer_required) { + (**p).needs["fs"] = subos::policy::Need::Must; + (**p).needs["pid"] = subos::policy::Need::Must; + } + xpkg::LoaderBoundaries boundaries; + boundaries.metadata = [&](const xpkg::MetadataInvocation& invocation) { + return metadata(invocation.package, invocation.context); + }; + boundaries.index_build = + [&](const fs::path& directory, + const xpkg::BuildOutput& sink) -> std::expected { + auto worker = launch(**p, directory, {}, true); + if (!worker) + return std::unexpected(worker.error()); + auto result = (*worker)->call({{"operation", "build_index"}, + {"package", directory.generic_string()}, + {"platform", context.platform}, + {"arch", context.arch}}); + if (!result) + return std::unexpected(result.error()); + if (sink) + sink(result->at("output").get()); + return {}; + }; + return xpkg::build_index(repo, ns, context, boundaries, output); +} +std::expected +create_executor(const fs::path& package, xpkg::ExecutionContext& context, + const std::vector& hook_logs, bool readonly_payload) { + auto p = policy(); + if (!p) + return std::unexpected(p.error()); + if (!*p && !readonly_payload) + return xpkg::create_executor(package); + if (readonly_payload) { + if (!*p) + p->emplace(minimum_layer_policy()); + (**p).needs["fs"] = subos::policy::Need::Must; + (**p).needs["pid"] = subos::policy::Need::Must; + } + const auto absolute_package = fs::absolute(package); + auto worker = launch(**p, absolute_package, context, false, hook_logs, readonly_payload); + if (!worker) + return std::unexpected(worker.error()); + if (!context.install_file.empty()) { + const auto private_file = (*worker)->scratch / context.install_file.filename(); + std::error_code ec; + fs::copy_file(context.install_file, private_file, fs::copy_options::none, ec); + if (ec) + return std::unexpected("cannot stage hook archive: " + ec.message()); + context.install_file = private_file; + } + return xpkg::create_executor( + absolute_package, + [worker = *worker](const xpkg::HookInvocation& original) + -> std::expected { + auto invocation = original; + if (!invocation.context.hook_log.empty() && + !worker->hook_logs.contains(invocation.context.hook_log.lexically_normal())) + return std::unexpected("hook log was not declared before worker launch"); + if (!worker->readonly_payload && (invocation.action == xpkg::HookAction::RunHook || + invocation.action == xpkg::HookAction::Elfpatch)) { + auto refreshed = worker->refresh_payload(); + if (!refreshed) + return std::unexpected(refreshed.error()); + } + Json env = Json::object(); + for (const auto& [name, value] : platform::environment()) + if (name == "PATH" || name.starts_with("XLINGS_BUILDDEP_")) + env[name] = value; + auto cwd = worker->scratch; + if (invocation.action == xpkg::HookAction::RunHook && + invocation.hook == xpkg::HookType::Install && + !invocation.context.install_file.empty()) + cwd = invocation.context.install_file.parent_path(); + else if ((invocation.action == xpkg::HookAction::RunHook || + invocation.action == xpkg::HookAction::Elfpatch) && + !invocation.context.install_dir.empty()) + cwd = invocation.context.install_dir; + else if (invocation.action == xpkg::HookAction::RunScript && + !Config::project_dir().empty()) + cwd = Config::project_dir(); + auto answer = worker->call({{"operation", "executor"}, + {"invocation", lua_protocol::encode(invocation)}, + {"environment", std::move(env)}, + {"cwd", cwd.generic_string()}}); + if (!answer) + return std::unexpected(answer.error()); + try { + auto result = lua_protocol::response(*answer); + const bool publishes = + !worker->readonly_payload && (invocation.action == xpkg::HookAction::RunHook || + invocation.action == xpkg::HookAction::Elfpatch); + if (auto safe = worker->validate_effects(result, publishes); !safe) { + worker->failure = safe.error(); + return std::unexpected(safe.error()); + } + if (invocation.action != xpkg::HookAction::RunScript) { + for (const auto field : {"xvm_ops", "install_requests"}) { + const auto& current = answer->at(field); + if (worker->effects.contains(field)) { + const auto& previous = worker->effects.at(field); + if (current.size() < previous.size()) + throw std::runtime_error("hook effect snapshot regressed"); + for (std::size_t i = 0; i < previous.size(); ++i) + if (current.at(i) != previous.at(i)) + throw std::runtime_error( + "hook effect snapshot rewrote an applied operation"); + } + worker->effects[field] = current; + } + } + if (publishes) { + auto published = worker->publish_payload(); + if (!published) + return std::unexpected("cannot publish hook payload: " + published.error()); + } + if (auto safe = worker->validate_effects(result); !safe) { + worker->failure = safe.error(); + return std::unexpected(safe.error()); + } + return result; + } catch (const std::exception& e) { + worker->failure = e.what(); + return std::unexpected(e.what()); + } + }); +} +int worker_main(int argc, char* argv[]) { + if (argc != 4 && argc != 5) + return 125; + auto attached = platform::worker::attach(argv[2], argv[3]); + if (!attached) + return 125; + auto channel = *attached; + if (argc == 5 && !platform::worker::install_trace(argv[4])) { + platform::worker::close(channel); + return 125; + } + xpkg::HookWorker worker; + while (true) { + auto input = platform::worker::receive(channel); + if (!input) + break; + Json answer = {{"protocol", xpkg::kHookBoundaryProtocol}, {"ok", false}}; + std::optional capture; + try { + auto request = Json::parse(*input); + if (request.at("protocol").get() != xpkg::kHookBoundaryProtocol) + throw std::runtime_error("unsupported Lua worker protocol"); + const auto operation = request.at("operation").get(); + const bool capture_output = operation != "executor" || + request.at("invocation").at("action") == "load" || + request.at("invocation").at("action") == "set_log_level"; + if (capture_output) + capture.emplace(fs::path(request.at("capture_log").get())); + if (operation == "metadata") { + auto result = + xpkg::load_package(request.at("package").get(), + {.platform = request.at("platform").get(), + .arch = request.at("arch").get()}); + if (!result) + throw std::runtime_error(result.error()); + answer["value"] = lua_protocol::encode(*result); + } else if (operation == "build_index") { + std::string output; + auto result = xpkg::build_index(request.at("package").get(), {}, + [&](std::string_view text) { output += text; }); + if (!result) + throw std::runtime_error(result.error()); + answer["value"] = {{"output", std::move(output)}}; + } else if (operation == "executor") { + const auto& environment = request.at("environment"); + for (auto it = environment.begin(); it != environment.end(); ++it) { + const auto& name = it.key(); + if (name != "PATH" && !name.starts_with("XLINGS_BUILDDEP_")) + throw std::runtime_error("invalid worker environment update"); + platform::set_env_variable(name, it.value().get()); + } + fs::current_path(request.at("cwd").get()); + auto result = worker.dispatch(lua_protocol::invocation(request.at("invocation"))); + if (!result) + throw std::runtime_error(result.error()); + answer["value"] = lua_protocol::encode(*result); + } else + throw std::runtime_error("unknown Lua worker operation"); + answer["ok"] = true; + } catch (const std::exception& e) { + answer["error"] = e.what(); + } + capture.reset(); + if (!platform::worker::send(channel, answer.dump())) + break; + } + platform::worker::close(channel); + return 0; +} +} // namespace xlings::xim::lua_boundary diff --git a/src/core/xim/lua_boundary.cppm b/src/core/xim/lua_boundary.cppm new file mode 100644 index 000000000..fde9386b7 --- /dev/null +++ b/src/core/xim/lua_boundary.cppm @@ -0,0 +1,19 @@ +export module xlings.core.xim.lua_boundary; +import std; +import mcpplibs.xpkg; +import mcpplibs.xpkg.loader; +import mcpplibs.xpkg.executor; +export namespace xlings::xim::lua_boundary { +std::expected +metadata(const std::filesystem::path& package, const mcpplibs::xpkg::LoaderContext& context, + const std::string& formal_provider = {}); +std::expected +build_index(const std::filesystem::path& repo, const std::string& default_namespace, + const mcpplibs::xpkg::LoaderContext& context, + const mcpplibs::xpkg::BuildOutput& output); +std::expected +create_executor(const std::filesystem::path& package, mcpplibs::xpkg::ExecutionContext& context, + const std::vector& hook_logs = {}, + bool readonly_payload = false); +int worker_main(int argc, char* argv[]); +} // namespace xlings::xim::lua_boundary diff --git a/src/core/xim/lua_protocol.cpp b/src/core/xim/lua_protocol.cpp new file mode 100644 index 000000000..c86cdaa12 --- /dev/null +++ b/src/core/xim/lua_protocol.cpp @@ -0,0 +1,371 @@ +module xlings.core.xim.lua_protocol; +import std; +import xlings.libs.json; +import mcpplibs.xpkg; +import mcpplibs.xpkg.executor; +namespace xlings::xim::lua_protocol { +namespace xpkg = mcpplibs::xpkg; +using Json = nlohmann::json; +namespace { +Json pack(const xpkg::ArchResource& v) { + Json j = Json::object(); + j["url"] = v.url; + j["sha256"] = v.sha256; + j["mirrors"] = v.mirrors; + return j; +} +xpkg::ArchResource unpack_ArchResource(const Json& j) { + xpkg::ArchResource v; + j.at("url").get_to(v.url); + j.at("sha256").get_to(v.sha256); + j.at("mirrors").get_to(v.mirrors); + return v; +} +Json pack(const xpkg::PlatformResource& v) { + Json j = Json::object(); + j["url"] = v.url; + j["sha256"] = v.sha256; + j["ref"] = v.ref; + j["mirrors"] = v.mirrors; + j["sha256_by_arch"] = v.sha256_by_arch; + j["arch_alias"] = v.arch_alias; + j["is_res"] = v.is_res; + j["revision"] = v.revision; + j["archs"] = Json::object(); + for (const auto& [k, item] : v.archs) + j["archs"][k] = pack(item); + return j; +} +xpkg::PlatformResource unpack_PlatformResource(const Json& j) { + xpkg::PlatformResource v; + j.at("url").get_to(v.url); + j.at("sha256").get_to(v.sha256); + j.at("ref").get_to(v.ref); + j.at("mirrors").get_to(v.mirrors); + j.at("sha256_by_arch").get_to(v.sha256_by_arch); + j.at("arch_alias").get_to(v.arch_alias); + j.at("is_res").get_to(v.is_res); + j.at("revision").get_to(v.revision); + for (auto it = j.at("archs").begin(); it != j.at("archs").end(); ++it) + v.archs.emplace(it.key(), unpack_ArchResource(it.value())); + return v; +} +Json pack(const xpkg::ExportsRuntime& v) { + Json j = Json::object(); + j["loader"] = v.loader; + j["libdirs"] = v.libdirs; + j["abi"] = v.abi; + return j; +} +xpkg::ExportsRuntime unpack_ExportsRuntime(const Json& j) { + xpkg::ExportsRuntime v; + j.at("loader").get_to(v.loader); + j.at("libdirs").get_to(v.libdirs); + j.at("abi").get_to(v.abi); + return v; +} +Json pack(const xpkg::ExportsBlock& v) { + Json j = Json::object(); + j["runtime"] = pack(v.runtime); + return j; +} +xpkg::ExportsBlock unpack_ExportsBlock(const Json& j) { + xpkg::ExportsBlock v; + v.runtime = unpack_ExportsRuntime(j.at("runtime")); + return v; +} +Json pack(const xpkg::PlatformMatrix& v) { + Json j = Json::object(); + j["source"] = v.source; + j["source_mirrors"] = v.source_mirrors; + j["platform_sources"] = v.platform_sources; + j["platform_source_mirrors"] = v.platform_source_mirrors; + j["deps"] = v.deps; + j["runtime_deps"] = v.runtime_deps; + j["build_deps"] = v.build_deps; + j["inherits"] = v.inherits; + j["entries"] = Json::object(); + for (const auto& [k, items] : v.entries) { + j["entries"][k] = Json::object(); + for (const auto& [key, item] : items) + j["entries"][k][key] = pack(item); + } + j["exports"] = Json::object(); + for (const auto& [k, item] : v.exports) + j["exports"][k] = pack(item); + return j; +} +xpkg::PlatformMatrix unpack_PlatformMatrix(const Json& j) { + xpkg::PlatformMatrix v; + j.at("source").get_to(v.source); + j.at("source_mirrors").get_to(v.source_mirrors); + j.at("platform_sources").get_to(v.platform_sources); + j.at("platform_source_mirrors").get_to(v.platform_source_mirrors); + j.at("deps").get_to(v.deps); + j.at("runtime_deps").get_to(v.runtime_deps); + j.at("build_deps").get_to(v.build_deps); + j.at("inherits").get_to(v.inherits); + for (auto it = j.at("entries").begin(); it != j.at("entries").end(); ++it) + for (auto entry = it.value().begin(); entry != it.value().end(); ++entry) + v.entries[it.key()].emplace(entry.key(), unpack_PlatformResource(entry.value())); + for (auto it = j.at("exports").begin(); it != j.at("exports").end(); ++it) + v.exports.emplace(it.key(), unpack_ExportsBlock(it.value())); + return v; +} +Json pack(const xpkg::Package& v) { + Json j = Json::object(); + j["spec"] = v.spec; + j["name"] = v.name; + j["description"] = v.description; + j["namespace_"] = v.namespace_; + j["homepage"] = v.homepage; + j["repo"] = v.repo; + j["docs"] = v.docs; + j["authors"] = v.authors; + j["maintainers"] = v.maintainers; + j["licenses"] = v.licenses; + j["categories"] = v.categories; + j["keywords"] = v.keywords; + j["programs"] = v.programs; + j["archs"] = v.archs; + j["xvm_enable"] = v.xvm_enable; + j["xpm"] = pack(v.xpm); + return j; +} +xpkg::Package unpack_Package(const Json& j) { + xpkg::Package v; + j.at("spec").get_to(v.spec); + j.at("name").get_to(v.name); + j.at("description").get_to(v.description); + j.at("namespace_").get_to(v.namespace_); + j.at("homepage").get_to(v.homepage); + j.at("repo").get_to(v.repo); + j.at("docs").get_to(v.docs); + j.at("authors").get_to(v.authors); + j.at("maintainers").get_to(v.maintainers); + j.at("licenses").get_to(v.licenses); + j.at("categories").get_to(v.categories); + j.at("keywords").get_to(v.keywords); + j.at("programs").get_to(v.programs); + j.at("archs").get_to(v.archs); + j.at("xvm_enable").get_to(v.xvm_enable); + v.xpm = unpack_PlatformMatrix(j.at("xpm")); + return v; +} +Json pack(const xpkg::DepExport& v) { + Json j = Json::object(); + j["loader"] = v.loader; + j["libdirs"] = v.libdirs; + j["abi"] = v.abi; + return j; +} +xpkg::DepExport unpack_DepExport(const Json& j) { + xpkg::DepExport v; + j.at("loader").get_to(v.loader); + j.at("libdirs").get_to(v.libdirs); + j.at("abi").get_to(v.abi); + return v; +} +Json pack(const xpkg::ResolvedDep& v) { + Json j = Json::object(); + j["spec"] = v.spec; + j["name"] = v.name; + j["version"] = v.version; + j["install_dir"] = v.install_dir; + j["libdirs"] = v.libdirs; + j["source"] = v.source; + return j; +} +xpkg::ResolvedDep unpack_ResolvedDep(const Json& j) { + xpkg::ResolvedDep v; + j.at("spec").get_to(v.spec); + j.at("name").get_to(v.name); + j.at("version").get_to(v.version); + j.at("install_dir").get_to(v.install_dir); + j.at("libdirs").get_to(v.libdirs); + j.at("source").get_to(v.source); + return v; +} +Json pack(const xpkg::ExecutionContext& v) { + Json j = Json::object(); + j["pkg_name"] = v.pkg_name; + j["version"] = v.version; + j["platform"] = v.platform; + j["arch"] = v.arch; + j["deps_list"] = v.deps_list; + j["args"] = v.args; + j["runtime_deps_list"] = v.runtime_deps_list; + j["build_deps_list"] = v.build_deps_list; + j["subos_sysrootdir"] = v.subos_sysrootdir; + j["pkgindex_dir"] = v.pkgindex_dir; + j["install_file"] = v.install_file.generic_string(); + j["install_dir"] = v.install_dir.generic_string(); + j["run_dir"] = v.run_dir.generic_string(); + j["xpkg_dir"] = v.xpkg_dir.generic_string(); + j["bin_dir"] = v.bin_dir.generic_string(); + j["project_data_dir"] = v.project_data_dir.generic_string(); + j["hook_log"] = v.hook_log.generic_string(); + j["deps_exports"] = Json::object(); + for (const auto& [k, item] : v.deps_exports) + j["deps_exports"][k] = pack(item); + j["resolved_deps"] = Json::object(); + for (const auto& [k, item] : v.resolved_deps) + j["resolved_deps"][k] = pack(item); + j["dependency_store_roots"] = Json::array(); + for (const auto& p : v.dependency_store_roots) + j["dependency_store_roots"].push_back(p.generic_string()); + j["self_exports"] = pack(v.self_exports); + return j; +} +xpkg::ExecutionContext unpack_ExecutionContext(const Json& j) { + xpkg::ExecutionContext v; + j.at("pkg_name").get_to(v.pkg_name); + j.at("version").get_to(v.version); + j.at("platform").get_to(v.platform); + j.at("arch").get_to(v.arch); + j.at("deps_list").get_to(v.deps_list); + j.at("args").get_to(v.args); + j.at("runtime_deps_list").get_to(v.runtime_deps_list); + j.at("build_deps_list").get_to(v.build_deps_list); + j.at("subos_sysrootdir").get_to(v.subos_sysrootdir); + j.at("pkgindex_dir").get_to(v.pkgindex_dir); + v.install_file = std::filesystem::path(j.at("install_file").get()); + v.install_dir = std::filesystem::path(j.at("install_dir").get()); + v.run_dir = std::filesystem::path(j.at("run_dir").get()); + v.xpkg_dir = std::filesystem::path(j.at("xpkg_dir").get()); + v.bin_dir = std::filesystem::path(j.at("bin_dir").get()); + v.project_data_dir = std::filesystem::path(j.at("project_data_dir").get()); + v.hook_log = std::filesystem::path(j.at("hook_log").get()); + for (auto it = j.at("deps_exports").begin(); it != j.at("deps_exports").end(); ++it) + v.deps_exports.emplace(it.key(), unpack_DepExport(it.value())); + for (auto it = j.at("resolved_deps").begin(); it != j.at("resolved_deps").end(); ++it) + v.resolved_deps.emplace(it.key(), unpack_ResolvedDep(it.value())); + for (const auto& p : j.at("dependency_store_roots")) + v.dependency_store_roots.emplace_back(p.get()); + v.self_exports = unpack_DepExport(j.at("self_exports")); + return v; +} +Json pack(const xpkg::XvmOp& v) { + Json j = Json::object(); + j["op"] = v.op; + j["name"] = v.name; + j["version"] = v.version; + j["bindir"] = v.bindir; + j["alias"] = v.alias; + j["type"] = v.type; + j["filename"] = v.filename; + j["binding"] = v.binding; + j["includedir"] = v.includedir; + j["src"] = v.src; + j["dst"] = v.dst; + j["args"] = v.args; + j["var"] = v.var; + j["value"] = v.value; + j["mode"] = v.mode; + j["envs"] = v.envs; + return j; +} +xpkg::XvmOp unpack_XvmOp(const Json& j) { + xpkg::XvmOp v; + j.at("op").get_to(v.op); + j.at("name").get_to(v.name); + j.at("version").get_to(v.version); + j.at("bindir").get_to(v.bindir); + j.at("alias").get_to(v.alias); + j.at("type").get_to(v.type); + j.at("filename").get_to(v.filename); + j.at("binding").get_to(v.binding); + j.at("includedir").get_to(v.includedir); + j.at("src").get_to(v.src); + j.at("dst").get_to(v.dst); + j.at("args").get_to(v.args); + j.at("var").get_to(v.var); + j.at("value").get_to(v.value); + j.at("mode").get_to(v.mode); + j.at("envs").get_to(v.envs); + return v; +} +Json pack(const xpkg::InstallRequest& v) { + Json j = Json::object(); + j["op"] = v.op; + j["target"] = v.target; + return j; +} +xpkg::InstallRequest unpack_InstallRequest(const Json& j) { + xpkg::InstallRequest v; + j.at("op").get_to(v.op); + j.at("target").get_to(v.target); + return v; +} +Json pack(const xpkg::HookResult& v) { + Json j = Json::object(); + j["success"] = v.success; + j["output"] = v.output; + j["error"] = v.error; + j["version"] = v.version; + return j; +} +xpkg::HookResult unpack_HookResult(const Json& j) { + xpkg::HookResult v; + j.at("success").get_to(v.success); + j.at("output").get_to(v.output); + j.at("error").get_to(v.error); + j.at("version").get_to(v.version); + return v; +} +} // namespace +Json encode(const xpkg::Package& v) { + auto j = pack(v); + j["type"] = static_cast(v.type); + j["status"] = static_cast(v.status); + return j; +} +xpkg::Package package(const Json& j) { + auto v = unpack_Package(j); + const int type = j.at("type").get(), status = j.at("status").get(); + if (type < 0 || type > 4 || status < 0 || status > 2) + throw std::runtime_error("invalid package enum"); + v.type = static_cast(type); + v.status = static_cast(status); + return v; +} +Json encode(const xpkg::HookInvocation& v) { + return {{"action", std::string(xpkg::hook_action_name(v.action))}, + {"package", v.package.generic_string()}, + {"hook", static_cast(v.hook)}, + {"context", pack(v.context)}, + {"log_level", v.log_level}}; +} +xpkg::HookInvocation invocation(const Json& j) { + auto action = xpkg::hook_action_from_string(j.at("action").get()); + int hook = j.at("hook").get(); + if (!action || hook < 0 || hook > 4) + throw std::runtime_error("invalid hook invocation"); + return {.action = *action, + .package = j.at("package").get(), + .hook = static_cast(hook), + .context = unpack_ExecutionContext(j.at("context")), + .log_level = j.at("log_level").get()}; +} +Json encode(const xpkg::HookResponse& v) { + Json j = {{"result", pack(v.result)}, + {"hooks", v.hooks}, + {"xvm_ops", Json::array()}, + {"install_requests", Json::array()}}; + for (const auto& op : v.xvm_ops) + j["xvm_ops"].push_back(pack(op)); + for (const auto& req : v.install_requests) + j["install_requests"].push_back(pack(req)); + return j; +} +xpkg::HookResponse response(const Json& j) { + xpkg::HookResponse v; + v.result = unpack_HookResult(j.at("result")); + j.at("hooks").get_to(v.hooks); + for (const auto& op : j.at("xvm_ops")) + v.xvm_ops.push_back(unpack_XvmOp(op)); + for (const auto& req : j.at("install_requests")) + v.install_requests.push_back(unpack_InstallRequest(req)); + return v; +} +} // namespace xlings::xim::lua_protocol diff --git a/src/core/xim/lua_protocol.cppm b/src/core/xim/lua_protocol.cppm new file mode 100644 index 000000000..280327408 --- /dev/null +++ b/src/core/xim/lua_protocol.cppm @@ -0,0 +1,13 @@ +export module xlings.core.xim.lua_protocol; +import std; +import xlings.libs.json; +import mcpplibs.xpkg; +import mcpplibs.xpkg.executor; +export namespace xlings::xim::lua_protocol { +nlohmann::json encode(const mcpplibs::xpkg::Package& value); +mcpplibs::xpkg::Package package(const nlohmann::json& value); +nlohmann::json encode(const mcpplibs::xpkg::HookInvocation& value); +mcpplibs::xpkg::HookInvocation invocation(const nlohmann::json& value); +nlohmann::json encode(const mcpplibs::xpkg::HookResponse& value); +mcpplibs::xpkg::HookResponse response(const nlohmann::json& value); +} // namespace xlings::xim::lua_protocol diff --git a/src/core/xim/payload.cpp b/src/core/xim/payload.cpp index 274bb75ad..58fa52932 100644 --- a/src/core/xim/payload.cpp +++ b/src/core/xim/payload.cpp @@ -8,13 +8,9 @@ import xlings.core.log; namespace xlings::xim { std::string_view host_platform_tag() { -#if defined(_WIN32) - return "windows"; -#elif defined(__APPLE__) - return "macosx"; -#else - return "linux"; -#endif + if constexpr (platform::is_windows) return "windows"; + else if constexpr (platform::is_macos) return "macosx"; + else return "linux"; } std::string_view executable_format_(const std::filesystem::path& file) { diff --git a/src/core/xim/repo.cpp b/src/core/xim/repo.cpp index a2f41233e..31bc6c532 100644 --- a/src/core/xim/repo.cpp +++ b/src/core/xim/repo.cpp @@ -54,20 +54,20 @@ bool ensure_local_repo_link_(const std::filesystem::path& localDir, return false; } -#if defined(_WIN32) - if (!platform::create_directory_link(localDir, sourceDir)) { - log::error("failed to create directory link: {} -> {}", - localDir.string(), sourceDir.string()); - return false; - } -#else - fs::create_directory_symlink(sourceDir, localDir, ec); - if (ec) { - log::error("failed to create symlink: {} -> {} ({})", - localDir.string(), sourceDir.string(), ec.message()); - return false; + if constexpr (platform::is_windows) { + if (!platform::create_directory_link(localDir, sourceDir)) { + log::error("failed to create directory link: {} -> {}", + localDir.string(), sourceDir.string()); + return false; + } + } else { + fs::create_directory_symlink(sourceDir, localDir, ec); + if (ec) { + log::error("failed to create symlink: {} -> {} ({})", + localDir.string(), sourceDir.string(), ec.message()); + return false; + } } -#endif log::debug("linked local index repo: {} -> {}", localDir.string(), sourceDir.string()); return true; diff --git a/src/core/xim/resolver.cpp b/src/core/xim/resolver.cpp index 5ca7575f4..9fdf585fd 100644 --- a/src/core/xim/resolver.cpp +++ b/src/core/xim/resolver.cpp @@ -41,6 +41,59 @@ std::string pin_target_to_subos(const std::string& target, return namePart + "@" + active; } +std::expected require_install_tool(InstallPlan& plan, + std::string_view toolKey) { + auto nodes = plan.nodes; + std::unordered_map indices; + for (std::size_t i = 0; i < nodes.size(); ++i) { + const auto key = node_key_(nodes[i].canonicalName, nodes[i].version); + if (!indices.emplace(key, i).second) + return std::unexpected("duplicate installation node: " + key); + } + const auto tool = indices.find(std::string(toolKey)); + if (tool == indices.end()) + return std::unexpected("installation tool is absent from the plan: " + std::string(toolKey)); + std::vector> dependencies(nodes.size()); + for (std::size_t i = 0; i < nodes.size(); ++i) { + for (const auto& edge : nodes[i].depEdges) { + const auto dependency = indices.find(edge.nodeKey); + if (dependency == indices.end()) + return std::unexpected("installation dependency is absent from the plan: " + edge.nodeKey); + dependencies[i].push_back(dependency->second); + } + } + std::vector colors(nodes.size()); + std::vector order; + std::function visit = [&](std::size_t node) { + if (colors[node] == 1) return false; + if (colors[node] == 2) return true; + colors[node] = 1; + for (const auto dependency : dependencies[node]) + if (!visit(dependency)) return false; + colors[node] = 2; + order.push_back(node); + return true; + }; + if (!visit(tool->second)) + return std::unexpected("cyclic installation tool dependencies: " + std::string(toolKey)); + const std::set toolClosure(order.begin(), order.end()); + for (std::size_t i = 0; i < nodes.size(); ++i) { + if (toolClosure.contains(i)) continue; + if (std::ranges::find(dependencies[i], tool->second) == dependencies[i].end()) { + dependencies[i].push_back(tool->second); + nodes[i].depEdges.push_back({.spec = nodes[tool->second].canonicalName, + .kind = DepKind::Build, .nodeKey = std::string(toolKey)}); + } + } + for (std::size_t i = 0; i < nodes.size(); ++i) + if (!visit(i)) return std::unexpected("cyclic installation dependencies"); + std::vector ordered; + ordered.reserve(nodes.size()); + for (const auto i : order) ordered.push_back(std::move(nodes[i])); + plan.nodes = std::move(ordered); + return {}; +} + std::expected resolve(PackageCatalog& catalog, std::span targets, const std::string& platform, const SubosVersionFn& subosVersionOf, const std::string& hostArch) { diff --git a/src/core/xim/resolver.cppm b/src/core/xim/resolver.cppm index 83162e58f..d4aa48024 100644 --- a/src/core/xim/resolver.cppm +++ b/src/core/xim/resolver.cppm @@ -22,6 +22,11 @@ std::string node_key_(std::string_view canonicalName, std::string_view version); std::string node_key_(const PackageMatch& match); +// An installation tool orders the remaining graph after its own dependency +// closure. These Build edges do not become payload runtime dependencies. +std::expected require_install_tool(InstallPlan& plan, + std::string_view toolKey); + // What version of `name` THIS SUBOS is pinned to, if any. // // Deliberately not called "active": the caller answers from a precedence, and diff --git a/src/core/xim/retention.cpp b/src/core/xim/retention.cpp new file mode 100644 index 000000000..e35872665 --- /dev/null +++ b/src/core/xim/retention.cpp @@ -0,0 +1,95 @@ +module xlings.core.xim.retention; + +import std; +import xlings.store; +import xlings.subos.rootfs; +import xlings.core.config; +import xlings.core.log; +import xlings.core.xim.payload; +import xlings.core.xim.installer; +import xlings.core.xim.libxpkg.types.type; + +namespace xlings::xim::retention { + +namespace rf = xlings::subos::rootfs; + +namespace { + +fs::path ledger() { return store::ledger_path(Config::paths().dataDir); } + +} // namespace + +store::Pins generation_pins(const fs::path& payload) { + std::vector roots; + std::vector unreadable; + const auto subosRoot = Config::paths().homeDir / "subos"; + std::error_code ec; + for (auto it = fs::directory_iterator(subosRoot, ec); !ec && it != fs::directory_iterator(); + it.increment(ec)) { + const auto& dir = it->path(); + std::error_code sec; + if (fs::is_symlink(it->symlink_status(sec)) || !it->is_directory(sec)) continue; // `current` + if (!fs::exists(dir / std::string(rf::kGenerations), sec)) continue; + const auto name = dir.filename().string(); + for (const int k : rf::generations(dir)) { + auto targets = rf::linked_targets(dir, k); + if (!targets) { + unreadable.push_back(std::format("generation {} of '{}'", k, name)); + continue; + } + roots.push_back({name, k, std::move(*targets)}); + } + } + if (ec && ec != std::errc::no_such_file_or_directory) + unreadable.push_back(subosRoot.string() + " (" + ec.message() + ")"); + return store::pins(payload, roots, unreadable); +} + +std::string describe(const store::Pins& pins) { + std::string out; + for (const auto& h : pins.holders) + out += std::format("{}generation {} of '{}'", out.empty() ? "" : ", ", h.generation, h.subos); + for (const auto& u : pins.unreadable) + out += std::format("{}{} (unreadable)", out.empty() ? "" : ", ", u); + return out; +} + +std::expected retain(const fs::path& payload, std::string target, + std::string version) { + return store::retain(ledger(), {payload, std::move(target), std::move(version), {}}); +} + +std::vector release() { + std::vector released; + auto entries = store::read_ledger(ledger()); + if (!entries) { + log::warn("[xim] retained payloads are kept: {}", entries.error()); + return released; + } + for (const auto& entry : *entries) { + std::error_code ec; + if (!fs::exists(entry.payload, ec)) { + if (!ec) (void)store::forget(ledger(), entry.payload); + continue; + } + // A workspace uses it again, or one cannot be read: not ours to delete. + if (!entry.target.empty() && + detail_::is_version_referenced_anywhere_(PackageScope::Global, entry.target, entry.version)) + continue; + if (generation_pins(entry.payload).held()) continue; + sweep_payload_trash(payload_trash_root(entry.payload)); + if (remove_payload_dir(entry.payload, entry.version) != RemoveOutcome::Removed) { + log::warn("[xim] {} is no longer held by any generation but could not be removed yet", + entry.payload.string()); + continue; + } + if (auto forgotten = store::forget(ledger(), entry.payload); !forgotten) + log::warn("[xim] {}", forgotten.error()); + log::info("[xim] released {}@{}: the last generation that linked into it was pruned", + entry.target, entry.version); + released.push_back(entry.payload); + } + return released; +} + +} // namespace xlings::xim::retention diff --git a/src/core/xim/retention.cppm b/src/core/xim/retention.cppm new file mode 100644 index 000000000..d5d438184 --- /dev/null +++ b/src/core/xim/retention.cppm @@ -0,0 +1,35 @@ +export module xlings.core.xim.retention; + +import std; +import xlings.store; + +// A payload a root generation still links into (SubOS design part 3 §7.1). +// +// `remove` takes a package out of every workspace and normally deletes its +// payload. When a retained generation of a rootfs SubOS links into it, the +// payload is that generation's too: rolling back to it must find every link's +// target. So the removal completes -- unregistered, configuration undone -- +// and the payload stays on disk in the retained ledger, until the last +// generation holding it is pruned. +export namespace xlings::xim::retention { + +namespace fs = std::filesystem; + +// What holds `payload` among the retained generations of this home's SubOSes. +// A generation that cannot be read holds it. +store::Pins generation_pins(const fs::path& payload); + +// "generation 3 of 'dev', generation 4 of 'dev'" -- for a person. +std::string describe(const store::Pins& pins); + +// Keep `payload` (just removed from every workspace) for the generations +// that hold it. +std::expected retain(const fs::path& payload, std::string target, + std::string version); + +// After generations were pruned: delete each retained payload nothing holds +// any longer. One that a workspace uses again (a reinstall of the same +// version) is left alone. Returns the payloads deleted. +std::vector release(); + +} // namespace xlings::xim::retention diff --git a/src/core/xself.cpp b/src/core/xself.cpp index b0245e9d9..5c0811ae5 100644 --- a/src/core/xself.cpp +++ b/src/core/xself.cpp @@ -48,7 +48,7 @@ int cmd_help(EventStream& stream) { {{"name", "install"}, {"desc", "Install xlings from release package"}}, {{"name", "uninstall"}, {"desc", "Remove this xlings install entirely (-y / --keep-data / --dry-run)"}}, {{"name", "init"}, {"desc", "Create home/data/subos dirs"}}, - {{"name", "update"}, {"desc", "Update index + install latest xlings"}}, + {{"name", "update"}, {"desc", "Update index + install latest xlings (--user: into this home, beside a system package's xlings)"}}, {{"name", "config"}, {"desc", "Show configuration details"}}, {{"name", "clean"}, {"desc", "Remove cache + gc orphaned packages (--dry-run)"}}, {{"name", "migrate"}, {"desc", "Migrate old layout to subos/default"}}, @@ -108,8 +108,12 @@ int run(int argc, char* argv[], EventStream& stream) { return cmd_init(); } if (action == "update") { - if (auto rc = reject_surplus(action); rc != 0) return rc; - return cmd_update(); + bool user = false; + for (const auto& a : args) { + if (a == "--user") user = true; + else return reject("update", a); + } + return cmd_update(user); } if (action == "config") { if (auto rc = reject_surplus(action); rc != 0) return rc; @@ -138,6 +142,9 @@ int run(int argc, char* argv[], EventStream& stream) { for (std::size_t i = 0; i < args.size(); ++i) { const auto& arg = args[i]; if (arg == "--fix") fix = true; + // Spelled here for the parser; `--isolation` is answered before + // this (cli.cpp), and --json only shapes that report. + else if (arg == "--json" || arg == "--isolation") {} // Discards unreadable binding metadata, so it is opt-in on top // of --fix rather than part of it. else if (arg == "--reset-metadata") resetMetadata = true; diff --git a/src/core/xself/doctor.cpp b/src/core/xself/doctor.cpp index 3f72d9f3b..8ae159c6a 100644 --- a/src/core/xself/doctor.cpp +++ b/src/core/xself/doctor.cpp @@ -1,11 +1,3 @@ -module; - -// The NSS cell needs the caller's effective uid. `import std;` does not pull -// POSIX in, and a named module's purview forbids including it there, so it -// goes in the global module fragment -- same arrangement as subos/sandbox.cppm. -#if !defined(_WIN32) -#include -#endif module xlings.core.xself.doctor; import std; @@ -20,6 +12,7 @@ import xlings.core.utils; import xlings.runtime; import xlings.core.elf_same_source; import xlings.core.entry_binary; +import xlings.core.home; import xlings.core.version_order; import xlings.core.xvm.types; import xlings.core.xvm.bindings; @@ -33,13 +26,14 @@ import xlings.core.xvm.relocation; // prune_empty_asset_dirs: `--fix` deletes the same links the removal path // deletes and must leave the same shape behind. import xlings.core.xvm.commands; +import xlings.core.xvm.materialize; import xlings.core.xself.repair; import xlings.core.xim.catalog; import xlings.core.xim.payload; import xlings.core.xim.install_state; import xlings.core.xim.commands; import xlings.core.profile; -import xlings.core.subos.manifest; +import xlings.subos.manifest; import xlings.core.xim.repo; import xlings.core.destructive_log; import xlings.core.home_config; @@ -48,12 +42,6 @@ import xlings.core.home_identity; namespace xlings::xself { -#ifdef _WIN32 - -#else - -#endif - // First `limit` names, comma-joined, with a count for the rest. // // A drift finding can carry dozens of names on a home that has not been @@ -110,11 +98,7 @@ DoctorState load_state_() { st.otherSnapshots.push_back(std::move(snapshot)); } -#ifdef _WIN32 - st.xlingsBin = p.homeDir / "bin" / "xlings.exe"; -#else - st.xlingsBin = p.homeDir / "bin" / "xlings"; -#endif + st.xlingsBin = p.homeDir / "bin" / (platform::is_windows ? "xlings.exe" : "xlings"); if (!fs::exists(st.xlingsBin)) st.xlingsBin = p.homeDir / "xlings"; // Was this database written for another root? @@ -170,18 +154,18 @@ bool payload_has_any_executable_(const fs::path& dir) { for (auto& e : platform::dir_entries(d)) { std::error_code fec; if (!e.is_regular_file(fec) && !e.is_symlink(fec)) continue; -#if defined(_WIN32) - auto ext = e.path().extension().string(); - if (ext == ".exe" || ext == ".bat" || ext == ".cmd") return true; -#else - auto st = fs::status(e.path(), fec); - if (!fec && (st.permissions() & (fs::perms::owner_exec - | fs::perms::group_exec - | fs::perms::others_exec)) - != fs::perms::none) { - return true; + if constexpr (platform::is_windows) { + auto ext = e.path().extension().string(); + if (ext == ".exe" || ext == ".bat" || ext == ".cmd") return true; + } else { + auto st = fs::status(e.path(), fec); + if (!fec && (st.permissions() & (fs::perms::owner_exec + | fs::perms::group_exec + | fs::perms::others_exec)) + != fs::perms::none) { + return true; + } } -#endif } return false; }; @@ -976,6 +960,8 @@ Scan detect_(const DoctorState& st, const CoordinateProbe& probe, } for (auto&& f : detect_entry_binary_(st)) add(std::move(f)); + for (auto&& f : detect_policy_clients_(p.homeDir, + entry_binary::version_of(entry_binary::path_of(p.homeDir)))) add(std::move(f)); for (auto&& f : detect_versions_sources_()) add(std::move(f)); @@ -2271,93 +2257,93 @@ Scan detect_(const DoctorState& st, const CoordinateProbe& probe, // Notice, not Error, when it works: this is the cell that is supposed // to be boring. When it fails it is an Error, because a home whose // packages resolve no users is broken in a way nothing else reports. -#ifdef __linux__ - for (const auto& root : payloadAuditRoots) { - const auto& storeName = root.storeName; - // `-x-glibc`, any namespace. - if (!storeName.ends_with("-x-glibc")) continue; - std::error_code sec; - const auto lib64 = root.path / "lib64"; - const auto loader = lib64 / "ld-linux-x86-64.so.2"; - const auto getent = root.path / "bin" / "getent"; - if (!fs::is_regular_file(loader, sec)) continue; - if (!fs::is_regular_file(getent, sec)) continue; - - const auto& version = root.version; - const auto cmd = std::format( - "\"{}\" --library-path \"{}\" \"{}\" passwd {} 2>/dev/null", - loader.string(), lib64.string(), getent.string(), - ::geteuid()); - auto [rc, out] = platform::run_command_capture(cmd); - - if (rc == 0 && !out.empty()) { + if constexpr (platform::is_linux) { + for (const auto& root : payloadAuditRoots) { + const auto& storeName = root.storeName; + // `-x-glibc`, any namespace. + if (!storeName.ends_with("-x-glibc")) continue; + std::error_code sec; + const auto lib64 = root.path / "lib64"; + const auto loader = lib64 / "ld-linux-x86-64.so.2"; + const auto getent = root.path / "bin" / "getent"; + if (!fs::is_regular_file(loader, sec)) continue; + if (!fs::is_regular_file(getent, sec)) continue; + + const auto& version = root.version; + const auto cmd = std::format( + "\"{}\" --library-path \"{}\" \"{}\" passwd {} 2>/dev/null", + loader.string(), lib64.string(), getent.string(), + platform::user_ids().euid); + auto [rc, out] = platform::run_command_capture(cmd); + + if (rc == 0 && !out.empty()) { + add({ + .kind = FindingKind::NssResolution, + .level = FindingLevel::Notice, + .target = storeName, + .version = version, + .detail = std::format( + "glibc {}: resolves the current user " + "(getent passwd {} under our loader)", + version, platform::user_ids().euid), + }); + continue; + } + + // Which backends this host asks for that we do not ship. Part of + // the finding, not a separate check: it is the only actionable + // thing about the failure. + std::string missing; + { + std::ifstream nss("/etc/nsswitch.conf"); + std::set seen; + for (std::string line; std::getline(nss, line);) { + if (auto h = line.find('#'); h != std::string::npos) + line.resize(h); + auto colon = line.find(':'); + if (colon == std::string::npos) continue; + auto rest = line.substr(colon + 1); + // `[NOTFOUND=return]` is control flow, not a module. + while (true) { + auto ob = rest.find('['); + if (ob == std::string::npos) break; + auto cb = rest.find(']', ob); + if (cb == std::string::npos) { + rest.resize(ob); + break; + } + rest.erase(ob, cb - ob + 1); + } + std::istringstream toks(rest); + for (std::string mod; toks >> mod;) { + if (!seen.insert(mod).second) continue; + if (!fs::is_regular_file( + lib64 / ("libnss_" + mod + ".so.2"), sec)) { + if (!missing.empty()) missing += ", "; + missing += mod; + } + } + } + } + add({ .kind = FindingKind::NssResolution, - .level = FindingLevel::Notice, + .level = FindingLevel::Error, .target = storeName, .version = version, .detail = std::format( - "glibc {}: resolves the current user " - "(getent passwd {} under our loader)", - version, ::geteuid()), + "glibc {}: does NOT resolve the current user under our " + "loader{}. Any package switched to this interpreter will " + "see no user, silently.", + version, + missing.empty() + ? std::string{} + : std::format( + " -- /etc/nsswitch.conf names backend(s) this " + "payload does not ship: {}", missing)), }); - continue; } - - // Which backends this host asks for that we do not ship. Part of - // the finding, not a separate check: it is the only actionable - // thing about the failure. - std::string missing; - { - std::ifstream nss("/etc/nsswitch.conf"); - std::set seen; - for (std::string line; std::getline(nss, line);) { - if (auto h = line.find('#'); h != std::string::npos) - line.resize(h); - auto colon = line.find(':'); - if (colon == std::string::npos) continue; - auto rest = line.substr(colon + 1); - // `[NOTFOUND=return]` is control flow, not a module. - while (true) { - auto ob = rest.find('['); - if (ob == std::string::npos) break; - auto cb = rest.find(']', ob); - if (cb == std::string::npos) { - rest.resize(ob); - break; - } - rest.erase(ob, cb - ob + 1); - } - std::istringstream toks(rest); - for (std::string mod; toks >> mod;) { - if (!seen.insert(mod).second) continue; - if (!fs::is_regular_file( - lib64 / ("libnss_" + mod + ".so.2"), sec)) { - if (!missing.empty()) missing += ", "; - missing += mod; - } - } - } - } - - add({ - .kind = FindingKind::NssResolution, - .level = FindingLevel::Error, - .target = storeName, - .version = version, - .detail = std::format( - "glibc {}: does NOT resolve the current user under our " - "loader{}. Any package switched to this interpreter will " - "see no user, silently.", - version, - missing.empty() - ? std::string{} - : std::format( - " -- /etc/nsswitch.conf names backend(s) this " - "payload does not ship: {}", missing)), - }); } -#endif // The whole subos tree, not four directories one level deep. // @@ -3034,12 +3020,37 @@ void repair_local_(const DoctorState& st, const Scan& scan, Config::display_path(source))); continue; } - xvm::place_asset(source, f.shimPath); - // Verified, not assumed. place_asset is best-effort by - // design (it logs and returns on a source that vanished - // mid-run), so trusting it here would let "re-pointed but - // still dangling" print as a repair -- the exact shape this - // whole change exists to remove. + auto claims = xvm::materialize::collect_claims(st.db, st.wsInstalled, + Config::paths().subosDir, Config::paths().libDir, st.homeStr); + if (claims) { + const auto store = (Config::paths().dataDir / "xpkgs").lexically_normal(); + const auto payload = [&](fs::path path) { + path = path.lexically_normal(); + while (!path.empty() && path != path.parent_path()) { + if (path.parent_path().parent_path() == store) return path; + path = path.parent_path(); + } + return fs::path{}; + }; + ec.clear(); + auto old = fs::read_symlink(f.shimPath, ec); + if (!ec) { + if (old.is_relative()) old = f.shimPath.parent_path() / old; + old = old.lexically_normal(); + const auto owner = payload(source); + const bool exists = fs::exists(f.shimPath, ec); + if (!ec && !exists && !owner.empty() && payload(old) == owner && + old.filename() == fs::path(source).filename()) + claims->push_back({old, f.shimPath, false}); + } + } + const auto placed = claims + ? xvm::place_asset(source, f.shimPath, *claims) + : xvm::MaterializationResult(std::unexpected(claims.error())); + if (!placed) { + note(glyph::mark(glyph::failed, "sysroot repair failed"), placed.error()); + continue; + } ec.clear(); if (fs::exists(f.shimPath, ec)) { note(glyph::mark(glyph::bullet, "link repointed"), @@ -3122,7 +3133,15 @@ void repair_local_(const DoctorState& st, const Scan& scan, Config::display_path(source))); continue; } - xvm::place_asset(source, f.shimPath); + const auto claims = xvm::materialize::collect_claims(st.db, st.wsInstalled, + Config::paths().subosDir, Config::paths().libDir, st.homeStr); + const auto placed = claims + ? xvm::place_asset(source, f.shimPath, *claims) + : xvm::MaterializationResult(std::unexpected(claims.error())); + if (!placed) { + note(glyph::mark(glyph::failed, "sysroot repair failed"), placed.error()); + continue; + } std::error_code pec; if (fs::exists(f.shimPath, pec)) { note(glyph::mark(glyph::bullet, "link placed"), @@ -4814,6 +4833,10 @@ Counts count_(const Scan& scan) { // the command. The line still prints, with the command that // settles it. break; + case FindingKind::PolicyClientUnsupported: + if (f.level == FindingLevel::Error) ++c.subos; + else ++c.warnings; + break; case FindingKind::EntryBinaryDrift: // Counts as nothing either: both shapes it reports are states // a user may have chosen on purpose. It must be visible, not @@ -5217,6 +5240,11 @@ void render_(const Scan& scan, const RepairReport& repair, bool fix, add(" " + glyph::mark(glyph::note, "note"), f.remedyNote); } break; + case FindingKind::PolicyClientUnsupported: + add(glyph::mark(glyph::failed, "policy client"), f.detail); + if (!f.remedy.empty()) add(" " + glyph::mark(glyph::remedy, "run"), f.remedy); + if (!f.remedyNote.empty()) add(" " + glyph::mark(glyph::note, "note"), f.remedyNote); + break; case FindingKind::EntryBinaryDrift: // Printed in full, always -- NOT collapsed into the notice // summary. There are at most two of these, and each one @@ -5502,6 +5530,37 @@ int cmd_doctor(EventStream& stream, bool fix, bool resetMetadata, bool dryRun, b // directories are immutable, so their scan results are cacheable by path // and mtime -- which is a separate change with its own risk surface. // See .agents/docs/2026-08-10-doctor-fix-hang-and-537.md (D2 vs D3). + // What a system install contributes (design §4), said once at the top: + // which binary is answering, and the system files it reads. + { + const auto entry = home::describe_entry(platform::get_executable_path(), + Config::paths().homeDir); + if (entry.system) + stream.emit(LogEvent{LogLevel::info, std::format( + "entry: {} (a system package's; its package manager updates it)", + entry.path.string())}); + // Without `/bin/xlings` every shim is skipped and nothing this + // home installs is on PATH (design part 2 §2.1). Any write command + // links it; `--fix` does too. + if (entry.system && xlings_binary_in_home(Config::paths().homeDir).empty()) { + if (fix && !dryRun && ensure_system_entry_link(Config::paths().homeDir)) { + stream.emit(LogEvent{LogLevel::info, "fixed: this home's entry now links to " + + entry.path.string()}); + } else { + stream.emit(LogEvent{LogLevel::error, std::format( + "this home has no entry: nothing it installs is on PATH until it links to " + "{} -- run `xlings self doctor --fix` (or any install)", entry.path.string())}); + } + } + std::error_code ec; + if (const auto cfg = home::system_config_path(); fs::is_regular_file(cfg, ec)) + stream.emit(LogEvent{LogLevel::info, std::format( + "system config: {} (defaults this home's .xlings.json overrides)", cfg.string())}); + if (const auto layer = home::system_layer()) + stream.emit(LogEvent{LogLevel::info, std::format( + "system layer: {} (its programs follow this home's on PATH; `sudo xlings install --system` adds to it)", layer->string())}); + } + const bool deepAudit = deep || fix; if (scope && !deepAudit) { stream.emit(ErrorEvent{ diff --git a/src/core/xself/doctor.cppm b/src/core/xself/doctor.cppm index 74664faef..d82efea2a 100644 --- a/src/core/xself/doctor.cppm +++ b/src/core/xself/doctor.cppm @@ -1,11 +1,3 @@ -module; - -// The NSS cell needs the caller's effective uid. `import std;` does not pull -// POSIX in, and a named module's purview forbids including it there, so it -// goes in the global module fragment -- same arrangement as subos/sandbox.cppm. -#if !defined(_WIN32) -#include -#endif export module xlings.core.xself.doctor; import std; @@ -33,7 +25,8 @@ import xlings.core.xim.catalog; import xlings.core.xim.payload; // classify_payload_platform import xlings.core.xim.install_state; import xlings.core.profile; -import xlings.core.subos.manifest; +import xlings.subos.manifest; +import xlings.platform; import xlings.platform.target; // platform::host().arch for the runtime family namespace xlings::xself { @@ -321,6 +314,8 @@ enum class FindingKind { // why `self update` used to refuse), so "the package version" is itself // ambiguous and a check built on it would inherit the ambiguity. EntryBinaryDrift, + // A policy requires a newer client than the actual entry can enforce. + PolicyClientUnsupported, // One version registered twice, under two spellings of its key -- bare // and namespaced -- both naming the same payload. The owner-less half is // a record from before providers were recorded; the owned half was @@ -439,11 +434,7 @@ struct DoctorState { std::optional relocation; }; -#ifdef _WIN32 -constexpr std::string_view shim_ext_ = ".exe"; -#else -constexpr std::string_view shim_ext_ = ""; -#endif +constexpr std::string_view shim_ext_ = platform::is_windows ? ".exe" : ""; // ── detection ──────────────────────────────────────────────────────── @@ -499,6 +490,8 @@ struct AuditSelection { std::function onAuditDone; }; +std::vector detect_policy_clients_(const fs::path& homeDir, std::string_view entryVersion); + Scan detect_(const DoctorState& st, const CoordinateProbe& probe, const AuditSelection& audit); diff --git a/src/core/xself/doctor_policy.cpp b/src/core/xself/doctor_policy.cpp new file mode 100644 index 000000000..7bdd89833 --- /dev/null +++ b/src/core/xself/doctor_policy.cpp @@ -0,0 +1,53 @@ +module xlings.core.xself.doctor; + +import std; +import xlings.libs.json; +import xlings.platform; +import xlings.core.entry_binary; +import xlings.subos.home_view; +import xlings.subos.policy; +import xlings.subos.policy_store; + +namespace xlings::xself { + +std::vector detect_policy_clients_(const fs::path& homeDir, std::string_view entryVersion) { + std::vector out; + const subos::HomeView home{homeDir}; + const auto root = homeDir / "config" / "subos"; + std::error_code ec; + const bool exists = fs::exists(root, ec); + if (!exists && !ec) return out; + const auto entry = entry_binary::path_of(homeDir); + const auto remedy = platform::shell_quote(entry.string()) + " self update"; + for (auto it = fs::directory_iterator(root, ec); !ec && it != std::default_sentinel; it.increment(ec)) { + const auto name = it->path().filename().string(); + auto file = subos::policy_store::read(home, name); + std::string detail, fix; + if (!file) { + detail = file.error(); + fix = "restore " + home.policy_file(name).string() + "; doctor never removes the policy"; + } else if (!*file || (**file).min_client.empty()) { + continue; + } else if (entryVersion.empty()) { + detail = std::format("{}: min_client = {}; could not observe the entry's version at {}", + name, (**file).min_client, entry.string()); + fix = "restore the entry, then run self doctor again; its version was not observed"; + } else if (auto supported = subos::policy::check_client(**file, entryVersion); !supported) { + detail = std::format("{}: {} ({})", name, supported.error(), entry.string()); + fix = "older clients cannot enforce this policy; upgrade the entry before using this instance"; + } else { + continue; + } + out.push_back({.kind = FindingKind::PolicyClientUnsupported, + .level = FindingLevel::Error, .target = "xlings", .detail = std::move(detail), + .remedy = file && *file && !(**file).min_client.empty() && !entryVersion.empty() ? remedy : "", + .remedyNote = std::move(fix), .subos = {name}}); + } + if (ec) + out.push_back({.kind = FindingKind::PolicyClientUnsupported, .level = FindingLevel::Error, + .target = "xlings", .detail = root.string() + ": policy requirements could not be read: " + ec.message(), + .remedyNote = "restore access to the policy directory, then run self doctor again"}); + return out; +} + +} // namespace xlings::xself diff --git a/src/core/xself/init.cpp b/src/core/xself/init.cpp index ac6906c04..868806a19 100644 --- a/src/core/xself/init.cpp +++ b/src/core/xself/init.cpp @@ -9,16 +9,19 @@ import xlings.core.log; import xlings.platform; import xlings.core.xself.compat; import xlings.core.xself.profile_resources; -import xlings.core.subos.manifest; +import xlings.subos.manifest; import xlings.core.xim.commands; import xlings.core.xvm.types; import xlings.core.xvm.db; +import xlings.core.xvm.owner; import xlings.core.xvm.shim; import xlings.core.xvm.shim_table; import xlings.core.xvm.shim_identity; import xlings.core.xvm.lock; import xlings.core.entry_binary; import xlings.core.home_identity; +import xlings.core.home; +import xlings.core.subos.root; namespace xlings::xself { @@ -34,20 +37,12 @@ bool is_bootstrap_home_root(const fs::path& root) { std::error_code ec; if (root.empty() || !fs::exists(root / ".xlings.json", ec)) return false; if (!fs::exists(root / "bin", ec) || !fs::is_directory(root / "bin", ec)) return false; -#ifdef _WIN32 - return fs::exists(root / "bin" / "xlings.exe", ec); -#else - return fs::exists(root / "bin" / "xlings", ec); -#endif + return fs::exists(root / "bin" / ("xlings" + std::string(platform::exe_suffix)), ec); } fs::path xlings_binary_in_home(const fs::path& home_dir) { -#ifdef _WIN32 - constexpr std::string_view name = "xlings.exe"; -#else - constexpr std::string_view name = "xlings"; -#endif - auto bin = home_dir / "bin" / std::string(name); + const auto name = "xlings" + std::string(platform::exe_suffix); + auto bin = home_dir / "bin" / name; if (fs::exists(bin)) return bin; // Bootstrap layout. Before `self init` runs, the binary sits directly at @@ -64,6 +59,26 @@ fs::path xlings_binary_in_home(const fs::path& home_dir) { return {}; } +bool ensure_system_entry_link(const fs::path& home_dir) { + if (home_dir.empty()) return false; + if (!xlings_binary_in_home(home_dir).empty()) return true; + if constexpr (!platform::is_posix) return false; + const auto exe = platform::get_executable_path(); + if (!home::describe_entry(exe, home_dir).system) return false; + std::error_code ec; + const auto link = home_dir / "bin" / "xlings"; + fs::create_directories(link.parent_path(), ec); + fs::create_symlink(exe, link, ec); + if (ec) { + log::debug("[xlings:self]: cannot link {} to {}: {}", link.string(), exe.string(), + ec.message()); + return false; + } + log::info("this home's xlings is the system's: {} -> {}", Config::display_path(link), + exe.string()); + return true; +} + LinkResult create_shim(const fs::path& source, const fs::path& target) { std::error_code ec; @@ -86,19 +101,19 @@ LinkResult create_shim(const fs::path& source, const fs::path& target) { } ec.clear(); -#if !defined(_WIN32) - // Unix: prefer relative symlink - auto rel = fs::relative(source, target.parent_path(), ec); - if (!ec && !rel.empty()) { - fs::create_symlink(rel, target, ec); + if constexpr (platform::is_posix) { + // Unix: prefer relative symlink + auto rel = fs::relative(source, target.parent_path(), ec); + if (!ec && !rel.empty()) { + fs::create_symlink(rel, target, ec); + if (!ec) return LinkResult::Symlink; + } + ec.clear(); + // Fallback: absolute symlink + fs::create_symlink(source, target, ec); if (!ec) return LinkResult::Symlink; + ec.clear(); } - ec.clear(); - // Fallback: absolute symlink - fs::create_symlink(source, target, ec); - if (!ec) return LinkResult::Symlink; - ec.clear(); -#endif // Hardlink (Unix fallback / Windows primary) fs::create_hard_link(source, target, ec); @@ -447,6 +462,19 @@ bool ensure_home_layout(const fs::path& home_dir) { log::warn("[xlings:self]: {} -- the home is recognised by its layout " "until the marker can be written", w.error()); } + // Declared at creation (xlings.core.home): the deployment mode this home + // was set up in, and the layout it is at. Inference is for markers that + // predate modes; a home created now says what it is. + { + const auto source = home_dir == Config::paths().homeDir + ? Config::paths().homeSource : home::Source::Default; + const auto ctx = home::describe(home_dir, source); + if (ctx.writable() && home_identity::has_marker(home_dir)) { + (void)home::declare(home_dir, + ctx.modeDeclared ? std::nullopt : std::optional(ctx.mode), + home::kLayout); + } + } // Not write_if_missing_: every home that predates `subos_info` already has // this file, so "missing" is exactly the case that never fires on the @@ -476,6 +504,7 @@ bool ensure_home_layout(const fs::path& home_dir) { ensure_home_config_defaults_(home_dir); + ensure_system_entry_link(home_dir); auto xlings_bin = xlings_binary_in_home(home_dir); if (!xlings_bin.empty()) { // A home whose shims could not be written is not a laid-out home. It @@ -497,13 +526,14 @@ bool ensure_home_layout(const fs::path& home_dir) { // `self doctor --fix` by hand -- 172 names on a measured home, every one // of them an active program the user cannot invoke. // - // `self init` runs on install AND on update, so the upgrade that fixes - // the cause also repairs what it did. On a healthy home the diff is - // empty and this costs one directory scan. + // Explicit self init and the install path both rebuild the table. + // On a healthy home the diff is empty and costs one directory scan. // // Not fatal on failure: the table is derived and converges on the next // install / use, and a home whose shims are laid out IS laid out. - if (auto sync = sync_shim_tables(); sync.changed()) { + const auto sync = sync_shim_tables(); + if (!sync.root_error.empty()) return false; + if (sync.changed()) { log::info("routing table: +{} -{} ~{} shim(s)", sync.added, sync.removed, sync.repointed); } @@ -634,6 +664,39 @@ xvm::TableReport apply_shim_table(const fs::path& subos_dir, ShimSyncSummary sync_shim_tables() { ShimSyncSummary summary; + // The first write command of a home whose xlings is a system package's: + // the package manager installed a binary, not a home, and no `self init` + // ever ran for it. Lay the home out now (that rebuilds this table too), + // and read the state it wrote -- the workspace this command loaded at + // startup did not exist yet, so it is "not observed", and the table + // would refuse to rebuild from it. + if (const auto home = Config::paths().homeDir; + xlings_binary_in_home(home).empty() && ensure_system_entry_link(home)) { + ensure_home_layout(home); + Config::reload_state(); + } + // A rootfs SubOS's /usr is derived from the same workspace (design part 2 + // §6): every workspace change gives it a new generation. Before the entry + // check: a root's /usr does not need shims. + if (const auto dir = Config::xvm_artifact_subos_dir(); + (dir != Config::global_subos_dir() || Config::global_workspace_observed()) + && dir.parent_path() == Config::paths().homeDir / "subos") { + const auto name = dir.filename().string(); + if (auto r = subos_root::refresh(Config::paths().homeDir, name, dir, Config::workspace(), + Config::versions(), "workspace change")) { + if (!*r) { + summary.root_error = std::format("the root of subos '{}' was not updated: {}", + name, r->error()); + log::error("{}", summary.root_error); + return summary; + } else if ((*r)->changed) { + log::info("subos '{}': root generation {}{}", name, (*r)->generation, + (*r)->conflicts ? std::format(" ({} name conflict(s), see `xlings subos " + "status {}`)", (*r)->conflicts, name) + : std::string{}); + } + } + } auto entry = xlings_binary_in_home(Config::paths().homeDir); std::error_code ec; if (entry.empty() || !fs::exists(entry, ec)) { @@ -718,6 +781,7 @@ ShimSyncSummary sync_shim_tables() { sync_one(Config::xvm_artifact_subos_dir(), Config::workspace(), "scope", scopeObserved); + // In project scope, the global active subos too: the project's bin is // never on PATH, so its command names must also exist in the directory // that is. `project_contributions()` is what carries them there. @@ -879,16 +943,81 @@ RepointSummary repoint_stale_shims(const fs::path& home) { bool replace_entry_binary(const fs::path& payloadBinary, const fs::path& entry, std::string_view coordinate, - std::string_view toVersion) { + std::string_view toVersion, + const PackageEntryActivation& activation) { + const auto home = entry.parent_path().filename() == "bin" + ? entry.parent_path().parent_path() + : entry.parent_path(); + // The system home of a machine whose root is a SubOS (deployment R, + // part 2 §5): its /usr/bin/xlings is a link of the current generation + // into this payload, so the version that runs moves with the generation + // and a rollback brings the previous one back. The entry is stage-0's + // and stays the build the machine booted with. + { + if (subos_root::running_host(home)) { + log::info("xlings {}: /usr/bin/xlings follows the root's generation; the entry " + "({}) stays as stage-0", toVersion, Config::display_path(entry)); + return true; + } + } + try { + if (activation.provider != "xim:xlings" && activation.provider != "xlings") { + log::error("shared entry activation requires the xlings package provider"); + return false; + } + const auto root = fs::canonical(home); + const auto scope = Config::subos_scope(); + std::error_code ec; + const auto declared = fs::symlink_status( + root / "config" / "subos" / scope.name / "policy.json", ec); + if (ec && ec != std::errc::no_such_file_or_directory) { + log::error("shared entry activation cannot inspect scope policy: {}", ec.message()); + return false; + } + const auto entered = std::getenv("XLINGS_SUBOS_MODE"); + const bool borrowed = !activation.source_home.empty(); + if (Config::workspace_config_path().lexically_normal() != + (Config::global_subos_dir() / ".xlings.json").lexically_normal() || borrowed || + declared.type() != fs::file_type::not_found || (entered && *entered)) { + log::info("xlings {} is active in this scope; the home owner's shared entry stays {}", + toVersion, Config::display_path(entry)); + return true; + } + const auto source = fs::canonical(payloadBinary); + const auto store = root / "data" / "xpkgs"; + const auto relative = source.lexically_relative(store); + const auto owner = xvm::coordinate_from_payload_path(source.generic_string()); + if (!owner || owner->package != "xlings" || + (!owner->ns.empty() && owner->ns != "xim") || relative.empty() || + relative.is_absolute() || + (relative.begin()->string() != "xim-x-xlings" && relative.begin()->string() != "xlings")) { + log::error("shared entry activation requires this home's xlings package payload"); + return false; + } + if (!Config::home_context().writable()) { + log::error("shared entry activation refused: this home's layout is read-only"); + return false; + } + if constexpr (platform::is_posix) { + const auto home_owner = platform::file_ownership(root); + const auto entry_owner = platform::file_ownership(entry); + const auto uid = platform::user_ids().uid; + if (!home_owner || !entry_owner || + (uid != 0 && (home_owner->uid != uid || entry_owner->uid != uid))) { + log::error("shared entry activation requires the home owner's authority"); + return false; + } + } + } catch (const std::exception& error) { + log::error("shared entry activation refused: {}", error.what()); + return false; + } if (!entry_binary::replace_with(payloadBinary, entry, coordinate, toVersion)) { return false; } // `/bin/xlings`, or `/xlings` in the bootstrap layout // (`xlings_binary_in_home` accepts both, so this must too). - const auto home = entry.parent_path().filename() == "bin" - ? entry.parent_path().parent_path() - : entry.parent_path(); const auto summary = repoint_stale_shims(home); if (summary.repointed != 0) { log::info("re-pointed {} shim(s) to the new entry binary", diff --git a/src/core/xself/init.cppm b/src/core/xself/init.cppm index c1eae91ea..2ef1895e7 100644 --- a/src/core/xself/init.cppm +++ b/src/core/xself/init.cppm @@ -8,7 +8,7 @@ import xlings.core.config; import xlings.core.xself.compat; // Generated at build time from config/shell/*.{sh,fish,ps1}; see mcpp.toml. import xlings.core.xself.profile_resources; -import xlings.core.subos.manifest; +import xlings.subos.manifest; // The routing table's decision layer. Imported by the INTERFACE because the // three writer functions below name its types; the pure/testable half stays // in xvm, this module is only its binding to a home. @@ -37,6 +37,14 @@ export enum class LinkResult { Symlink, Hardlink, Copy, Failed }; export bool is_builtin_shim(std::string_view name); export bool is_bootstrap_home_root(const fs::path& root); export fs::path xlings_binary_in_home(const fs::path& home_dir); +// Deployment S (design part 2 §5): the running binary is a system package's +// and `home_dir` has no entry of its own. Every shim links to +// `/bin/xlings`, so without one nothing installed is reachable -- the +// packages land and `cmake` is still the host's. The entry becomes a symlink +// to the system binary: the package manager's upgrade reaches every user, and +// `self update` still refuses (what runs is the system file). True when the +// home has an entry afterwards; never replaces one that exists. +export bool ensure_system_entry_link(const fs::path& home_dir); export LinkResult create_shim(const fs::path& source, const fs::path& target); // Returns how many shims could not be created. NOT void: every result used to // be discarded, so a shim that failed -- which on Windows is what a locked @@ -85,9 +93,9 @@ export xvm::TableReport apply_shim_table(const fs::path& subos_dir, // arrive as derived table entries with an owner and a reclamation path, // rather than as files nobody records and nothing can remove. // -// Best-effort by design: a failure here means a name is momentarily missing -// from PATH, not that the install did not happen. Failures are logged, and -// `self doctor` reports the drift. +// Shim updates are best-effort and doctor reports drift. A root projection +// failure is returned to the write command: its /usr must not be reported as +// updated when it still names the previous generation. // What a rebuild changed, summed over the scopes it touched. Returned so the // callers that run on UPGRADE can say what they repaired -- a repair nobody @@ -97,6 +105,7 @@ export struct ShimSyncSummary { std::size_t removed {}; std::size_t repointed {}; std::size_t refused {}; // scopes whose workspace could not be observed + std::string root_error; // a root projection did not converge [[nodiscard]] bool changed() const { return added != 0 || removed != 0 || repointed != 0; @@ -156,10 +165,16 @@ export std::string entry_command(const fs::path& home, std::string_view args); // changes the entry's file object is the one that knows every hard-link shim // just detached from it, so the repair lives with the change rather than // with each caller remembering it. +export struct PackageEntryActivation { + std::string provider; + fs::path source_home; +}; + export bool replace_entry_binary(const fs::path& payloadBinary, const fs::path& entry, std::string_view coordinate, - std::string_view toVersion); + std::string_view toVersion, + const PackageEntryActivation& activation); bool is_builtin_shim(std::string_view name); diff --git a/src/core/xself/install.cpp b/src/core/xself/install.cpp index af63d3970..32d80e207 100644 --- a/src/core/xself/install.cpp +++ b/src/core/xself/install.cpp @@ -4,6 +4,7 @@ import std; import xlings.core.xself.init; import xlings.core.xself.shell_profile; import xlings.core.config; +import xlings.core.home; import xlings.core.home_config; import xlings.core.xvm.lock; import xlings.libs.json; @@ -16,7 +17,8 @@ import xlings.core.utils; import xlings.core.version_order; import xlings.core.confirm; import xlings.core.destructive_log; -import xlings.core.subos.userdata; +import xlings.subos.userdata; +import xlings.core.subos.ports; namespace xlings::xself { @@ -156,18 +158,14 @@ bool is_under_temp_dir(const fs::path& p) { // quick_install uses "xlings-install" in temp dir name — definitive indicator if (s.find("xlings-install") != std::string::npos) return true; if (s.find("/tmp/") == 0 || s.find("/tmp") == 0) return true; -#if defined(__APPLE__) // macOS: /var/folders/... is system temp; canonical paths may use /private/var - if (s.find("/var/folders/") != std::string::npos || - s.find("/private/var/folders/") != std::string::npos) + if (platform::is_macos && (s.find("/var/folders/") != std::string::npos || + s.find("/private/var/folders/") != std::string::npos)) return true; -#endif -#if defined(_WIN32) // Windows: match path segments /Temp/ or /Tmp/ (GetTempPath uses these) - if (s.find("/Temp/") != std::string::npos || s.find("/Tmp/") != std::string::npos || - s.find("/temp/") != std::string::npos || s.find("/tmp/") != std::string::npos) + if (platform::is_windows && (s.find("/Temp/") != std::string::npos || s.find("/Tmp/") != std::string::npos || + s.find("/temp/") != std::string::npos || s.find("/tmp/") != std::string::npos)) return true; -#endif for (const char* env : {"TMPDIR", "TEMP", "TMP", "RUNNER_TEMP"}) { if (const char* v = std::getenv(env)) { auto prefix = fs::path(v).generic_string(); @@ -175,11 +173,9 @@ bool is_under_temp_dir(const fs::path& p) { auto prefixSlash = prefix; if (prefixSlash.back() != '/') prefixSlash += '/'; if (s.starts_with(prefixSlash) || s == prefix) return true; -#if defined(__APPLE__) // macOS: /var resolves to /private/var; canonical paths may have /private prefix - if (prefix.starts_with("/var/") && s.starts_with("/private" + prefixSlash)) + if (platform::is_macos && prefix.starts_with("/var/") && s.starts_with("/private" + prefixSlash)) return true; -#endif } } return false; @@ -223,12 +219,7 @@ fs::path detect_existing_home() { return absolute.lexically_normal(); } auto [rc, out] = platform::run_command_capture( -#ifdef _WIN32 - "where xlings 2>nul" -#else - "command -v xlings 2>/dev/null" -#endif - ); + platform::is_windows ? "where xlings 2>nul" : "command -v xlings 2>/dev/null"); (void)rc; // "where"/"command -v" may return multiple lines; use first valid path auto lines = utils::split_string(out, '\n'); @@ -273,8 +264,7 @@ void copy_directory_contents(const fs::path& src, const fs::path& dst) { } } -void setup_shell_profiles(const fs::path& homeDir) { -#if defined(__linux__) || defined(__APPLE__) +void setup_posix_profiles_(const fs::path& homeDir) { auto profileSh = homeDir / "config" / "shell" / "xlings-profile.sh"; auto profileFish = homeDir / "config" / "shell" / "xlings-profile.fish"; @@ -287,19 +277,19 @@ void setup_shell_profiles(const fs::path& homeDir) { fs::path rcHome(platform::target_home()); std::vector profiles; -#if defined(__APPLE__) - profiles = { - rcHome / ".zshrc", - rcHome / ".bashrc", - rcHome / ".zprofile", - }; -#else - profiles = { - rcHome / ".bashrc", - rcHome / ".zshrc", - rcHome / ".profile", - }; -#endif + if constexpr (platform::is_macos) { + profiles = { + rcHome / ".zshrc", + rcHome / ".bashrc", + rcHome / ".zprofile", + }; + } else { + profiles = { + rcHome / ".bashrc", + rcHome / ".zshrc", + rcHome / ".profile", + }; + } bool added = false; for (auto& prof : profiles) { @@ -372,7 +362,9 @@ void setup_shell_profiles(const fs::path& homeDir) { std::println(" {}", sourceLine); } } -#elif defined(_WIN32) +} + +void setup_windows_profiles_(const fs::path& homeDir) { auto xlingsBin = homeDir / "subos" / "current" / "bin"; std::string setHomeCmd = "powershell -NoProfile -Command \"" "[System.Environment]::SetEnvironmentVariable('XLINGS_HOME','" @@ -446,7 +438,11 @@ void setup_shell_profiles(const fs::path& homeDir) { std::println("[xlings:self] no PowerShell host was configured, add manually to $PROFILE:"); std::println(" {}", snippet); } -#endif +} + +void setup_shell_profiles(const fs::path& homeDir) { + if constexpr (platform::is_windows) setup_windows_profiles_(homeDir); + else setup_posix_profiles_(homeDir); } // Advise when running as root, distinguishing the two failure-prone cases: @@ -675,7 +671,8 @@ int cmd_install(EventStream& stream) { return 1; } setup_shell_profiles(targetHome); - platform::chown_to_invoker(targetHome); + if (home::describe(targetHome, home::Source::Env).user_owned()) + platform::chown_to_invoker(targetHome); log::println("[xlings:self] {} ({}) - ok\n", targetHome.string(), pkgVersion); return 0; } @@ -826,7 +823,8 @@ int cmd_install(EventStream& stream) { std::error_code e; if (it->is_symlink(e) || !it->is_directory(e)) continue; if (auto removed = subos::userdata::delete_subos( - it->path(), "self-install-overwrite", *overwriteConfirmed); + subos::home_view(), it->path(), "self-install-overwrite", + *overwriteConfirmed); !removed) { log::error("[xlings:self] {}", removed.error()); return 1; @@ -957,12 +955,7 @@ int cmd_install(EventStream& stream) { setup_shell_profiles(targetHome); - auto verifyBin = targetHome / "bin" / -#ifdef _WIN32 - "xlings.exe"; -#else - "xlings"; -#endif + auto verifyBin = targetHome / "bin" / ("xlings" + std::string(platform::exe_suffix)); if (fs::exists(verifyBin)) { auto [rc, out] = platform::run_command_capture( "\"" + verifyBin.string() + "\" -h"); @@ -970,8 +963,7 @@ int cmd_install(EventStream& stream) { log::warn("[xlings:self] verification failed"); } -#if defined(__linux__) - if (fs::exists(verifyBin)) { + if (platform::is_linux && fs::exists(verifyBin)) { platform::set_env_variable("XLINGS_HOME", targetHome.string()); auto binDir = (targetHome / "subos" / "current" / "bin").string(); auto existingPath = std::string(std::getenv("PATH") ? std::getenv("PATH") : ""); @@ -984,21 +976,18 @@ int cmd_install(EventStream& stream) { log::warn(" hint: run manually: xlings install xim:patchelf@0.18.0 -y"); } } -#endif // Hand the freshly-written ~/.xlings back to the invoking user when we // ran via sudo, so a later non-sudo `xlings install` isn't locked out by // root-owned files. No-op for pure root / non-root installs. - platform::chown_to_invoker(targetHome); + if (home::describe(targetHome, home::Source::Env).user_owned()) + platform::chown_to_invoker(targetHome); std::println("\n[xlings:self] install: {} ({}) - ok", targetHome.string(), pkgVersion); std::println(""); std::println(" run 'xlings -h' to get started"); -#if defined(__linux__) || defined(__APPLE__) - std::println(" restart shell or: source ~/.bashrc"); -#else - std::println(" restart terminal to refresh PATH"); -#endif + if constexpr (platform::is_posix) std::println(" restart shell or: source ~/.bashrc"); + else std::println(" restart terminal to refresh PATH"); std::println(""); return 0; } diff --git a/src/core/xself/profile_resources.cppm b/src/core/xself/profile_resources.cppm index efbac7683..b8a173640 100644 --- a/src/core/xself/profile_resources.cppm +++ b/src/core/xself/profile_resources.cppm @@ -79,10 +79,14 @@ namespace xlings::xself::profile_resources { // fish and pwsh (7+) each get a hook that asks the CLI, so the // candidate set is the live command spec plus installed tools and // subos names rather than a snapshot frozen at install time. -export inline constexpr std::string_view kVersion = "12"; +// 13 — the system layer (deployment M, SubOS design part 2 §10): when a +// root-owned /xlings (or $XLINGS_SYSTEM_LAYER) is present, its programs +// come right after the home's own -- a user's version wins, the +// system's is used without a copy, and both before the host's. +export inline constexpr std::string_view kVersion = "13"; export inline constexpr std::string_view bash_sh = -R"XPROFILE(# xlings-profile-version: 12 +R"XPROFILE(# xlings-profile-version: 13 # Xlings Shell Profile (bash/zsh) _xlings_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../.." 2>/dev/null && pwd)" @@ -109,6 +113,16 @@ case ":$PATH:" in *) export PATH="$XLINGS_BIN:$XLINGS_HOME/bin:$PATH" ;; esac +# The system layer's programs, after this home's own (deployment M). +_xlings_layer="${XLINGS_SYSTEM_LAYER:-/xlings}/subos/default/bin" +if [ -d "$_xlings_layer" ] && [ "$_xlings_layer" != "$XLINGS_BIN" ]; then + case ":$PATH:" in + *":$_xlings_layer:"*) ;; + *) export PATH="$(printf '%s' "$PATH" | sed "s|$XLINGS_HOME/bin|$XLINGS_HOME/bin:$_xlings_layer|")" ;; + esac +fi +unset _xlings_layer + # Prompt marker: when the sub-shell was spawned by `xlings subos use`, # decorate PS1 with [xsubos:] so the user sees which subos they are in. # Idempotent: re-sourcing the profile won't double the label. @@ -224,7 +238,7 @@ fi )XPROFILE"; export inline constexpr std::string_view fish = -R"XPROFILE(# xlings-profile-version: 12 +R"XPROFILE(# xlings-profile-version: 13 # Xlings Shell Profile (fish) set -l _script_dir (dirname (status filename)) @@ -243,6 +257,17 @@ if not contains "$XLINGS_BIN" $PATH set -gx PATH "$XLINGS_BIN" "$XLINGS_HOME/bin" $PATH end +# The system layer's programs, after this home's own (deployment M). +set -l _xlings_layer (set -q XLINGS_SYSTEM_LAYER; and echo $XLINGS_SYSTEM_LAYER; or echo /xlings)/subos/default/bin +if test -d "$_xlings_layer"; and not contains "$_xlings_layer" $PATH; and test "$_xlings_layer" != "$XLINGS_BIN" + set -l _i (contains -i "$XLINGS_HOME/bin" $PATH) + if test -n "$_i" + set -gx PATH $PATH[1..$_i] "$_xlings_layer" $PATH[(math $_i + 1)..-1] + else + set -gx PATH $PATH "$_xlings_layer" + end +end + # Prompt marker: wrap fish_prompt to prepend [xsubos:] when the env # is set. Snapshot the user's existing prompt into _xlings_orig_fish_prompt # once so re-sourcing doesn't recursively wrap. Color is added via @@ -300,7 +325,7 @@ complete -c xlings -f -a '(__xlings_complete)' )XPROFILE"; export inline constexpr std::string_view pwsh = -R"XPROFILE(# xlings-profile-version: 12 +R"XPROFILE(# xlings-profile-version: 13 # Xlings Shell Profile (PowerShell) $env:XLINGS_HOME = (Resolve-Path "$PSScriptRoot\..\..").Path diff --git a/src/core/xself/repair.cpp b/src/core/xself/repair.cpp index a24d7c59d..e330009b2 100644 --- a/src/core/xself/repair.cpp +++ b/src/core/xself/repair.cpp @@ -16,11 +16,7 @@ bool is_shell_safe_token(std::string_view s) { } std::string quiet_suffix() { -#if defined(_WIN32) - return " >NUL 2>&1"; -#else - return " >/dev/null 2>&1"; -#endif + return std::format(" >{} 2>&1", platform::null_device); } bool probe_reinstallable(const std::string& target, const std::string& version, const CommandRunner& run, const std::string& client) { diff --git a/src/core/xself/uninstall.cpp b/src/core/xself/uninstall.cpp index 3731e7a62..59e149e9d 100644 --- a/src/core/xself/uninstall.cpp +++ b/src/core/xself/uninstall.cpp @@ -6,7 +6,8 @@ import xlings.core.log; import xlings.core.xself.shell_profile; import xlings.platform; import xlings.core.destructive_log; -import xlings.core.subos.userdata; +import xlings.subos.userdata; +import xlings.core.subos.ports; namespace xlings::xself { @@ -78,19 +79,12 @@ bool home_dir_safe_to_remove_(const fs::path& home) { // the directory we're about to delete. Returns the path it moved to. fs::path chdir_to_safe_() { std::error_code ec; -#ifdef _WIN32 - fs::path safe = "C:\\"; - if (auto* tmp = std::getenv("TEMP")) safe = tmp; -#else - fs::path safe = "/"; - if (auto* tmp = std::getenv("TMPDIR")) safe = tmp; -#endif + fs::path safe = platform::is_windows ? "C:\\" : "/"; + if (auto* tmp = std::getenv(platform::is_windows ? "TEMP" : "TMPDIR")) safe = tmp; fs::current_path(safe, ec); return safe; } -#ifdef _WIN32 - // Move xlings.exe out of XLINGS_HOME and schedule the moved copy for // delete-on-reboot. Returns true on success (or no-op if file already // gone), false on hard failure. @@ -127,8 +121,6 @@ bool windows_self_displace_(const fs::path& xlingsExe) { return true; } -#endif - int print_summary_(const fs::path& home, const UninstallOpts& opts, std::uintmax_t dataSize, @@ -196,18 +188,18 @@ std::vector hooked_startup_files_(const fs::path& userHome) { std::vector files; for (auto& rel : rc_files) files.push_back(userHome / rel); -#ifdef _WIN32 // Not a fixed path under the home: each PowerShell host reports its own // $PROFILE, and OneDrive folder redirection can move both. Same probe // install used to find them. - for (const auto& p : shell_profile::probe_hosts( - shell_profile::kPowerShellHosts, - [](const std::string& cmd) { - return platform::run_command_capture(cmd); - })) { - if (p.status == shell_profile::ProbeStatus::Answered) files.push_back(p.path); + if constexpr (platform::is_windows) { + for (const auto& p : shell_profile::probe_hosts( + shell_profile::kPowerShellHosts, + [](const std::string& cmd) { + return platform::run_command_capture(cmd); + })) { + if (p.status == shell_profile::ProbeStatus::Answered) files.push_back(p.path); + } } -#endif return files; } @@ -299,14 +291,14 @@ int cmd_uninstall(UninstallOpts opts) { auto safe = chdir_to_safe_(); log::debug("self uninstall: chdir → {}", safe.string()); -#ifdef _WIN32 // Displace the running xlings.exe before bulk-removing the home tree. - auto xlingsExe = home / "bin" / "xlings.exe"; - if (!windows_self_displace_(xlingsExe)) { - log::error("self uninstall: failed to displace running xlings.exe; aborting"); - return 1; + if constexpr (platform::is_windows) { + auto xlingsExe = home / "bin" / "xlings.exe"; + if (!windows_self_displace_(xlingsExe)) { + log::error("self uninstall: failed to displace running xlings.exe; aborting"); + return 1; + } } -#endif // Sub-paths to remove. --keep-data keeps the user's data: the package payloads under data/, // and every subos with its home. A later `self install` picks both up as @@ -381,9 +373,8 @@ int cmd_uninstall(UninstallOpts opts) { } else { log::println("xlings uninstalled. {} removed.", home.string()); } -#ifdef _WIN32 - log::println("the launcher binary will be cleaned up on next system restart."); -#endif + if constexpr (platform::is_windows) + log::println("the launcher binary will be cleaned up on next system restart."); return 0; } diff --git a/src/core/xself/uninstall.cppm b/src/core/xself/uninstall.cppm index 29a58debc..f77a69d2c 100644 --- a/src/core/xself/uninstall.cppm +++ b/src/core/xself/uninstall.cppm @@ -32,10 +32,6 @@ export struct UninstallOpts { bool dryRun = false; // --dry-run: print plan, do nothing }; -#ifdef _WIN32 - -#endif - export int cmd_uninstall(UninstallOpts opts); diff --git a/src/core/xself/update.cpp b/src/core/xself/update.cpp index fc22f58e6..004b267b3 100644 --- a/src/core/xself/update.cpp +++ b/src/core/xself/update.cpp @@ -2,6 +2,7 @@ module xlings.core.xself.update; import std; import xlings.core.config; +import xlings.core.home; import xlings.core.entry_binary; import xlings.core.xself.init; import xlings.core.xvm.db; @@ -10,6 +11,8 @@ import xlings.platform; namespace xlings::xself { +namespace fs = std::filesystem; + bool update_landed_on_index_build(std::string_view activeVersion) { // Empty is NOT a failure: it means nothing recorded an active version, // which is a different defect and one this command must not claim to have @@ -29,7 +32,31 @@ bool update_landed_on_index_build(std::string_view activeVersion) { return activeVersion.substr(0, colon) != "local"; } -int cmd_update() { +int cmd_update(bool user) { + // Deployment S (design §4): this binary is a system package's. Replacing + // it is the package manager's job, and installing a second xlings into + // the home behind the user's back would leave two clients answering to + // `xlings` depending on PATH order. Say so; `--user` is the explicit ask. + if (!user) { + const auto entry = home::describe_entry(platform::get_executable_path(), + Config::paths().homeDir); + if (entry.system) { + // The command for the package manager this host has. + std::string how = "the system's package manager"; + std::error_code ec; + for (auto [bin, cmd] : {std::pair{"/usr/bin/pacman", "sudo pacman -Syu xlings"}, + std::pair{"/usr/bin/apt-get", "sudo apt-get install --only-upgrade xlings"}, + std::pair{"/usr/bin/dnf", "sudo dnf upgrade xlings"}, + std::pair{"/usr/bin/zypper", "sudo zypper update xlings"}, + std::pair{"/sbin/apk", "sudo apk upgrade xlings"}}) { + if (fs::exists(bin, ec)) { how = std::format("`{}`", cmd); break; } + } + log::error("{} belongs to a system package; update it with {}", entry.path.string(), how); + log::error(" or install a user-level xlings into {} that updates itself: " + "`xlings self update --user`", Config::paths().homeDir.string()); + return 1; + } + } // Both children run with `--ui-mode cli`: no progress bars. This command's // output is a short narrative of steps; a bar redrawn inside it, followed // by the next step's log, left the two glued together, and the downloads diff --git a/src/core/xself/update.cppm b/src/core/xself/update.cppm index c0f7680f0..24af52fdf 100644 --- a/src/core/xself/update.cppm +++ b/src/core/xself/update.cppm @@ -10,7 +10,9 @@ namespace xlings::xself { // platform::exec rather than calling cmd_install/cmd_use directly: that // avoids the circular module dependency that would otherwise arise // (xim.commands and xvm.commands both import xlings.core.xself). -export int cmd_update(); +// `user`: install a home-level xlings even though this one is a system +// package's (deployment S), which is otherwise refused. +export int cmd_update(bool user = false); // Did `use xlings latest` land on the build the index handed us? // diff --git a/src/core/xvm/commands.cpp b/src/core/xvm/commands.cpp index 74ffd5bf7..732046ec5 100644 --- a/src/core/xvm/commands.cpp +++ b/src/core/xvm/commands.cpp @@ -2,11 +2,13 @@ module xlings.core.xvm.commands; import std; import xlings.core.config; +import xlings.core.home; +import xlings.core.home.layers; import xlings.core.log; import xlings.core.diag; import xlings.core.version_order; import xlings.core.palette; -import xlings.core.subos.manifest; +import xlings.subos.manifest; import xlings.platform; import xlings.runtime; import xlings.libs.json; @@ -22,26 +24,23 @@ import xlings.core.xvm.errors; import xlings.core.xvm.owner; import xlings.core.xvm.switch_plan; import xlings.core.xvm.shim; +import xlings.core.xvm.materialize; import xlings.i18n; namespace xlings::xvm { void create_link_(const fs::path& src, const fs::path& dst) { std::error_code ec; -#if defined(_WIN32) - if (fs::is_directory(src)) { - // Use directory junction on Windows (no admin required) - platform::create_directory_link(dst.string(), src.string()); - } else { - fs::create_hard_link(src, dst, ec); - if (ec) { - ec.clear(); - fs::copy_file(src, dst, fs::copy_options::overwrite_existing, ec); + if constexpr (platform::is_windows) { + if (fs::is_directory(src)) { + // Use directory junction on Windows (no admin required) + platform::create_directory_link(dst.string(), src.string()); + } else { + fs::create_hard_link(src, dst, ec); } + } else { + fs::create_symlink(src, dst, ec); } -#else - fs::create_symlink(src, dst, ec); -#endif if (ec) log::warn("[xvm] link failed: {} -> {}", Config::display_path(dst), Config::display_path(src)); } @@ -67,311 +66,91 @@ bool sysroot_source_is_local_(const fs::path& src) { || a.starts_with(b + "/"); }; const auto& p = Config::paths(); - return under(p.dataDir) || under(p.homeDir); + if (under(p.dataDir) || under(p.homeDir)) return true; + const auto versions = Config::versions(); + for (const auto& [target, info] : versions) + for (const auto& [version, data] : info.versions) + if (!data.sourceHome.empty() && home::layers::owns_source_payload(data.sourceHome, src)) return true; + return false; } -void install_headers(const std::string& includedir, const fs::path& sysroot_include) { - fs::create_directories(sysroot_include); - std::error_code ec; - fs::path src(includedir); - if (!fs::exists(src, ec)) return; - if (!sysroot_source_is_local_(src)) { - // Warned, not refused. A recipe may legitimately expose headers from - // outside the store (a wrapper around system headers is the obvious - // one), and refusing would break packages that work today. What is - // NOT legitimate is the run that produced the measured damage, and - // this is the moment it becomes visible instead of surfacing weeks - // later as a missing header from a subos the user is not even in. - log::warn("[xvm] linking headers from outside this home: {}", - Config::display_path(src)); - log::warn(" into sysroot: {}", Config::display_path(sysroot_include)); - log::warn(" these links outlive the run that made them; " - "`xlings self doctor --fix` removes them once they dangle"); - } - for (auto& entry : platform::dir_entries(src)) { - auto target = sysroot_include / entry.path().filename(); - // Already pointing at this exact source: leave it alone. - // `xlings use` now re-materializes the active release on every - // invocation so it can repair a sysroot that drifted, and - // remove-then-relink would open a window on every one of those - // calls where the header is simply absent -- long enough for a - // concurrent build to fail on it. equivalent() covers symlinks, - // Windows junctions and hard links alike; a copy fallback compares - // unequal and is relinked, which is correct. - std::error_code sameEc; - if (std::filesystem::equivalent(target, entry.path(), sameEc) - && !sameEc) { - continue; - } - if (fs::exists(target, ec) || fs::is_symlink(target, ec)) { - log::debug("[xvm] overwriting header: {}", entry.path().filename().string()); - fs::remove_all(target, ec); +namespace { +fs::path materialization_root_(const fs::path& destination, std::span claims = {}) { + const auto scope = fs::absolute(Config::paths().subosDir).lexically_normal(); + const auto path = fs::absolute(destination).lexically_normal(); + const auto relative = path.lexically_relative(scope); + if (!relative.empty() && *relative.begin() != "..") return scope; + // Standalone callers supply an isolated sysroot and explicit old claims. + auto root = path.parent_path(); + for (const auto& claim : claims) { + const auto asset = fs::absolute(claim.destination).lexically_normal(); + while (root != root.parent_path()) { + const auto from = asset.lexically_relative(root); + if (!from.empty() && *from.begin() != "..") break; + root = root.parent_path(); } - create_link_(entry.path(), target); + if (root == asset) root = root.parent_path(); } + return root; } - -void remove_headers(const std::string& includedir, const fs::path& sysroot_include) { - if (includedir.empty()) return; - fs::path src(includedir); - std::error_code ec; - if (!fs::exists(src, ec)) return; - for (auto& entry : platform::dir_entries(src)) { - auto target = sysroot_include / entry.path().filename(); - if (fs::is_symlink(target, ec)) { - fs::remove(target, ec); -#if defined(_WIN32) - } else if (fs::exists(target, ec)) { - // On Windows, directory junctions appear as dirs, not symlinks - fs::remove_all(target, ec); -#endif - } - } +MaterializationResult apply_assets_(std::vector changes, + const fs::path& root, std::span supplied) { + std::vector claims(supplied.begin(), supplied.end()); + if (supplied.empty()) { + auto recorded = materialize::collect_claims(Config::versions(), Config::workspace_installed(), + Config::paths().subosDir, Config::paths().libDir, Config::paths().homeDir.string()); + if (!recorded) return std::unexpected(recorded.error()); + claims = std::move(*recorded); + } + auto prepared = materialize::preflight_materialization(changes, claims, root); + if (!prepared) return std::unexpected(prepared.error()); + auto applied = prepared->execute(); + if (!applied) return std::unexpected(applied.error()); + auto proofs = applied->proofs(); + auto committed = applied->commit(); + if (!committed) return std::unexpected(committed.error()); + return proofs; } - -void install_headers(const HeaderAsset& asset, const fs::path& sysroot_include) { - install_headers(asset.sourceDir, header_destination_(asset, sysroot_include)); } -void remove_headers(const HeaderAsset& asset, const fs::path& sysroot_include) { - const auto destination = header_destination_(asset, sysroot_include); - remove_headers(asset.sourceDir, destination); - // A prefix directory that only ever held this release's links is litter - // once they are gone. remove() on a non-empty directory fails, so this - // cannot take anything else with it. - if (!asset.destinationPrefix.empty()) { - std::error_code ec; - fs::remove(destination, ec); +MaterializationResult install_headers(const HeaderAsset& asset, const fs::path& includeRoot, + std::span claims) { + if (!asset.sourceDir.empty() && !sysroot_source_is_local_(asset.sourceDir)) { + log::warn("[xvm] linking headers from outside this home: {}", Config::display_path(asset.sourceDir)); } + std::vector changes; + auto expanded = materialize::append_headers(changes, asset, includeRoot); + if (!expanded) return std::unexpected(expanded.error()); + return apply_assets_(std::move(changes), materialization_root_(includeRoot, claims), claims); } - -// Is this path a symlink we placed, pointing into the payload store? -bool is_payload_link_(const fs::path& path, const fs::path& payloadRoot) { - std::error_code ec; - if (!fs::is_symlink(path, ec)) return false; - const auto linkTarget = fs::read_symlink(path, ec); - if (ec) return false; - const auto normalize = [](std::string s) { - std::ranges::replace(s, '\\', '/'); - while (s.size() > 1 && s.back() == '/') s.pop_back(); - return s; - }; - const auto root = normalize(payloadRoot.string()); - return !root.empty() && normalize(linkTarget.string()).starts_with(root); +MaterializationResult install_headers(const std::string& source, const fs::path& includeRoot, + std::span claims) { + return install_headers(HeaderAsset{source, ""}, includeRoot, claims); } - -// Make every directory on the way to `destination` a real directory. -// -// `create_directories` treats an existing symlink-to-directory as "already -// there" and returns success, so placing `usr/include/scsi/sg.h` while -// `usr/include/scsi` is a directory-granularity asset link writes the new -// entry **inside another package's payload**. Measured, not theorised: the -// link lands in `data/xpkgs///include/scsi/`, where every subos on -// the machine reads it, and no uninstall will ever take it out again. -// -// Nothing in the index produces this shape today (measured: zero directory -// assets whose destination is an ancestor of another package's asset). It -// becomes reachable the moment a package that shares a directory declares its -// contents per-file instead -- which is exactly the fix for `usr/include/scsi` -// being one package's directory rather than both packages' merge. -// -// Converting rather than refusing, and losing nothing while doing it: the -// link is replaced by a real directory holding one link per entry the payload -// directory offered. That is the same shape `install_headers` builds and the -// same shape `declare_headers_tree` produces, so the package whose asset is -// being unwrapped keeps every header it was providing -- it just provides -// them individually now. Order-independent by construction: whichever of the -// two packages is installed first, neither loses anything. -// -// A symlink that is NOT a payload link is left alone rather than refused. -// Only the payload store can be corrupted by writing through a link -- it is -// shared by every subos on the machine and nothing ever audits it. A symlink -// anywhere else is somebody's normal filesystem: `/tmp` is a link to -// `/private/tmp` on macOS, and refusing over it would mean no isolated test -// home on that platform could place an asset at all. -// -// Bounded at the xlings home for the same reason it is bounded at all: no -// path above it can be a payload link, and walking to `/` for each of a -// thousand assets buys nothing. -bool ensure_real_parent_dirs_(const fs::path& destination) { - const auto& paths = Config::paths(); - const auto payloadRoot = paths.dataDir / "xpkgs"; - const auto homeDir = paths.homeDir; - - std::vector ancestors; - for (auto p = destination.parent_path(); - !p.empty() && p != p.parent_path() && p != homeDir; - p = p.parent_path()) { - ancestors.push_back(p); - } - - std::error_code ec; - for (auto& ancestor : ancestors | std::views::reverse) { - if (!is_payload_link_(ancestor, payloadRoot)) continue; - const auto payloadDir = fs::read_symlink(ancestor, ec); - if (ec) { - // Refusing here is right -- writing through a link we cannot read - // is how a payload gets a file in it -- but refusing in silence - // would make it look like the asset was placed. - log::warn("[xvm] not writing under {}: it is a link into the " - "payload store that cannot be read ({})", - Config::display_path(ancestor), ec.message()); - return false; - } - - ec.clear(); - fs::remove(ancestor, ec); - fs::create_directories(ancestor, ec); - if (ec) { - log::warn("[xvm] could not unwrap {}: {}", - Config::display_path(ancestor), ec.message()); - return false; - } - // Give back everything the directory asset was providing, one link - // per FILE, so unwrapping is not a deletion. - // - // Recursively, and that is not tidiness. Linking a sub-directory - // wholesale would put back exactly the shape being unwrapped, one - // level down -- and an undeclared one, which nothing reclaims and - // which turns a later removal of a leaf beneath it into a delete - // inside the payload. Measured against real packages before this - // recursed: `usr/include/scsi/fc` came back as a link, and giving up - // the package emptied `include/scsi/fc/` out of its payload. - // - // Same depth cap the index's own tree walker uses: a payload that - // links a directory back at an ancestor would otherwise not - // terminate. - const auto rebuild = [](auto&& self, const fs::path& from, - const fs::path& into, int depth) -> void { - if (depth > 8) return; - std::error_code listEc; - if (!fs::is_directory(from, listEc)) return; - for (const auto& entry : platform::dir_entries(from)) { - const auto destination = into / entry.path().filename(); - std::error_code dirEc; - if (fs::is_directory(entry.path(), dirEc) - && !fs::is_symlink(entry.path(), dirEc)) { - fs::create_directories(destination, dirEc); - self(self, entry.path(), destination, depth + 1); - } else { - create_link_(entry.path(), destination); - } - } - }; - rebuild(rebuild, payloadDir, ancestor, 0); - log::debug("[xvm] unwrapped directory asset {} into a real directory", - Config::display_path(ancestor)); - } - return true; -} - -void place_asset(const std::string& source, const fs::path& destination) { - if (source.empty() || destination.empty()) return; - std::error_code ec; - fs::path src(source); - if (!fs::exists(src, ec)) { - log::debug("[xvm] asset source missing, not placed: {}", - Config::display_path(source)); - return; - } - if (!ensure_real_parent_dirs_(destination)) return; - fs::create_directories(destination.parent_path(), ec); - - // Already pointing at this exact file: leave it alone. Keeps the repeated - // re-materialization that `use` performs down to a stat. - std::error_code sameEc; - if (fs::equivalent(destination, src, sameEc) && !sameEc) return; - - const auto staging = - destination.parent_path() - / (destination.filename().string() + ".xlings-new"); - fs::remove_all(staging, ec); - create_link_(src, staging); - if (!fs::exists(staging, ec) && !fs::is_symlink(staging, ec)) { - log::warn("[xvm] could not stage asset: {}", - Config::display_path(destination)); - return; - } - ec.clear(); - fs::rename(staging, destination, ec); - if (ec) { - // Platforms without an atomic replace for this entry kind. Accept the - // window rather than leave the staging file behind. - std::error_code rmEc; - fs::remove_all(destination, rmEc); - ec.clear(); - fs::rename(staging, destination, ec); - if (ec) { - fs::remove_all(staging, rmEc); - log::warn("[xvm] could not place asset {}: {}", - Config::display_path(destination), ec.message()); - } - } +std::expected remove_headers(const HeaderAsset& asset, const fs::path& includeRoot, + std::span claims) { + std::vector changes; + auto expanded = materialize::append_headers(changes, asset, includeRoot, true); + if (!expanded) return std::unexpected(expanded.error()); + auto applied = apply_assets_(std::move(changes), materialization_root_(includeRoot, claims), claims); + if (!applied) return std::unexpected(applied.error()); + return {}; } - -void remove_asset(const fs::path& destination) { - if (destination.empty()) return; - std::error_code ec; - if (fs::is_symlink(destination, ec) || fs::exists(destination, ec)) { - fs::remove_all(destination, ec); - } +std::expected remove_headers(const std::string& source, const fs::path& includeRoot, + std::span claims) { + return remove_headers(HeaderAsset{source, ""}, includeRoot, claims); } - -// Take one declared asset out, and take the directories that only existed to -// hold it out too. -// -// `fs::remove`, never `remove_all`: on a platform where the asset is a -// followable entry (a Windows junction, or a link some other tool replaced -// with a real directory) `remove_all` walks into it and deletes the payload -// behind it. `self doctor --fix` carries the same note for the same reason. -// -// The directory sweep stops at three components -- see prune_empty_asset_dirs. -// -// A destination whose ANCESTOR is a payload link is not removed at all. It is -// the mirror of the trap `ensure_real_parent_dirs_` guards on the way in, and -// it bites harder: `usr/include/scsi/fc/fc_fs.h` where `usr/include/scsi/fc` -// links into a payload does not name a file in the subos, it names the -// PACKAGE'S OWN FILE, shared by every subos on the machine. Measured while -// testing this very change against real packages: giving up linux-headers in -// one subos emptied `include/scsi/fc/` out of its payload -- four headers -// gone from a package that was still installed and still active elsewhere. -// -// Refused rather than repaired. The ancestor link points into SOME payload, -// not necessarily the one being given up, so unlinking it here could take a -// directory away from a package nobody asked about. What is left is an -// undeclared link, which doctor reports once its payload goes. -bool ancestor_is_a_payload_link_(const fs::path& subosDir, - const fs::path& absolute, - const fs::path& payloadRoot) { - for (auto p = absolute.parent_path(); - !p.empty() && p != p.parent_path() && p != subosDir; - p = p.parent_path()) { - if (is_payload_link_(p, payloadRoot)) return true; - } - return false; +MaterializationResult place_asset(const std::string& source, const fs::path& destination, + std::span claims) { + if (source.empty() || destination.empty()) return std::vector{}; + return apply_assets_({{source, destination, false}}, materialization_root_(destination, claims), claims); } - -void remove_declared_asset_(const fs::path& subosDir, - const fs::path& payloadRoot, - const std::string& destination) { - const auto absolute = subosDir / destination; - if (ancestor_is_a_payload_link_(subosDir, absolute, payloadRoot)) { - log::warn("[xvm] not removing {}: a directory on the way to it links " - "into a package payload, so this path names that package's " - "own file rather than anything in this subos", - Config::display_path(absolute)); - return; - } - std::error_code ec; - if (fs::is_symlink(absolute, ec) || fs::exists(absolute, ec)) { - ec.clear(); - if (!fs::remove(absolute, ec) && ec) { - log::warn("[xvm] could not remove declared asset {}: {}", - Config::display_path(absolute), ec.message()); - return; - } - } - prune_empty_asset_dirs(absolute, subosDir); +std::expected remove_asset(const fs::path& destination, + std::span claims) { + if (destination.empty()) return {}; + auto removed = apply_assets_({{{}, destination, true}}, materialization_root_(destination, claims), claims); + if (!removed) return std::unexpected(removed.error()); + return {}; } void prune_empty_asset_dirs(const fs::path& absolute, @@ -399,84 +178,41 @@ void prune_empty_asset_dirs(const fs::path& absolute, auto relative = relativeToRoot.parent_path(); while (components(relative) >= 3) { std::error_code rmEc; - if (!fs::remove(subosRoot / relative, rmEc)) break; + const auto directory = subosRoot / relative; + if (!fs::is_directory(fs::symlink_status(directory, rmEc)) || rmEc || + !platform::remove_empty_directory(directory)) break; relative = relative.parent_path(); } } -// Is this destination still ours to delete, as far as the filesystem can say? -// -// Only POSIX can say anything. There the asset is a symlink, so one pointing -// outside the payload store was replaced after we placed it -- that is -// `xvm-sysroot-drift`, someone else's decision, and giving up a release has -// no business overruling it. On Windows the same asset is a hard link or a -// copy (`create_link_`), which carries no origin at all, so the declaration -// in the database is the only authority there is. Returning true is therefore -// the correct Windows answer and not a gap: the caller has already -// established that a record it is dropping claimed this path. -bool declared_asset_is_ours_(const fs::path& absolute, - const fs::path& payloadRoot) { - std::error_code ec; - if (!fs::is_symlink(absolute, ec)) return true; - return is_payload_link_(absolute, payloadRoot); -} - -void reclaim_declared_assets(const fs::path& subosDir, - const fs::path& payloadRoot, - const std::set& destinations, - const VersionDB& db, - const Workspace& activeAfter) { - if (destinations.empty()) return; - - // One pass over the database, not one per destination. The library - // cleanup rescans everything for each name, which is free for the fifteen - // sonames a toolchain ships and is not free for the 274 assets one glib - // install declares. - std::map> activeClaims; - for (const auto& [target, info] : db) { - const auto activeIt = activeAfter.find(target); - if (activeIt == activeAfter.end()) continue; - auto versionIt = info.versions.find(activeIt->second); - if (versionIt == info.versions.end()) continue; - const auto& data = versionIt->second; - if (effective_kind(info, data) != "files") continue; - if (data.fileDst.empty()) continue; - if (!destinations.contains(data.fileDst)) continue; - activeClaims.emplace(data.fileDst, - std::pair{target, activeIt->second}); - } - +std::expected reclaim_declared_assets(const fs::path& subosDir, + const fs::path&, const std::set& destinations, + const VersionDB& db, const Workspace& activeAfter, + std::span claims) { + std::vector changes; for (const auto& destination : destinations) { - if (const auto claim = activeClaims.find(destination); - claim != activeClaims.end()) { - const auto placement = file_placement( - db, claim->second.first, claim->second.second, - Config::paths().homeDir.string()); - if (!placement.empty()) { - place_asset(placement.source, subosDir / destination); - continue; + bool put = false; + for (const auto& [target, version] : activeAfter) { + const auto placement = file_placement(db, target, version, Config::paths().homeDir.string()); + if (placement.destination == destination && !placement.empty()) { + changes.push_back({placement.source, subosDir / destination, false}); + put = true; } } - if (!declared_asset_is_ours_(subosDir / destination, payloadRoot)) { - log::warn("[xvm] {} was replaced after xlings placed it; leaving " - "it alone (run `xlings self doctor` to see it)", - Config::display_path(subosDir / destination)); - continue; - } - remove_declared_asset_(subosDir, payloadRoot, destination); + if (!put) changes.push_back({{}, subosDir / destination, true}); } + auto result = apply_assets_(std::move(changes), subosDir, claims); + if (!result) return std::unexpected(result.error()); + return {}; } - -void place_library(const std::string& source, - const std::string& name, - const fs::path& sysroot_lib) { - if (name.empty()) return; - place_asset(source, sysroot_lib / name); +MaterializationResult place_library(const std::string& source, const std::string& name, + const fs::path& sysroot_lib) { + if (name.empty()) return std::vector{}; + return place_asset(source, sysroot_lib / name); } - -void remove_library(const std::string& name, const fs::path& sysroot_lib) { - if (name.empty()) return; - remove_asset(sysroot_lib / name); +std::expected remove_library(const std::string& name, const fs::path& sysroot_lib) { + if (name.empty()) return {}; + return remove_asset(sysroot_lib / name); } bool runtime_activation_refused_(const VersionDB& db, @@ -584,47 +320,74 @@ int cmd_use(const std::string& target, const std::string& version, EventStream& auto db = Config::versions(); auto& p = Config::paths(); + const auto versionStatePath = &Config::versions_mut() == &Config::global_versions() + ? p.homeDir / ".xlings.json" : Config::project_state_path(); + const auto workspaceStatePath = Config::workspace_config_path(false); - if (!has_target(db, target)) { - log::error("[xlings:use] '{}' not found in version database", target); - log::error(" hint: install it first with `xlings install {}`", target); - return 1; - } - - // "latest" means pick the highest available version. + std::optional borrowed; std::string resolved; - if (version == "latest") { - // Scope-aware: pick_highest_version strips the ":" prefix (e.g. - // the bootstrap "local:0.4.47") before comparing by numeric semver, so - // a higher real release (e.g. 0.4.52) wins. semver::sort_desc alone - // cannot parse "local:..." and falls back to lexicographic order, where - // "local:0.4.47" wrongly beats "0.4.52" — the bug that made - // `xlings self update` install the new version yet stay on the old - // local one (xlings -> local:0.4.47). - auto it = db.find(target); - if (it == db.end() || it->second.versions.empty()) { - log::error("no versions installed for '{}'", target); + if (has_target(db, target)) { + auto owned = db.at(target); + std::erase_if(owned.versions, [](const auto& entry) { return !entry.second.sourceHome.empty(); }); + const VersionDB preferred{{target, std::move(owned)}}; + resolved = version == "latest" ? pick_highest_version(preferred.at(target).versions) + : match_version(preferred, target, version); + if (resolved.empty()) resolved = version == "latest" ? pick_highest_version(db.at(target).versions) + : match_version(db, target, version); + } + const auto* existing = resolved.empty() ? nullptr : get_vdata(db, target, resolved); + if (resolved.empty() || (existing && !existing->sourceHome.empty())) { + auto declared = home::read_system_layer(); + if (!declared) { log::error("{}", declared.error()); return 1; } + if (*declared) { + auto layer = home::layers::read_source_snapshot(**declared); + if (!layer) { log::error("{}", layer.error()); return 1; } + if (existing && existing->sourceHome != layer->sourceHome.string()) { + log::error("{}: borrowed registration belongs to a different system layer", target); + return 1; + } + const auto chosen = has_target(layer->versions, target) + ? (version == "latest" ? pick_highest_version(layer->versions.at(target).versions) + : match_version(layer->versions, target, version)) + : std::string{}; + if (!chosen.empty()) { + auto closure = home::layers::plan_borrow(*layer, db, target, chosen); + if (!closure) { log::error("{}", closure.error()); return 1; } + borrowed = std::move(*closure); + resolved = chosen; + for (const auto& [name, imported] : borrowed->registrations) { + auto& info = db[name]; + info.type = imported.type; + info.filename = imported.filename; + for (const auto& [key, data] : imported.versions) info.versions[key] = data; + for (const auto& [peer, edges] : imported.bindings) + for (const auto& [key, linked] : edges) info.bindings[peer][key] = linked; + } + } else if (existing) { + log::error("{}: borrowed release is no longer registered by its source layer", target); + return 1; + } + } else if (existing) { + log::error("{}: the declared system layer for this borrowed release is missing", target); return 1; } - resolved = pick_highest_version(it->second.versions); - } else { - // Fuzzy match version - resolved = match_version(db, target, version); } - if (resolved.empty()) { - log::error("version '{}' not found for '{}'", version, target); - auto all = get_all_versions(db, target); - if (!all.empty()) { - std::string avail; - for (auto& v : all) { - if (!avail.empty()) avail += ", "; - avail += v; + if (!has_target(db, target)) { + log::error("[xlings:use] '{}' not found in version database", target); + log::error(" hint: install it first with `xlings install {}`", target); + } else { + log::error("version '{}' not found for '{}'", version, target); + std::string available; + for (const auto& key : get_all_versions(db, target)) { + if (!available.empty()) available += ", "; + available += key; } - log::error(" available: {}", avail); + if (!available.empty()) log::error(" available: {}", available); } return 1; } + if (runtime_activation_refused_(db, target, resolved)) return 1; log::debug("fuzzy version match: {} -> {}", version, resolved); @@ -641,11 +404,11 @@ int cmd_use(const std::string& target, const std::string& version, EventStream& // `-print-sysroot` -- and could not compile, because `usr/include` was // empty. Nothing said so. // - // `use` cannot fix that: it cannot even name what is missing. So it stops - // pretending to. Switching stays what it says it is -- moving between - // versions this subos has -- and getting a version into a subos belongs to - // `install`, which resolves dependencies and materialises them. - if (filter_to_subos_installed_(target, {resolved}).empty()) { + // Owned payloads still require install's scope opt-in. The system-layer + // branch above is a separate proof: it validated every recorded runtime + // dependency and plans their views together, so it can opt that complete + // borrowed closure in without running or inheriting another scope's config. + if (!borrowed && filter_to_subos_installed_(target, {resolved}).empty()) { const auto origin = Config::version_origin(target); // The package that records `target@resolved`, if a record proves one; // `target` itself is a program name and may not be installable. @@ -689,6 +452,35 @@ int cmd_use(const std::string& target, const std::string& version, EventStream& log::error("{}", render(plan.error(), true)); return 1; } + if (borrowed) { + // Each package release is planned against the SAME outgoing workspace. + // Dependencies are materialized as well as the requested release. + for (const auto& [name, key] : borrowed->members) { + if (plan->members.contains(name)) continue; + auto dependency = plan_use_switch(db, workspace, name, key, p.homeDir.string()); + if (!dependency) { log::error("{}", render(dependency.error(), true)); return 1; } + for (const auto& [member, memberVersion] : dependency->members) { + const auto found = plan->members.find(member); + if (found != plan->members.end() && found->second != memberVersion) { + log::error("{}: contradictory layer closure switch", member); + return 1; + } + plan->members[member] = memberVersion; + } + plan->switches.insert(plan->switches.end(), dependency->switches.begin(), dependency->switches.end()); + plan->removeHeaders.insert(plan->removeHeaders.end(), dependency->removeHeaders.begin(), dependency->removeHeaders.end()); + plan->installHeaders.insert(plan->installHeaders.end(), dependency->installHeaders.begin(), dependency->installHeaders.end()); + plan->stranded.insert(plan->stranded.end(), dependency->stranded.begin(), dependency->stranded.end()); + plan->reclaimFiles.insert(dependency->reclaimFiles.begin(), dependency->reclaimFiles.end()); + } + // Refuse unreadable local authorities before persisting imported metadata. + auto versionDocument = home::read_json_for_update(versionStatePath); + auto scopeDocument = home::read_json_for_update(workspaceStatePath); + if (!versionDocument || !scopeDocument) { + log::error("{}", !versionDocument ? versionDocument.error() : scopeDocument.error()); + return 1; + } + } const auto& to_switch = plan->members; // A program the outgoing release had and this one does not keeps @@ -721,100 +513,112 @@ int cmd_use(const std::string& target, const std::string& version, EventStream& return 1; } - // Everything above this line is a decision; everything below changes the - // filesystem. Members that are already where they belong emit no change. - auto sysroot_include = p.subosDir / "usr" / "include"; - // `/lib`, which is where the install path has always put - // libraries (`Config::paths().libDir`) and where they actually are: 94 - // entries on a real installation, against one on `/usr/lib`. - // This line used to read `usr/lib`, and the disagreement was invisible - // because the switch side never emitted any library work at all -- - // `VData::libdir` has no writer. Making libraries switch without fixing - // it would have started filling a second, unused directory. - auto sysroot_lib = p.libDir; - // Headers first, and as two whole passes rather than per member: they are - // an asset of the release, not of any one member of it. Interleaving the - // passes -- remove one member's, install the next member's -- let the - // outgoing release's removal delete a link the incoming release had - // already put down, for every header name the two versions share. The - // plan has the two lists deduplicated and disjoint already. - for (const auto& asset : plan->removeHeaders) { - remove_headers(asset, sysroot_include); + // Prepare the entire new projection before changing either authority. + const auto previousDb = Config::versions_mut(); + const auto previousActive = Config::workspace(); + const auto previousInstalled = Config::workspace_installed(); + auto versionDocument = home::read_json_for_update(versionStatePath); + auto workspaceDocument = home::read_json_for_update(workspaceStatePath); + if (!versionDocument || !workspaceDocument) { + log::error("{}", !versionDocument ? versionDocument.error() : workspaceDocument.error()); + return 1; } - for (const auto& asset : plan->installHeaders) { - install_headers(asset, sysroot_include); + auto candidateDb = previousDb; + if (borrowed) { + for (const auto& [name, imported] : borrowed->registrations) { + auto& info = candidateDb[name]; + info.type = imported.type; + info.filename = imported.filename; + for (const auto& [key, data] : imported.versions) info.versions[key] = data; + for (const auto& [peer, edges] : imported.bindings) + for (const auto& [key, linked] : edges) info.bindings[peer][key] = linked; + } } - - for (const auto& change : plan->switches) { - if (!change.removeLibName.empty()) - remove_library(change.removeLibName, sysroot_lib); - if (!change.installLibSource.empty()) - place_library(change.installLibSource, change.installLibName, - sysroot_lib); - // File assets carry their own destination, relative to the subos - // root, so they are joined here rather than assumed into a fixed dir. - if (!change.removeFileDest.empty()) - remove_asset(p.subosDir / change.removeFileDest); - if (!change.installFileSource.empty()) - place_asset(change.installFileSource, - p.subosDir / change.installFileDest); - log::debug("switching {}: {} -> {}", change.target, - change.previousVersion.empty() ? "(none)" - : change.previousVersion, - change.version); + auto candidateActive = previousActive; + auto candidateInstalled = previousInstalled; + for (const auto& [name, key] : to_switch) { + candidateActive[name] = key; + auto& installed = candidateInstalled[name]; + if (std::ranges::find(installed, key) == installed.end()) installed.push_back(key); + } + for (auto& [name, keys] : candidateInstalled) { + std::ranges::sort(keys); + keys.erase(std::unique(keys.begin(), keys.end()), keys.end()); + } + for (const auto& [name, key] : plan->reclaimFiles) { + const auto found = candidateActive.find(name); + if (found != candidateActive.end() && found->second == key) candidateActive.erase(found); + } + auto oldClaims = materialize::collect_claims(Config::versions(), previousInstalled, + p.subosDir, p.libDir, p.homeDir.string()); + reclaim_conflicting_file_bindings(candidateDb, candidateActive, to_switch, p.homeDir.string()); + WorkspaceInstalled selected; + for (const auto& [name, key] : candidateActive) selected[name] = {key}; + auto desired = materialize::collect_claims(db, selected, p.subosDir, p.libDir, p.homeDir.string(), + materialize::ClaimSource::Present); + if (!oldClaims || !desired) { + log::error("{}", !oldClaims ? oldClaims.error() : desired.error()); + return 1; } - - // Update workspace for all nodes in the binding tree, and opt this - // subos into the version's installed[] set if it wasn't already. - // - // The entry target is guaranteed to be in installed[] already -- the gate - // above refused otherwise. What this still covers is the *members*: a - // release can gain a program between versions (gcc 16 adding a binary gcc - // 15 did not have), and a member the subos never saw is arriving as part - // of a release it did ask for, not instead of one. Its payload is the same - // already-materialised payload, so recording it is bookkeeping, not an - // install. - auto& wsi = Config::workspace_installed_mut(); - for (auto& [name, ver] : to_switch) { - Config::workspace_mut()[name] = ver; - auto& list = wsi[name]; - if (std::find(list.begin(), list.end(), ver) == list.end()) { - list.push_back(ver); - log::debug("auto-add to installed[]: {} += {}", name, ver); + auto obsolete = materialize::obsolete_assets(*oldClaims, *desired); + if (!obsolete) { log::error("{}", obsolete.error()); return 1; } + auto changes = std::move(*obsolete); + for (const auto& claim : *desired) { + changes.push_back({claim.source, claim.destination, false}); + } + auto prepared = materialize::preflight_materialization(changes, *oldClaims, p.subosDir); + if (!prepared) { log::error("{}", prepared.error()); return 1; } + auto applied = prepared->execute(); + if (!applied) { log::error("{}", applied.error()); return 1; } + bool metadataTouched = false; + const auto restore = [&](const std::string& reason) { + log::error("{}", reason); + auto restored = applied->rollback(); + if (!restored) log::error("materializer recovery: {}", restored.error()); + Config::versions_mut() = previousDb; + Config::workspace_mut() = previousActive; + Config::workspace_installed_mut() = previousInstalled; + if (metadataTouched) { + try { + if (borrowed) platform::write_file_atomic(versionStatePath.string(), versionDocument->dump(2)); + platform::write_file_atomic(workspaceStatePath.string(), workspaceDocument->dump(2)); + } catch (const std::exception& error) { + log::error("activation metadata recovery failed: {}", error.what()); + return; + } + const auto sync = xself::sync_shim_tables(); + if (!sync.root_error.empty()) log::error("previous root projection recovery failed: {}", sync.root_error); } - log::debug("binding sync: {} -> {}", name, ver); - } - - // Assets the release being left declared and the incoming one does not. - // - // Deactivated FIRST, and that order is the whole of it. Reclaiming asks - // "does anything still active declare this destination", so a member left - // active answers yes about itself and its link is re-pointed straight - // back at the release being left -- the switch then changes nothing at - // all. The release moved; a member it has no version of did not come - // along, and saying so is not a guess about intent. - // - // `installed[]` is untouched: the payload is still there and `use` can - // bring it back. Only `active` moves. - std::set reclaimDests; - auto& activeAfter = Config::workspace_mut(); - for (const auto& [memberTarget, memberVersion] : plan->reclaimFiles) { - const auto placement = file_placement(db, memberTarget, memberVersion, - Config::paths().homeDir.string()); - if (!placement.destination.empty()) { - reclaimDests.insert(placement.destination); + }; + Config::versions_mut() = candidateDb; + Config::workspace_mut() = candidateActive; + Config::workspace_installed_mut() = candidateInstalled; + metadataTouched = true; + try { + if (borrowed) Config::save_versions(); + Config::save_workspace(); + auto recorded = home::read_json_for_update(workspaceStatePath); + if (!recorded || !recorded->contains("workspace")) { + restore("activation workspace could not be persisted"); + return 1; + } + const auto actual = subos_workspace_from_json((*recorded)["workspace"]); + if (actual.active != candidateActive || actual.installed != candidateInstalled) { + restore("activation workspace write did not record the planned state"); + return 1; } - if (const auto it = activeAfter.find(memberTarget); - it != activeAfter.end() && it->second == memberVersion) { - activeAfter.erase(it); - log::debug("deactivated {}@{}: the release moved and this member " - "did not come along", memberTarget, memberVersion); + if (borrowed) { + auto recordedDb = home::read_json_for_update(versionStatePath); + if (!recordedDb || !recordedDb->contains("versions") || + (*recordedDb)["versions"] != versions_to_json(candidateDb)) { + restore("borrowed layer metadata could not be persisted"); + return 1; + } } + } catch (const std::exception& error) { + restore(std::string("activation metadata write failed: ") + error.what()); + return 1; } - reclaim_declared_assets(p.subosDir, p.dataDir / "xpkgs", - reclaimDests, db, activeAfter); - - Config::save_workspace(); // The routing table follows the workspace that was just written. // @@ -830,18 +634,16 @@ int cmd_use(const std::string& target, const std::string& version, EventStream& // get two spellings depending on which path created it, and only one of // them dispatches: `shim_dispatch` looks the name up in the workspace, // which is keyed by target. `sync_shim_tables` has one spelling. -#ifdef _WIN32 - auto xlings_bin = p.homeDir / "bin" / "xlings.exe"; - constexpr std::string_view shim_ext = ".exe"; -#else - auto xlings_bin = p.homeDir / "bin" / "xlings"; - constexpr std::string_view shim_ext = ""; -#endif + constexpr std::string_view shim_ext = platform::exe_suffix; + auto xlings_bin = p.homeDir / "bin" / ("xlings" + std::string(shim_ext)); if (!fs::exists(xlings_bin)) { xlings_bin = p.homeDir / "xlings"; } - xself::sync_shim_tables(); + if (const auto sync = xself::sync_shim_tables(); !sync.root_error.empty()) { + restore(sync.root_error); + return 1; + } // Self-replace: when the user switches to a different version of xlings // (or its multicall aliases xim/xvm), physically replace the bootstrap @@ -860,9 +662,19 @@ int cmd_use(const std::string& target, const std::string& version, EventStream& auto active_bin = fs::path(vd->path) / ("xlings" + std::string(shim_ext)); if (fs::exists(active_bin)) { - xself::replace_entry_binary( - active_bin, xlings_bin, - std::format("{}@{}", target, resolved), resolved); + const auto owner = recorded_owner(db, target, resolved); + if (!owner) { + restore(std::format("{}@{}: entry activation has no proven package owner", target, resolved)); + return 1; + } + const auto provider = owner->ns.empty() ? owner->package + : owner->ns + ":" + owner->package; + if (!xself::replace_entry_binary( + active_bin, xlings_bin, std::format("{}@{}", target, resolved), resolved, + xself::PackageEntryActivation{provider, vd->sourceHome})) { + restore("entry activation failed"); + return 1; + } } } // COMPAT(0.4.8 → drop in 0.6.0): opportunistically drop legacy @@ -872,6 +684,9 @@ int cmd_use(const std::string& target, const std::string& version, EventStream& xself::compat::v0_4_8::cleanup_legacy_alias_shims(p.binDir, xlings_bin); } + auto committed = applied->commit(); + if (!committed) { log::error("{}", committed.error()); return 1; } + // Which release did this actually move? // // `xlings use java 25.0.4-zulu` names a MEMBER, not a package, so the diff --git a/src/core/xvm/commands.cppm b/src/core/xvm/commands.cppm index 6876b6c58..ad49f420f 100644 --- a/src/core/xvm/commands.cppm +++ b/src/core/xvm/commands.cppm @@ -3,7 +3,7 @@ export module xlings.core.xvm.commands; import std; import xlings.core.config; -import xlings.core.subos.manifest; +import xlings.subos.manifest; import xlings.runtime; import xlings.core.semver; import xlings.core.entry_binary; @@ -17,12 +17,13 @@ import xlings.core.xvm.inspect; import xlings.core.xvm.errors; import xlings.core.xvm.switch_plan; import xlings.core.xvm.shim; +import xlings.core.xvm.materialize; export namespace xlings::xvm { namespace fs = std::filesystem; -// Cross-platform link: symlink on Unix, directory junction or copy on Windows +// Cross-platform link: symlink on Unix, directory junction or hard link on Windows void create_link_(const fs::path& src, const fs::path& dst); // Where a header asset lands: `/include/`, or @@ -53,36 +54,39 @@ fs::path header_destination_(const HeaderAsset& asset, // ever wrong there. bool sysroot_source_is_local_(const fs::path& src); -// Install header symlinks from source includedir into sysroot include/ -void install_headers(const std::string& includedir, const fs::path& sysroot_include); +using MaterializationResult = std::expected, std::string>; + +// Install header links, preserving entries without old ledger or fresh proof. +MaterializationResult install_headers(const std::string& includedir, const fs::path& sysroot_include, + std::span claims = {}); // Remove header symlinks that point into the given source includedir -void remove_headers(const std::string& includedir, const fs::path& sysroot_include); +std::expected remove_headers(const std::string& includedir, const fs::path& sysroot_include, + std::span claims = {}); // Same two operations, addressed by header asset rather than by bare source // directory, so the destination prefix is honored. -void install_headers(const HeaderAsset& asset, const fs::path& sysroot_include); +MaterializationResult install_headers(const HeaderAsset& asset, const fs::path& sysroot_include, + std::span claims = {}); -void remove_headers(const HeaderAsset& asset, const fs::path& sysroot_include); +std::expected remove_headers(const HeaderAsset& asset, const fs::path& sysroot_include, + std::span claims = {}); -// Place one file at an exact destination, replacing whatever is there. +// Place one file at an exact destination, replacing only a proven derived link. // // Shared by libraries and by declared file assets: both are "this payload // file becomes that path in the subos", and both need the same replacement // discipline. // -// Replaces by rename rather than remove-then-link. Two versions of a library -// share a soname, so a switch overwrites the same name -- and `use` -// re-materializes the active release on every invocation to repair a drifted -// sysroot, so remove-then-link would open a window on every one of those -// calls where the file is simply absent. Long enough for a concurrent build -// step to fail on it. rename(2) replaces atomically; Windows has no -// equivalent for every entry kind, so the staging file is cleaned up and the -// direct path is taken there. -void place_asset(const std::string& source, const fs::path& destination); +// Old entries are retained in unique owned staging until commit. Platforms +// with exchange publish atomically; the guarded fallback never overwrites an +// unknown entry. Copies of regular files are not ownership evidence. +MaterializationResult place_asset(const std::string& source, const fs::path& destination, + std::span claims = {}); // Take one placed file back out. -void remove_asset(const fs::path& destination); +std::expected remove_asset(const fs::path& destination, + std::span claims = {}); // Remove the directories that only existed to hold a declared asset. // @@ -120,22 +124,22 @@ void prune_empty_asset_dirs(const fs::path& absolute, // `usr/include/scsi`, is claimed by two packages, and both mistakes are // reachable there. // -// Directories that only existed to hold these assets go too, down to three -// path components: `usr/include/xkbcommon` is ours, `usr/include` is the -// sysroot's own shape. -void reclaim_declared_assets(const fs::path& subosDir, +// Old claims must be captured before unregistering outgoing metadata. +// Real parent directories are retained when their ownership is unobserved. +std::expected reclaim_declared_assets(const fs::path& subosDir, const fs::path& payloadRoot, const std::set& destinations, const VersionDB& db, - const Workspace& activeAfter); + const Workspace& activeAfter, + std::span claims = {}); // Library-shaped wrappers, kept because the sysroot lib directory is implied // rather than declared for libraries. -void place_library(const std::string& source, +MaterializationResult place_library(const std::string& source, const std::string& name, const fs::path& sysroot_lib); -void remove_library(const std::string& name, const fs::path& sysroot_lib); +std::expected remove_library(const std::string& name, const fs::path& sysroot_lib); // Helper: filter a list of version keys (`ns:ver` or bare) down to those // that appear in the current subos's installed[] for `target`. Tolerant diff --git a/src/core/xvm/db.cpp b/src/core/xvm/db.cpp index 2f60f5c27..4c43b3a14 100644 --- a/src/core/xvm/db.cpp +++ b/src/core/xvm/db.cpp @@ -582,21 +582,21 @@ std::string normalize_subos_paths(const std::string& text, return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); }; auto path_equal = [](std::string_view a, std::string_view b) { -#if defined(_WIN32) // Windows compares paths case-insensitively and treats / and \ alike. - if (a.size() != b.size()) return false; - auto fold = [](char c) -> char { - if (c == '\\') return '/'; - if (c >= 'A' && c <= 'Z') return static_cast(c - 'A' + 'a'); - return c; - }; - for (std::size_t i = 0; i < a.size(); ++i) { - if (fold(a[i]) != fold(b[i])) return false; + if constexpr (platform::is_windows) { + if (a.size() != b.size()) return false; + auto fold = [](char c) -> char { + if (c == '\\') return '/'; + if (c >= 'A' && c <= 'Z') return static_cast(c - 'A' + 'a'); + return c; + }; + for (std::size_t i = 0; i < a.size(); ++i) { + if (fold(a[i]) != fold(b[i])) return false; + } + return true; + } else { + return a == b; } - return true; -#else - return a == b; -#endif }; std::string out; @@ -753,6 +753,12 @@ std::string expand_path(const std::string& path, const std::string& xlings_home) nlohmann::json vdata_to_json(const VData& vdata) { nlohmann::json j; j["path"] = vdata.path; + if (!vdata.sourceHome.empty()) { + j["layer"] = vdata.layerMetadata.empty() ? nlohmann::json::object() + : nlohmann::json::parse(vdata.layerMetadata); + j["layer"]["home"] = vdata.sourceHome; + j["layer"]["scope"] = vdata.sourceScope; + } if (!vdata.kind.empty()) j["kind"] = vdata.kind; if (!vdata.sourceName.empty()) j["sourceName"] = vdata.sourceName; if (!vdata.destinationName.empty()) @@ -855,6 +861,18 @@ VData vdata_from_json(const nlohmann::json& j) { return escaped; }; + if (auto layer = j.find("layer"); layer != j.end()) { + if (layer->is_object() && layer->contains("home") && (*layer)["home"].is_string() && + layer->contains("scope") && (*layer)["scope"].is_string() && + !(*layer)["home"].get().empty() && !(*layer)["scope"].get().empty()) { + vdata.sourceHome = (*layer)["home"].get(); + vdata.sourceScope = (*layer)["scope"].get(); + vdata.layerMetadata = layer->dump(); + } else { + recordIssue("invalid-layer-provenance", "/layer"); + vdata.bindingUnreadable["layer"] = layer->dump(); + } + } if (j.contains("path") && j["path"].is_string()) vdata.path = j["path"].get(); if (j.contains("kind") && j["kind"].is_string()) @@ -1132,6 +1150,11 @@ nlohmann::json versions_to_json(const VersionDB& db) { return j; } +VersionDB empty_version_db() { + VersionDB result; + return result; +} + VersionDB versions_from_json(const nlohmann::json& j) { VersionDB db; if (!j.is_object()) return db; diff --git a/src/core/xvm/db.cppm b/src/core/xvm/db.cppm index 6fe56be87..2825ce959 100644 --- a/src/core/xvm/db.cppm +++ b/src/core/xvm/db.cppm @@ -373,6 +373,9 @@ VInfo vinfo_from_json(const nlohmann::json& j); nlohmann::json versions_to_json(const VersionDB& db); +// Construct in the database module to avoid foreign-module map default-constructor instantiation. +VersionDB empty_version_db(); + VersionDB versions_from_json(const nlohmann::json& j); // The home config's cheap index of the DB's keys: {program: filename-stem}. diff --git a/src/core/xvm/lock.cpp b/src/core/xvm/lock.cpp index da3a1b1ce..400eb6d9c 100644 --- a/src/core/xvm/lock.cpp +++ b/src/core/xvm/lock.cpp @@ -143,6 +143,23 @@ std::expected acquire_state_lock(const std::filesystem:: }; const auto path = state_lock_path(home); + // A lock file that cannot even be opened for writing is not one another + // process holds: waiting for it would wait out the whole timeout for + // nothing. Inside a SubOS sandbox the home is read-only by design, and the + // xlings there sends what changes it to the broker instead. + // Not on Windows: there the holder opens the file without sharing it, so + // "cannot open" is exactly what a held lock looks like. + if constexpr (platform::is_posix) { + std::ofstream probe(path, std::ios::app); + if (!probe) { + const bool inside = utils::get_env_or_default("XLINGS_SUBOS_MODE") == "sandbox"; + return std::unexpected(std::format( + "{} cannot be written{}", path.string(), + inside ? " -- inside a SubOS sandbox the xlings home is read-only; run this " + "outside it, or through the broker (`xlings install` does that)" + : ": the home is read-only or not yours")); + } + } std::string error; if (!lock.lock_.acquire(path, timeout, {}, error, onWait)) { return std::unexpected(std::format( diff --git a/src/core/xvm/materialize.cpp b/src/core/xvm/materialize.cpp new file mode 100644 index 000000000..e90521143 --- /dev/null +++ b/src/core/xvm/materialize.cpp @@ -0,0 +1,624 @@ +module xlings.core.xvm.materialize; + +import std; +import xlings.platform; +import xlings.core.log; +import xlings.core.xvm.db; +import xlings.core.xvm.bindings; +import xlings.core.xvm.switch_plan; + +namespace xlings::xvm::materialize { +namespace { +fs::path normal_(const fs::path& path) { + std::error_code ec; + auto absolute = fs::absolute(path, ec); + auto normalized = (ec ? path : absolute).lexically_normal(); + while (normalized != normalized.root_path() && normalized.filename().empty()) + normalized = normalized.parent_path(); + return normalized; +} +bool below_(const fs::path& root, const fs::path& path) { + auto r = root.begin(), p = path.begin(); + for (; r != root.end(); ++r, ++p) if (p == path.end() || *r != *p) return false; + return p != path.end(); +} +std::expected present_(const fs::path& path) { + std::error_code ec; + auto status = fs::symlink_status(path, ec); + if (status.type() == fs::file_type::not_found && + (!ec || ec == std::errc::no_such_file_or_directory)) return false; + if (ec) return std::unexpected(path.string() + ": " + ec.message()); + return true; +} +bool refers_(const fs::path& path, const fs::path& source, const fs::path& original) { + std::error_code ec; + const auto target = platform::read_symlink(path, ec); + if (!ec) { + if (normal_(target.is_absolute() ? target : original.parent_path() / target) == normal_(source)) return true; + if constexpr (!platform::is_windows) return false; + } + if (normal_(source) == original) return false; + ec.clear(); + // File object identity proves a hard link or directory junction. Byte + // equality of an unknown regular file does not prove ownership. + return fs::equivalent(path, source, ec) && !ec; + +} +std::optional owner_(const fs::path& path, const fs::path& original, + std::span claims) { + for (const auto& claim : claims) { + const auto destination = normal_(claim.destination); + auto source = claim.source; + if (destination != original) { + if (!claim.descendants || !below_(destination, original)) continue; + source /= original.lexically_relative(destination); + } + if (refers_(path, source, original)) return normal_(source); + } + return std::nullopt; +} +std::expected, std::string> entries_(const fs::path& path) { + std::vector result; + std::error_code ec; + fs::directory_iterator it(path, ec), end; + if (ec) return std::unexpected(path.string() + ": cannot read directory: " + ec.message()); + for (; it != end; it.increment(ec)) { + if (ec) return std::unexpected(path.string() + ": cannot finish reading directory: " + ec.message()); + result.push_back(it->path()); + } + if (ec) return std::unexpected(path.string() + ": cannot finish reading directory: " + ec.message()); + std::ranges::sort(result); + return result; +} +std::expected link_(const fs::path& source, const fs::path& destination) { + std::error_code ec; + if constexpr (platform::is_windows) { + if (fs::is_directory(source, ec)) { + if (!platform::create_directory_link(destination.string(), source.string())) + return std::unexpected("cannot create directory link " + destination.string()); + } else fs::create_hard_link(source, destination, ec); + } else fs::create_symlink(source, destination, ec); + if (ec) return std::unexpected(destination.string() + ": cannot create derived link: " + ec.message()); + return {}; +} +std::expected scratch_(const fs::path& directory) { + std::random_device random; + for (int attempt = 0; attempt < 32; ++attempt) { + auto candidate = directory / (".xlings-materialize-" + + std::format("{:08x}{:08x}", random(), random())); + std::error_code ec; + if (fs::create_directory(candidate, ec)) return candidate; + if (ec && ec != std::errc::file_exists) + return std::unexpected(candidate.string() + ": cannot create owned staging: " + ec.message()); + } + return std::unexpected("cannot reserve unique materializer staging in " + directory.string()); +} +struct Operation { + AssetChange change; + bool unwrap { false }; + std::optional previous; +}; +struct Done { + Operation operation; + fs::path stage; + fs::path backup; + std::vector tree; + std::vector directories; + bool published { false }; +}; +std::expected build_tree_(const fs::path& source, const fs::path& destination, + const fs::path& published, std::vector& proof, std::vector& directories, int depth = 0) { + if (depth > 64) return std::unexpected(source.string() + ": directory asset exceeds 64 levels"); + std::error_code ec; + if (!fs::create_directory(destination, ec) || ec) + return std::unexpected(destination.string() + ": cannot create derived directory"); + directories.push_back(published); + auto entries = entries_(source); + if (!entries) return std::unexpected(entries.error()); + for (const auto& entry : *entries) { + const auto to = destination / entry.filename(); + const auto final = published / entry.filename(); + if (fs::is_directory(entry, ec) && !fs::is_symlink(entry, ec)) { + auto built = build_tree_(entry, to, final, proof, directories, depth + 1); + if (!built) return built; + } else { + auto linked = link_(entry, to); + if (!linked) return linked; + proof.push_back({entry, final}); + } + } + return {}; +} +std::expected clear_tree_(Done& done, const fs::path& at) { + std::error_code ec; + const auto& destination = done.operation.change.destination; + for (const auto& proof : done.tree | std::views::reverse) { + const auto entry = at / proof.destination.lexically_relative(destination); + auto present = present_(entry); + if (!present) return std::unexpected(present.error()); + if (!*present) continue; + if (!refers_(entry, proof.source, proof.destination)) + return std::unexpected(entry.string() + ": derived tree contains changed or user-owned data; preserved"); + if (!fs::remove(entry, ec) || ec) return std::unexpected(entry.string() + ": cannot remove owned link"); + } + for (const auto& directory : done.directories | std::views::reverse) { + const auto entry = at / directory.lexically_relative(destination); + ec.clear(); + auto present = present_(entry); + if (!present) return std::unexpected(present.error()); + if (!*present) continue; + if (!fs::is_directory(fs::symlink_status(entry, ec)) || ec) + return std::unexpected(entry.string() + ": changed directory entry preserved"); + if (!fs::remove(entry, ec) && ec) + return std::unexpected(entry.string() + ": nonempty derived directory preserved: " + ec.message()); + } + return {}; +} +std::expected unlink_proven_(const fs::path& path, const fs::path& source, + const fs::path& original) { + auto present = present_(path); + if (!present) return std::unexpected(present.error()); + if (!*present) return {}; + if (!refers_(path, source, original)) return std::unexpected(path.string() + ": changed or user-owned entry preserved"); + std::error_code ec; + if (!fs::remove(path, ec) || ec) return std::unexpected(path.string() + ": cannot remove derived entry: " + ec.message()); + return {}; +} +} + +struct Prepared::State { + fs::path root; + std::vector claims; + std::vector operations; +}; +struct Applied::State { + std::vector done; + std::vector proof; + std::vector newDirectories; + bool finished { false }; +}; + +Prepared::Prepared() : state_(std::make_unique()) {} +Prepared::~Prepared() {} +Prepared::Prepared(Prepared&& other) : state_(std::move(other.state_)) {} +Prepared& Prepared::operator=(Prepared&& other) { state_ = std::move(other.state_); return *this; } +Applied::Applied() : state_(std::make_unique()) {} +Applied::~Applied() { + if (state_ && !state_->finished) { + auto result = rollback(); + if (!result) log::error("[xvm] materializer rollback: {}", result.error()); + } +} +Applied::Applied(Applied&& other) : state_(std::move(other.state_)) {} +Applied& Applied::operator=(Applied&& other) { + if (state_ && !state_->finished) { + auto result = rollback(); + if (!result) log::error("[xvm] materializer rollback: {}", result.error()); + } + state_ = std::move(other.state_); + return *this; +} +const std::vector& Applied::proofs() const { return state_->proof; } + +std::expected, std::string> collect_claims(const VersionDB& db, + const WorkspaceInstalled& installed, const fs::path& subosRoot, + const fs::path& libraryRoot, const std::string& home, ClaimSource source) { + std::vector result; + std::set> seen; + for (const auto& [target, keys] : installed) { + for (const auto& version : keys) { + const auto* data = get_vdata(db, target, version); + if (!data) { + if (source == ClaimSource::Recorded) continue; + return std::unexpected(target + "@" + version + ": installed asset registration missing"); + } + const auto prefix = data->sourceHome.empty() ? home : data->sourceHome; + for (auto header : group_header_assets(db, target, version)) { + header.sourceDir = expand_path(header.sourceDir, prefix); + const auto relative = fs::path(header.destinationPrefix); + if (relative.is_absolute()) return std::unexpected("header destination must be relative"); + for (const auto& part : relative) + if (part == "..") return std::unexpected("header destination escapes its scope"); + if (source == ClaimSource::Recorded) { + const auto destination = subosRoot / "usr/include" / relative; + if (!header.sourceDir.empty() && seen.emplace(header.sourceDir, destination).second) + result.push_back({header.sourceDir, destination, true}); + continue; + } + std::vector entries; + auto expanded = append_headers(entries, header, subosRoot / "usr/include"); + if (!expanded) return std::unexpected(expanded.error()); + for (const auto& entry : entries) { + std::error_code ec; + if (seen.emplace(entry.source, entry.destination).second) + result.push_back({entry.source, entry.destination, fs::is_directory(entry.source, ec)}); + } + } + const auto library = library_placement(db, target, version, prefix); + if (!library.empty() && seen.emplace(library.source, libraryRoot / library.name).second) + result.push_back({library.source, libraryRoot / library.name, false}); + const auto file = file_placement(db, target, version, prefix); + if (!file.empty() && seen.emplace(file.source, subosRoot / file.destination).second) { + std::error_code ec; + result.push_back({file.source, subosRoot / file.destination, + source == ClaimSource::Recorded || fs::is_directory(file.source, ec)}); + } + } + } + return result; +} + +std::expected, std::string> obsolete_assets( + std::span claims, std::span desired) { + std::vector result; + std::set visited; + const auto inspect = [&](const auto& self, const fs::path& entry, int depth) + -> std::expected { + if (depth > 64) return std::unexpected(entry.string() + ": directory asset exceeds 64 levels"); + if (!visited.insert(normal_(entry)).second) return {}; + auto present = present_(entry); + if (!present) return std::unexpected(present.error()); + if (!*present) return {}; + const auto owner = owner_(entry, entry, claims); + if (owner) { + const bool retained = std::ranges::any_of(desired, [&](const auto& next) { + return normal_(next.destination) == normal_(entry) + || below_(normal_(entry), normal_(next.destination)) + || (next.descendants && below_(normal_(next.destination), normal_(entry))); + }); + if (!retained) result.push_back({*owner, entry, true}); + return {}; + } + std::error_code ec; + const auto status = fs::symlink_status(entry, ec); + if (ec) return std::unexpected(entry.string() + ": " + ec.message()); + if (!fs::is_directory(status)) return {}; + auto entries = entries_(entry); + if (!entries) return std::unexpected(entries.error()); + for (const auto& child : *entries) { + auto checked = self(self, child, depth + 1); + if (!checked) return checked; + } + return {}; + }; + for (const auto& claim : claims) { + auto checked = inspect(inspect, claim.destination, 0); + if (!checked) return std::unexpected(checked.error()); + } + return result; +} +std::expected append_headers(std::vector& changes, + const HeaderAsset& asset, const fs::path& includeRoot, bool remove) { + if (asset.sourceDir.empty()) return {}; + const auto relative = fs::path(asset.destinationPrefix); + if (relative.is_absolute()) return std::unexpected("header destination must be relative"); + for (const auto& part : relative) if (part == "..") return std::unexpected("header destination escapes its scope"); + auto entries = entries_(asset.sourceDir); + if (!entries) return std::unexpected(entries.error()); + for (const auto& entry : *entries) changes.push_back({entry, includeRoot / relative / entry.filename(), remove}); + return {}; +} + +std::expected preflight_materialization( + std::span changes, std::span claims, + const fs::path& subosRoot) { + Prepared result; + result.state_->root = normal_(subosRoot); + result.state_->claims.assign(claims.begin(), claims.end()); + std::map operations; + for (auto change : changes) { + change.destination = normal_(change.destination); + if (!below_(result.state_->root, change.destination)) + return std::unexpected(change.destination.string() + ": asset destination escapes its SubOS"); + if (!change.source.empty()) change.source = normal_(change.source); + const auto previous = operations.find(change.destination); + if (previous != operations.end()) { + if (previous->second.change.remove && !change.remove) operations.erase(previous); + else if (!previous->second.change.remove && change.remove) continue; + else if (previous->second.change.remove && change.remove) continue; + else if (previous->second.change.source != change.source) + return std::unexpected(change.destination.string() + ": contradictory materialization sources"); + else continue; + } + operations.emplace(change.destination, Operation{change}); + } + // A previously unwrapped directory is a container, not an owned entry. + // Check every leaf against the old ledger and plan leaf changes instead + // of replacing the container (which could hold user data). + std::vector containers; + for (const auto& [destination, operation] : operations) { + auto exists = present_(destination); + if (!exists) return std::unexpected(exists.error()); + if (!*exists || owner_(destination, destination, claims)) continue; + std::error_code ec; + if (fs::is_directory(destination, ec) && !fs::is_symlink(destination, ec) && !ec) + containers.push_back(destination); + } + for (const auto& container : containers) { + auto found = operations.find(container); + if (found == operations.end()) continue; + const auto change = found->second.change; + std::error_code ec; + if (!change.remove && (!fs::is_directory(change.source, ec) || ec)) + return std::unexpected(container.string() + ": real directory cannot be replaced by a file"); + std::vector oldLeaves; + const auto inspect = [&](const auto& self, const fs::path& at, int depth) -> std::expected { + if (depth > 64) return std::unexpected(at.string() + ": directory asset exceeds 64 levels"); + auto entries = entries_(at); + if (!entries) return std::unexpected(entries.error()); + if (entries->empty()) return std::unexpected(at.string() + ": unobserved empty directory preserved"); + for (const auto& entry : *entries) { + auto owned = owner_(entry, entry, claims); + if (owned) { + bool belongs = false; + for (const auto& claim : claims) { + if (normal_(claim.destination) != container || !claim.descendants) continue; + const auto oldSource = claim.source / entry.lexically_relative(container); + if (refers_(entry, oldSource, entry)) { belongs = true; break; } + } + if (belongs) oldLeaves.push_back({*owned, entry, true}); + continue; + } + ec.clear(); + if (fs::is_directory(entry, ec) && !fs::is_symlink(entry, ec) && !ec) { + auto checked = self(self, entry, depth + 1); + if (!checked) return checked; + } else return std::unexpected(entry.string() + ": user-owned or unobserved entry preserved"); + } + return {}; + }; + auto inspected = inspect(inspect, container, 0); + if (!inspected) return std::unexpected(inspected.error()); + operations.erase(found); + for (const auto& leaf : oldLeaves) + if (!operations.contains(leaf.destination)) operations.emplace(leaf.destination, Operation{leaf}); + if (!change.remove) { + const auto expand = [&](const auto& self, const fs::path& source, const fs::path& to, int depth) -> std::expected { + if (depth > 64) return std::unexpected(source.string() + ": directory asset exceeds 64 levels"); + auto entries = entries_(source); + if (!entries) return std::unexpected(entries.error()); + for (const auto& entry : *entries) { + const auto target = to / entry.filename(); + ec.clear(); + if (fs::is_directory(entry, ec) && !fs::is_symlink(entry, ec) && !ec) { + auto expanded = self(self, entry, target, depth + 1); + if (!expanded) return expanded; + } else { + auto previous = operations.find(target); + if (previous == operations.end() || previous->second.change.remove) + operations.insert_or_assign(target, Operation{{entry, target, false}}); + } + } + return {}; + }; + auto expanded = expand(expand, change.source, container, 0); + if (!expanded) return std::unexpected(expanded.error()); + } + } + // A declared directory and another package's descendant must merge into + // a real directory, never write through the existing payload link. + for (auto& [destination, operation] : operations) { + if (operation.change.remove) continue; + std::error_code ec; + if (!fs::exists(operation.change.source, ec) || ec) + return std::unexpected(operation.change.source.string() + ": asset source is missing or unreadable"); + if (fs::is_directory(operation.change.source, ec)) { + for (const auto& [child, nested] : operations) + if (!nested.change.remove && below_(destination, child)) operation.unwrap = true; + } + } + // Plan every linked ancestor's conversion while everything is still read-only. + std::vector unwrapping; + for (const auto& [destination, operation] : operations) { + for (auto parent = destination.parent_path(); parent != result.state_->root; + parent = parent.parent_path()) { + if (parent.empty() || parent == parent.parent_path()) break; + std::error_code ec; + auto exists = present_(parent); + if (!exists) return std::unexpected(exists.error()); + if (!*exists) continue; + platform::read_symlink(parent, ec); + const bool linked = !ec || fs::is_symlink(parent, ec) || owner_(parent, parent, claims).has_value(); + if (!linked) { + ec.clear(); + if (!fs::is_directory(parent, ec) || ec) + return std::unexpected(parent.string() + ": asset parent is not a directory"); + continue; + } + auto source = owner_(parent, parent, claims); + if (!source) return std::unexpected(parent.string() + ": user-owned or unobserved parent link preserved"); + if (!fs::is_directory(*source, ec) || ec) + return std::unexpected(parent.string() + ": declared parent source is unreadable"); + if (!operations.contains(parent)) unwrapping.push_back({{*source, parent, false}, true, source}); + else operations.at(parent).unwrap = true; + } + } + for (auto& operation : unwrapping) operations.emplace(operation.change.destination, std::move(operation)); + for (auto& [destination, operation] : operations) { + auto exists = present_(destination); + if (!exists) return std::unexpected(exists.error()); + if (*exists) { + if (!operation.change.remove && refers_(destination, operation.change.source, destination) && !operation.unwrap) continue; + auto owner = owner_(destination, destination, claims); + if (!owner) return std::unexpected(destination.string() + ": user-owned or unobserved entry preserved"); + operation.previous = *owner; + } else if (operation.change.remove) continue; + result.state_->operations.push_back(std::move(operation)); + } + return result; +} + +std::expected Applied::rollback() { + if (!state_ || state_->finished) return {}; + std::string failures; + const auto failed = [&](const std::string& error) { + if (!failures.empty()) failures += "; "; + failures += error; + }; + for (auto& done : state_->done | std::views::reverse) { + const auto& change = done.operation.change; + auto backup = done.backup.empty() ? std::expected{false} : present_(done.backup); + if (!backup) { failed(backup.error()); continue; } + if (done.published && !change.remove) { + auto current = present_(change.destination); + if (!current) { failed(current.error()); continue; } + if (*current) { + if (done.operation.unwrap) { + // Removing only the exact links we created preserves any + // concurrently added regular file or directory. + auto cleared = clear_tree_(done, change.destination); + if (!cleared) { failed(cleared.error()); continue; } + } else { + auto removed = unlink_proven_(change.destination, change.source, change.destination); + if (!removed) { failed(removed.error()); continue; } + } + } + } + if (*backup) { + auto restored = platform::rename_no_replace(done.backup, change.destination); + if (!restored) { failed(restored.error() + "; original entry retained in " + done.backup.string()); continue; } + } + const auto staged = done.stage / "entry"; + auto pending = present_(staged); + if (!pending) failed(pending.error()); + else if (*pending && staged != done.backup) { + auto removed = done.operation.unwrap ? clear_tree_(done, staged) + : unlink_proven_(staged, change.source, change.destination); + if (!removed) failed(removed.error()); + } + std::error_code ec; + if (!done.stage.empty() && !fs::remove(done.stage, ec) && ec) + failed(done.stage.string() + ": recovery entries retained: " + ec.message()); + } + for (const auto& directory : state_->newDirectories | std::views::reverse) { + std::error_code ec; + if (fs::is_directory(directory, ec) && !fs::is_symlink(directory, ec)) fs::remove(directory, ec); + } + state_->finished = true; + if (!failures.empty()) return std::unexpected(std::move(failures)); + return {}; +} + +std::expected Applied::commit() { + if (!state_ || state_->finished) return {}; + // The caller already made metadata durable. Cleanup failure must retain + // the backup and may not roll the newly committed projection back. + state_->finished = true; + std::string failures; + for (auto& done : state_->done) { + if (!done.backup.empty()) { + auto removed = unlink_proven_(done.backup, *done.operation.previous, done.operation.change.destination); + if (!removed) { + if (!failures.empty()) failures += "; "; + failures += removed.error(); + continue; + } + } + std::error_code ec; + if (!done.stage.empty() && !fs::remove(done.stage, ec) && ec) { + if (!failures.empty()) failures += "; "; + failures += done.stage.string() + ": owned staging cleanup failed: " + ec.message(); + } + } + if (!failures.empty()) return std::unexpected("materialization committed; recovery entries retained: " + failures); + return {}; +} + +std::expected Prepared::execute() { + Applied result; + const auto fail = [&](const std::string& reason) -> std::expected { + auto restored = result.rollback(); + return std::unexpected(reason + (restored ? std::string{} : "; rollback: " + restored.error())); + }; + const auto parents = [&](const fs::path& destination) -> std::expected { + std::vector missing; + for (auto directory = destination.parent_path();; directory = directory.parent_path()) { + auto present = present_(directory); + if (!present) return std::unexpected(present.error()); + const bool inside = directory == state_->root || below_(state_->root, directory); + if (*present) { + std::error_code ec; + platform::read_symlink(directory, ec); + const bool link = !ec; + ec.clear(); + if (!fs::is_directory(directory, ec) || (inside && link) || ec) + return std::unexpected(directory.string() + ": refusing to write through an unobserved parent"); + // Check every ancestor inside the declared scope, even when + // the immediate parent already exists. OS links above the + // explicit scope (macOS /tmp) are outside this ownership rule. + if (!inside || directory == state_->root) break; + } else missing.push_back(directory); + if (directory.empty() || directory == directory.parent_path()) + return std::unexpected("missing filesystem root for materialization"); + } + for (const auto& directory : missing | std::views::reverse) { + std::error_code ec; + if (!fs::create_directory(directory, ec) || ec) + return std::unexpected(directory.string() + ": parent creation conflict"); + result.state_->newDirectories.push_back(directory); + } + return {}; + }; + for (auto operation : state_->operations) { + const auto& change = operation.change; + auto current = present_(change.destination); + if (!current) return fail(current.error()); + if (*current) { + if (!operation.previous || !refers_(change.destination, *operation.previous, change.destination)) { + // Earlier directory conversion in this transaction owns these + // leaf links. The proof is local to Applied, never a cache. + const auto created = std::ranges::find_if(result.state_->proof, [&](const Proof& proof) { + return proof.destination == change.destination && refers_(change.destination, proof.source, change.destination); + }); + if (created == result.state_->proof.end()) + return fail(change.destination.string() + ": ownership changed after preflight; entry preserved"); + operation.previous = created->source; + } + if (!change.remove && !operation.unwrap && refers_(change.destination, change.source, change.destination)) continue; + } else if (change.remove) continue; + else if (operation.previous) return fail(change.destination.string() + ": owned entry disappeared after preflight"); + auto createdParents = parents(change.destination); + if (!createdParents) return fail(createdParents.error()); + auto stage = scratch_(change.destination.parent_path()); + if (!stage) return fail(stage.error()); + result.state_->done.push_back({operation, *stage}); + auto& done = result.state_->done.back(); + const auto entry = done.stage / "entry"; + if (!change.remove) { + auto created = operation.unwrap + ? build_tree_(change.source, entry, change.destination, done.tree, done.directories) + : link_(change.source, entry); + if (!created) return fail(created.error()); + } + if (operation.previous) { + if (!change.remove) { + auto exchanged = platform::exchange_paths(entry, change.destination); + if (!exchanged) return fail(exchanged.error()); + if (*exchanged) { + done.backup = entry; + done.published = true; + if (!refers_(entry, *operation.previous, change.destination)) + return fail(change.destination.string() + ": foreign entry raced publication; restoring it"); + } + } + if (!done.published) { + done.backup = done.stage / "previous"; + auto parked = platform::rename_no_replace(change.destination, done.backup); + if (!parked) return fail(parked.error()); + if (!refers_(done.backup, *operation.previous, change.destination)) + return fail(change.destination.string() + ": foreign entry raced publication; restoring it"); + } + } + if (!change.remove && !done.published) { + auto published = platform::rename_no_replace(entry, change.destination); + if (!published) return fail(published.error()); + done.published = true; + } + if (done.published) { + if (operation.unwrap) result.state_->proof.insert(result.state_->proof.end(), done.tree.begin(), done.tree.end()); + else result.state_->proof.push_back({change.source, change.destination}); + } + } + return result; +} +} diff --git a/src/core/xvm/materialize.cppm b/src/core/xvm/materialize.cppm new file mode 100644 index 000000000..8becd7875 --- /dev/null +++ b/src/core/xvm/materialize.cppm @@ -0,0 +1,76 @@ +export module xlings.core.xvm.materialize; + +import std; +import xlings.core.xvm.types; + +export namespace xlings::xvm::materialize { +namespace fs = std::filesystem; + +struct AssetClaim { + fs::path source; + fs::path destination; + bool descendants { false }; +}; +struct AssetChange { + fs::path source; + fs::path destination; + bool remove { false }; +}; +struct Proof { + fs::path source; + fs::path destination; +}; + +enum class ClaimSource { Recorded, Present }; + +// Claims come from the scope's installed registrations, never from merely +// discovering a symlink whose target happens to be inside a store. +std::expected, std::string> collect_claims(const VersionDB& db, + const WorkspaceInstalled& installed, const fs::path& subosRoot, + const fs::path& libraryRoot, const std::string& home, + ClaimSource source = ClaimSource::Recorded); +std::expected, std::string> obsolete_assets( + std::span claims, std::span desired); +std::expected append_headers(std::vector& changes, + const HeaderAsset& asset, const fs::path& includeRoot, bool remove = false); + +class Applied { +public: + Applied(); + ~Applied(); + Applied(Applied&&); + Applied& operator=(Applied&&); + Applied(const Applied&) = delete; + Applied& operator=(const Applied&) = delete; + const std::vector& proofs() const; + std::expected rollback(); + // Called only after metadata and the root projection are durable. + std::expected commit(); +private: + struct State; + std::unique_ptr state_; + friend class Prepared; +}; + +class Prepared { +public: + Prepared(); + ~Prepared(); + Prepared(Prepared&&); + Prepared& operator=(Prepared&&); + Prepared(const Prepared&) = delete; + Prepared& operator=(const Prepared&) = delete; + std::expected execute(); +private: + struct State; + std::unique_ptr state_; + friend std::expected preflight_materialization( + std::span, std::span, const fs::path&); +}; + +// Entire-plan preflight is read-only. execute revalidates ownership before +// publication and keeps old derived entries for rollback until commit. +std::expected preflight_materialization( + std::span changes, std::span claims, + const fs::path& subosRoot); +} diff --git a/src/core/xvm/relocation.cpp b/src/core/xvm/relocation.cpp index b41f85fab..cd2a667e5 100644 --- a/src/core/xvm/relocation.cpp +++ b/src/core/xvm/relocation.cpp @@ -1,6 +1,7 @@ module xlings.core.xvm.relocation; import std; +import xlings.platform; import xlings.core.xvm.types; namespace xlings::xvm { @@ -18,9 +19,7 @@ constexpr bool is_sep_(char c) { return c == '/' || c == '\\'; } // most expensive false positive this module could produce. constexpr char fold_(char c) { if (is_sep_(c)) return '/'; -#ifdef _WIN32 - if (c >= 'A' && c <= 'Z') return static_cast(c - 'A' + 'a'); -#endif + if (platform::is_windows && c >= 'A' && c <= 'Z') return static_cast(c - 'A' + 'a'); return c; } diff --git a/src/core/xvm/shim.cpp b/src/core/xvm/shim.cpp index 9e179413d..93c93ba51 100644 --- a/src/core/xvm/shim.cpp +++ b/src/core/xvm/shim.cpp @@ -1,11 +1,3 @@ -module; - -#include - -#if defined(__linux__) || defined(__APPLE__) -#include -#endif - module xlings.core.xvm.shim; import std; @@ -28,9 +20,38 @@ import xlings.core.xvm.shim_table; import xlings.core.xvm.shim_view; import xlings.core.home_config; import xlings.core.home_identity; +import xlings.subos.rootfs; +import xlings.platform.target; namespace xlings::xvm { +std::expected root_compiler_alias(std::string alias, + const VData& data, const std::filesystem::path& scope, const std::filesystem::path& root) { + if constexpr (platform::OS_NAME != "linux") return alias; + if (!data.bindingGroup || data.bindingGroup->provider != "xim:gcc" + || data.bindingGroup->rootTarget != "xim-gnu-gcc") return alias; + const auto end = alias.find_first_of(" \t"); + const auto driver = std::filesystem::path(alias.substr(0, end)).filename().string(); + const bool compiler = driver == "gcc" || driver == "g++" || driver == "cc" || driver == "c++" + || driver.ends_with("-gcc") || driver.ends_with("-g++") || driver.ends_with("-c++"); + if (!compiler || !subos::rootfs::running_projection(root, scope)) return alias; + const auto flag = "--sysroot=" + scope.string(); + const auto found = alias.find(flag); + if (found == alias.npos || (found > 0 && alias[found - 1] != ' ' && alias[found - 1] != '\t') + || (found + flag.size() != alias.size() && alias[found + flag.size()] != ' ' + && alias[found + flag.size()] != '\t')) return alias; + std::string loader; + if constexpr (platform::build_arch() == "x86_64") loader = "/lib64/ld-linux-x86-64.so.2"; + else if constexpr (platform::build_arch() == "aarch64") loader = "/lib/ld-linux-aarch64.so.1"; + else return std::unexpected("this GCC root has no supported logical loader for its architecture"); + std::error_code ec; + if (!std::filesystem::is_regular_file(root / std::filesystem::path(loader).relative_path(), ec) || ec) + return std::unexpected("the GCC root has no logical glibc loader " + loader + "; install glibc in this scope"); + alias.replace(found, flag.size(), "--sysroot=/"); + alias += " -Wl,--dynamic-linker," + loader; + return alias; +} + bool is_xlings_binary(std::string_view name) { return name == "xlings"; } @@ -48,11 +69,24 @@ resolve_owner_home(const std::filesystem::path& invoked) { // marker, else the legacy layout minus a SubOS. The structural predicate // this replaced accepted a SubOS, since every SubOS has an empty `subos/` // of its own (#617), and the walk stopped one level too early. + // + // One link at a time, the file's own location first. Resolving the whole + // chain answered with where the ENTRY lives, which is the home only when + // the entry is the home's own file: a home whose entry links to a system + // package's binary (deployment S) had every shim anchored to nothing. A + // link to a shim from outside any home still reaches it on the next hop. auto walk_up = [](fs::path p) -> std::optional { - std::error_code ec; - auto canon = fs::weakly_canonical(p, ec); - if (!ec && !canon.empty()) p = canon; - return home_identity::nearest_home(p.parent_path()); + for (int hop = 0; hop < 16; ++hop) { + std::error_code ec; + auto dir = fs::weakly_canonical(p.parent_path(), ec); + if (ec || dir.empty()) dir = p.parent_path(); + if (auto h = home_identity::nearest_home(dir)) return h; + if (!fs::is_symlink(p, ec)) break; + auto target = fs::read_symlink(p, ec); + if (ec) break; + p = target.is_absolute() ? target : dir / target; + } + return std::nullopt; }; if (invoked.has_parent_path() && !invoked.parent_path().empty()) { @@ -599,23 +633,20 @@ void report_passthrough_(const std::string& program_name, int exec_host_program_(const std::filesystem::path& host, int argc, char* argv[]) { auto hostStr = host.string(); - std::vector newArgv; - newArgv.push_back(hostStr.c_str()); - for (int i = 1; i < argc; ++i) newArgv.push_back(argv[i]); - newArgv.push_back(nullptr); - -#if defined(__linux__) || defined(__APPLE__) - execvp(hostStr.c_str(), const_cast(newArgv.data())); - log::error("xlings: failed to exec '{}'", hostStr); - return 127; -#else - std::string cmd = platform::shell_quote(hostStr); - for (int i = 1; i < argc; ++i) { - cmd += " "; - cmd += platform::shell_quote(argv[i]); + if constexpr (platform::is_posix) { + std::vector args{hostStr}; + for (int i = 1; i < argc; ++i) args.emplace_back(argv[i]); + (void)platform::exec_program(args, platform::environment()); + log::error("xlings: failed to exec '{}'", hostStr); + return 127; + } else { + std::string cmd = platform::shell_quote(hostStr); + for (int i = 1; i < argc; ++i) { + cmd += " "; + cmd += platform::shell_quote(argv[i]); + } + return platform::exec(cmd); } - return platform::exec(cmd); -#endif } // ── `ldd` answers in the file's world, not this subos's ────────────── @@ -1139,6 +1170,9 @@ int shim_dispatch(const std::string& program_name, int argc, char* argv[]) { // is the only layer that knows which subos this process resolved to // -- which is exactly why the answer was not in the database. alias_cmd = expand_subos_placeholder(alias_cmd, active_subos_dir); + const auto root_alias = root_compiler_alias(alias_cmd, *vdata, active_subos_dir); + if (!root_alias) { log::error("xlings: {}", root_alias.error()); return 1; } + alias_cmd = *root_alias; // Refuse rather than hand a vanishing reference to the shell. // @@ -1182,6 +1216,31 @@ int shim_dispatch(const std::string& program_name, int argc, char* argv[]) { platform::set_env_variable("XLINGS_SHIM_DEPTH", std::to_string(depth + 1)); + // An alias that is plain words naming this payload's own program + // (gcc's `gcc --sysroot=`) runs directly: no /bin/sh between + // the shim and the compiler, so it dispatches in a root that has no + // shell (design part 2 §2.4 G2, SHIM-NO-SHELL). Anything the shell + // would interpret -- a variable, a quote, a pipe -- still goes + // through it, as before. + if constexpr (platform::is_posix) { + if (alias_cmd.find_first_of("$`'\"\\|;&<>()*?[]{}~#\n") == std::string::npos) { + std::vector words; + std::istringstream in(alias_cmd); + for (std::string w; in >> w;) words.push_back(w); + if (!words.empty()) { + auto exe = resolve_executable(words.front(), vdata->path, xlings_home); + if (!exe.empty()) { + std::vector args{exe.string()}; + args.insert(args.end(), words.begin() + 1, words.end()); + for (int i = 1; i < argc; ++i) args.emplace_back(argv[i]); + (void)platform::exec_program(args, platform::environment()); + log::error("xlings: failed to exec '{}'", exe.string()); + return 127; + } + } + } + } + // Build command: resolved alias + original args, run via platform::exec std::string cmd = alias_cmd; for (int i = 1; i < argc; ++i) { @@ -1240,33 +1299,25 @@ int shim_dispatch(const std::string& program_name, int argc, char* argv[]) { return 1; } - // Build argv for execvp - auto exe_str = exe_path.string(); - std::vector new_argv; - new_argv.push_back(exe_str.c_str()); - for (int i = 1; i < argc; ++i) { - new_argv.push_back(argv[i]); - } - new_argv.push_back(nullptr); - // Increment shim depth before exec so child processes see it platform::set_env_variable("XLINGS_SHIM_DEPTH", std::to_string(depth + 1)); -#if defined(__linux__) || defined(__APPLE__) - execvp(exe_path.c_str(), const_cast(new_argv.data())); - // If execvp returns, it failed - log::error("xlings: failed to exec '{}'", - Config::display_path(exe_path)); - return 1; -#else - // Fallback for platforms without execvp - std::string cmd = platform::shell_quote(exe_path.string()); - for (int i = 1; i < argc; ++i) { - cmd += " "; - cmd += platform::shell_quote(argv[i]); + if constexpr (platform::is_posix) { + std::vector args{exe_path.string()}; + for (int i = 1; i < argc; ++i) args.emplace_back(argv[i]); + (void)platform::exec_program(args, platform::environment()); + // Returned: the exec failed. + log::error("xlings: failed to exec '{}'", + Config::display_path(exe_path)); + return 1; + } else { + std::string cmd = platform::shell_quote(exe_path.string()); + for (int i = 1; i < argc; ++i) { + cmd += " "; + cmd += platform::shell_quote(argv[i]); + } + return platform::exec(cmd); } - return platform::exec(cmd); -#endif } } diff --git a/src/core/xvm/shim.cppm b/src/core/xvm/shim.cppm index 39384cd1e..3a576aab7 100644 --- a/src/core/xvm/shim.cppm +++ b/src/core/xvm/shim.cppm @@ -1,11 +1,3 @@ -module; - -#include - -#if defined(__linux__) || defined(__APPLE__) -#include -#endif - export module xlings.core.xvm.shim; import std; @@ -91,6 +83,12 @@ handoff_target(const std::filesystem::path& ownImage); // see that shim make its own decision. bool consume_handoff_marker(); +// Runtime link settings for a formally registered glibc GCC driver in a +// verified root projection. Shared payload specs remain scope-independent. +std::expected root_compiler_alias(std::string alias, + const VData& data, const std::filesystem::path& scope, + const std::filesystem::path& root = "/"); + // Resolve the real executable path for a shim target std::filesystem::path resolve_executable(const std::string& program_name, const std::string& path, diff --git a/src/core/xvm/shim_view.cppm b/src/core/xvm/shim_view.cppm index 88a597cf6..0f9ef39e6 100644 --- a/src/core/xvm/shim_view.cppm +++ b/src/core/xvm/shim_view.cppm @@ -41,7 +41,7 @@ struct ShimView { WorkspaceInstalled installed; VersionDB slice; -#if defined(_MSC_VER) +#if defined(_MSC_VER) // platform-if-ok: compiler, not platform: GCC/MSVC module special members ShimView() = default; ~ShimView() = default; ShimView(const ShimView&) = default; @@ -58,7 +58,7 @@ struct ShimView { #endif }; -#if !defined(_MSC_VER) +#if !defined(_MSC_VER) // platform-if-ok: compiler, not platform: GCC/MSVC module special members // Out-of-line special members to work around GCC module boundary issues // (same treatment as VData/VInfo in types.cppm). xlings::xvm::ShimView::ShimView() = default; diff --git a/src/core/xvm/switch_plan.cpp b/src/core/xvm/switch_plan.cpp index dbfe53b7e..eb452329f 100644 --- a/src/core/xvm/switch_plan.cpp +++ b/src/core/xvm/switch_plan.cpp @@ -2,6 +2,7 @@ module xlings.core.xvm.switch_plan; import std; import xlings.core.xvm.types; +import xlings.core.xvm.db; import xlings.core.xvm.bindings; import xlings.core.xvm.errors; @@ -28,6 +29,22 @@ std::pair provider_of_(const VersionDB& db, } namespace xlings::xvm { +void reclaim_conflicting_file_bindings(const VersionDB& db, Workspace& workspace, + const Workspace& selected, const std::string& home) { + for (const auto& [target, version] : selected) { + const auto* data = get_vdata(db, target, version); + const auto incoming = file_placement(db, target, version, + data && !data->sourceHome.empty() ? data->sourceHome : home); + std::error_code ec; + if (incoming.empty() || !std::filesystem::is_regular_file(incoming.source, ec) || ec) + continue; + std::erase_if(workspace, [&](const auto& entry) { + if (selected.contains(entry.first)) return false; + const auto old = file_placement(db, entry.first, entry.second, home); + return !old.empty() && old.destination == incoming.destination; + }); + } +} std::expected plan_use_switch( const VersionDB& db, diff --git a/src/core/xvm/switch_plan.cppm b/src/core/xvm/switch_plan.cppm index f0717e705..ae6f62773 100644 --- a/src/core/xvm/switch_plan.cppm +++ b/src/core/xvm/switch_plan.cppm @@ -26,6 +26,8 @@ namespace xlings::xvm::detail_ { } // namespace xlings::xvm::detail_ export namespace xlings::xvm { +void reclaim_conflicting_file_bindings(const VersionDB& db, Workspace& workspace, + const Workspace& selected, const std::string& home); // What has to change on disk for one member of the release. // diff --git a/src/core/xvm/types.cpp b/src/core/xvm/types.cpp index 07bdc445a..ed581b419 100644 --- a/src/core/xvm/types.cpp +++ b/src/core/xvm/types.cpp @@ -54,6 +54,6 @@ bool has_program_kind(const VersionDB& db, const std::string& target) { // namespace xlings::xvm -#if !defined(_MSC_VER) +#if !defined(_MSC_VER) // platform-if-ok: compiler, not platform: GCC/MSVC module special members #endif diff --git a/src/core/xvm/types.cppm b/src/core/xvm/types.cppm index 90ed90431..40180410d 100644 --- a/src/core/xvm/types.cppm +++ b/src/core/xvm/types.cppm @@ -24,6 +24,9 @@ struct BindingIntegrityIssue { struct VData { std::string path; + std::string sourceHome; // borrowed registration provenance; empty means this home + std::string sourceScope; + std::string layerMetadata; // retain unknown provenance keys on load/save std::string kind; std::string sourceName; std::string destinationName; @@ -64,7 +67,7 @@ struct VData { // dependency on the JSON library. std::map bindingUnreadable; -#if defined(_MSC_VER) +#if defined(_MSC_VER) // platform-if-ok: compiler, not platform: GCC/MSVC module special members VData() = default; ~VData() = default; VData(const VData&) = default; @@ -87,7 +90,7 @@ struct VInfo { std::map versions; std::map> bindings; -#if defined(_MSC_VER) +#if defined(_MSC_VER) // platform-if-ok: compiler, not platform: GCC/MSVC module special members VInfo() = default; ~VInfo() = default; VInfo(const VInfo&) = default; @@ -210,7 +213,7 @@ bool has_program_kind(const VersionDB& db, const std::string& target); } // namespace xlings::xvm -#if !defined(_MSC_VER) +#if !defined(_MSC_VER) // platform-if-ok: compiler, not platform: GCC/MSVC module special members // Out-of-line special members to work around GCC module boundary issues xlings::xvm::VData::VData() = default; xlings::xvm::VData::~VData() = default; diff --git a/src/interface.cpp b/src/interface.cpp index d1ca0e381..61cc46137 100644 --- a/src/interface.cpp +++ b/src/interface.cpp @@ -7,12 +7,6 @@ // It provides a single entry point (`interface::run`) that the cli layer // hooks up to its `interface` subcommand action. -module; - -#ifdef __unix__ -#include -#endif - module xlings.interface; import std; @@ -434,23 +428,24 @@ void InterfaceSession::heartbeat_loop_(std::stop_token st) { } void InterfaceSession::stdin_loop_(std::stop_token st) { -#ifdef __unix__ - while (!st.stop_requested()) { - ::pollfd pfd{0, POLLIN, 0}; - int rc = ::poll(&pfd, 1, 100); - if (rc <= 0) continue; - if (pfd.revents & (POLLHUP | POLLERR)) return; - if (!(pfd.revents & POLLIN)) continue; + if constexpr (platform::is_linux) { + // Polled, so a stop request is seen within 100 ms rather than at + // the next line. + while (!st.stop_requested()) { + platform::PollFd in{ 0 }; + if (platform::poll_fds(std::span(&in, 1), 100) <= 0) continue; + if (in.closed && !in.readable) return; + if (!in.readable) continue; + std::string line; + if (!std::getline(std::cin, line)) return; + handle_stdin_line_(line); + } + } else { std::string line; - if (!std::getline(std::cin, line)) return; - handle_stdin_line_(line); - } -#else - std::string line; - while (!st.stop_requested() && std::getline(std::cin, line)) { - handle_stdin_line_(line); + while (!st.stop_requested() && std::getline(std::cin, line)) { + handle_stdin_line_(line); + } } -#endif } void InterfaceSession::handle_stdin_line_(std::string_view line) { diff --git a/src/interface.cppm b/src/interface.cppm index d9c2c9089..6c4731639 100644 --- a/src/interface.cppm +++ b/src/interface.cppm @@ -7,12 +7,6 @@ // It provides a single entry point (`interface::run`) that the cli layer // hooks up to its `interface` subcommand action. -module; - -#ifdef __unix__ -#include -#endif - export module xlings.interface; import std; @@ -33,7 +27,9 @@ namespace xlings::interface { // producer sends at most one per 100 ms per stream plus the final one; the // renderer keeps its own frame state, so `prevLines` is deprecated and always // 0 (it stays until 2.0 because a minor version only adds). -export constexpr const char* kProtocolVersion = "1.5"; +// 1.6: subos_exec emits bounded stdout/stderr chunks before the command ends. +// Invalid UTF-8 chunks use encoding=base64; cancellation reaps the child. +export constexpr const char* kProtocolVersion = "1.6"; // Convert any Event variant to one NDJSON line (no trailing newline). // Returns "" for events not surfaced to wire (e.g. CompletedEvent — the diff --git a/src/main.cpp b/src/main.cpp index 0ab66f6ae..72e91ba6b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,31 +1,68 @@ import std; import xlings.cli; +import xlings.core.xim.lua_boundary; import xlings.core.config; import xlings.core.log; import xlings.platform; import xlings.core.xvm.shim; import xlings.core.home_identity; +import xlings.core.home; +import xlings.subos.session; +import luban.stage0; +import xlings.carrier.wsl2; +import xlings.observe; import xlings.core.xvm.lock; import xlings.core.destructive_log; // Cross-version compat shims (alias migrations, profile auto-upgrade). // See compact/xself.cppm — each compat lives in its own version sub-namespace. import xlings.core.xself.compat; -#ifdef _WIN32 -#include -#define isatty _isatty -#define STDOUT_FD 1 -#else -#include -#define STDOUT_FD STDOUT_FILENO -#endif - -#ifdef __APPLE__ -#include // std::_Exit -#endif int main(int argc, char* argv[]) { + // `xlings __carrier-env K=V ... -- `: a carrier's launcher carries + // no variables and its guest has no env(1) (design part 3 §5.1). Set + // them and go on as `xlings `. + std::vector carried; + if (argc >= 2 && std::string_view(argv[1]) == "__carrier-env") { + int i = 2; + for (; i < argc && std::string_view(argv[i]) != "--"; ++i) { + const std::string_view kv(argv[i]); + const auto eq = kv.find('='); + if (eq == std::string_view::npos || eq == 0) { + std::println(std::cerr, "xlings __carrier-env: expected K=V, got '{}'", kv); + return 2; + } + xlings::platform::set_env_variable(std::string(kv.substr(0, eq)), std::string(kv.substr(eq + 1))); + } + carried.push_back(argv[0]); + for (++i; i < argc; ++i) carried.push_back(argv[i]); + carried.push_back(nullptr); + argc = static_cast(carried.size()) - 1; + argv = carried.data(); + } + if (argc >= 2 && std::string_view(argv[1]) == "__xpkg-worker") + return xlings::xim::lua_boundary::worker_main(argc, argv); + // A machine's first process when its root is a SubOS (design part 2 + // §8.2): before anything reads a home -- /proc is not even mounted yet. + if (argc >= 1 && std::filesystem::path(argv[0]).filename() == luban::stage0::kName) + return luban::stage0::run(argc, argv); + + // The first process inside a SubOS sandbox (xlings.subos.session). Before + // everything: it must not read, adopt or write any home -- inside, the + // home is the sandbox's view of it -- and it is not a shim. + // Inside a carrier's machine (design part 3 §5.3): mount one granted + // Windows directory. Run there by the xlings on the host, as root. + if (argc >= 2 && std::string_view(argv[1]) == "__carrier-grant") { + std::vector args(argv + 2, argv + argc); + return xlings::carrier::wsl2::guest_grant(args); + } + + if (argc >= 2 && std::string_view(argv[1]) == "__session-init") { + std::vector args(argv + 2, argv + argc); + return xlings::subos::session::session_init(args); + } + // Shim handoff (#615), before anything else runs -- in particular before // any path is narrowed to the ANSI code page, which is where a stale // client dies silently in a directory outside it (mcpp#693). @@ -56,7 +93,7 @@ int main(int argc, char* argv[]) { // Restore terminal cursor visibility on exit (safety net for TUI download progress) // Only emit when stdout is a TTY to avoid polluting captured output std::atexit([]() { - if (isatty(STDOUT_FD)) { + if (xlings::platform::stdout_is_terminal()) { std::cout << "\033[?25h" << std::flush; } }); @@ -125,6 +162,40 @@ int main(int argc, char* argv[]) { // recognised by its layout. if (is_cli) xlings::home_identity::adopt_legacy_home(p.homeDir); + // What kind of home this is (xlings.core.home). A home a NEWER client + // moved to a layout this one does not know is read, never written: + // writing would undo what this client cannot see. Otherwise the mode + // is declared once (inferred from where the home is, for a marker that + // predates modes) and the layout raised to this client's -- layout 2 + // only adds directories, so an older client is unaffected. Commands + // only, for the same reason as the marker above. + bool refused = false; + if (is_cli) { + const auto& ctx = xlings::Config::home_context(); + xlings::observe::trace("home", std::format("{} source={} mode={}{} layout={}", ctx.home.string(), + xlings::home::to_string(ctx.source), xlings::home::to_string(ctx.mode), + ctx.modeDeclared ? "" : " (inferred)", ctx.layout)); + if (!ctx.writable()) { + std::vector rest(argv + 1, argv + argc); + if (!xlings::home::is_read_only_command(rest)) { + xlings::log::error( + "{} is at home layout {}, written by a newer xlings; this " + "client knows layout {} and only reads it. Run the home's " + "own client: {}", + p.homeDir.string(), ctx.layout, xlings::home::kLayout, + (p.homeDir / "bin" / "xlings").string()); + rc = 1; + refused = true; + } + } else if (xlings::home_identity::has_marker(p.homeDir) + && (!ctx.modeDeclared || ctx.layout < xlings::home::kLayout)) { + (void)xlings::home::declare( + p.homeDir, + ctx.modeDeclared ? std::nullopt : std::optional(ctx.mode), + xlings::home::kLayout); + } + } + // What this process calls itself when another one has to wait for its // state lock. Set here because this is the only place that has argv and // is not itself a command; see xvm/lock.cppm. @@ -142,7 +213,9 @@ int main(int argc, char* argv[]) { xlings::xvm::set_lock_command_hint(std::move(command)); } - if (is_cli) { + if (refused) { + // rc set above; fall through to the platform exit path. + } else if (is_cli) { rc = xlings::cli::run(argc, argv); } else { rc = xlings::xvm::shim_dispatch(program_name, argc, argv); @@ -155,14 +228,13 @@ int main(int argc, char* argv[]) { rc = 1; } -#ifdef __APPLE__ - // On macOS, static libc++ linked with dynamic libc++abi causes SIGABRT - // during static destruction. Skip destructors — CLI tool needs no cleanup. - // _Exit skips atexit handlers, so restore cursor explicitly here. - if (isatty(STDOUT_FD)) std::cout << "\033[?25h" << std::flush; - std::cerr.flush(); - std::_Exit(rc); -#else + if constexpr (xlings::platform::is_macos) { + // On macOS, static libc++ linked with dynamic libc++abi causes SIGABRT + // during static destruction. Skip destructors — CLI tool needs no cleanup. + // _Exit skips atexit handlers, so restore cursor explicitly here. + if (xlings::platform::stdout_is_terminal()) std::cout << "\033[?25h" << std::flush; + std::cerr.flush(); + std::_Exit(rc); + } return rc; -#endif } diff --git a/tests/README.md b/tests/README.md index 1e5606192..c2c3fc227 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,165 +1,79 @@ -# xlings Tests +# xlings tests -This directory contains unit tests and end-to-end usability tests for `xlings`. +Design: `.agents/docs/2026-10-05-subos-architecture-design.md` §24. -## Unit Tests +## Layout -Script: `tests/unit/test_main.cpp` (51 gtest tests, 11 suites) +| Path | What | Runs with | +|---|---|---| +| `tests/unit/test_*.cpp` | gtest unit tests (and testkit's own tests) | `mcpp test` | +| `tests/e2e/test_*.cpp` | end-to-end tests in C++: drive the built binary through `modules/testkit` | `mcpp test` | +| `tests/e2e/*_test.sh`, `*.ps1` | legacy end-to-end scripts | `tests/e2e/run_all.sh`, or `xdev test --suite e2e-shell` | +| `tests/scripts/` | contract scripts (Python / shell) | `xdev test --suite contract-scripts` | +| `tests/suites.toml` | the legacy suites xdev knows | `xdev test --suite ` | +| `tests/fresh-install/`, `tests/candidate-install/` | install-path suites | their workflows | -| Suite | Tests | Coverage | -|-------|-------|----------| -| I18nTest | 7 | 多语言翻译 | -| LogTest | 4 | 日志级别与文件输出 | -| UtilsTest | 5 | 字符串工具 | -| CmdlineTest | 4 | CLI 解析 | -| UiTest | 2 | UI 组件 | -| XimTypesTest | 4 | PlanNode, InstallPlan 等类型 | -| XimIndexTest | 9 | 索引构建/搜索/版本匹配/加载 | -| XimResolverTest | 4 | 依赖解析/拓扑排序/循环检测 | -| XimDownloaderTest | 3 | 下载任务/归档解压 | -| XimInstallerTest | 4 | 安装编排/错误处理 | -| XimCommandsTest | 5 | 命令执行 (search/list/info) | +New tests are written in C++. A script leaves `tests/suites.toml` when it is +ported. -### Run +## Writing an end-to-end test -```bash -mcpp build -mcpp test -``` - -Note: XimIndex/Resolver/Installer/Commands tests require the `xim-pkgindex` repo at a known path. They will `GTEST_SKIP` if not found. - -## E2E Linux Usability Test - -Script: `tests/e2e/linux_usability_test.sh` - -What it validates in an isolated extracted release package: - -- basic CLI availability (`xlings -h`, `xlings config`, `xvm --version`) -- `xlings info ` mapping to `xvm info` -- `subos` lifecycle (`new`, `use`, `list`, `info`, `remove`) -- `subos new` fails when `config/xvm` is missing (package incomplete) -- new `subos` short aliases (`ls`, `i`, `rm`) -- backward compatibility for existing `subos` commands -- self-maintenance dry-run command -- optional network install scenario (`xlings install d2x`) - -### Run - -```bash -# auto-detect latest build/xlings-*-linux-x86_64.tar.gz -bash tests/e2e/linux_usability_test.sh - -# specify archive explicitly -bash tests/e2e/linux_usability_test.sh build/xlings-0.4.0-linux-x86_64.tar.gz -``` - -### Network Scenario - -By default network-dependent installation checks are enabled. - -```bash -SKIP_NETWORK_TESTS=1 bash tests/e2e/linux_usability_test.sh -``` - -Same toggle works for macOS and Windows scripts. - -## E2E Project Scenarios - -Scenario configs: +```cpp +#include +import xlings.testkit; // before the header (mcpp scans imports in the source) +#include "xlings/xtest.hpp" +import std; -- `tests/e2e/scenarios/local_repo/.xlings.json` -- `tests/e2e/scenarios/global_fallback/.xlings.json` -- `tests/e2e/scenarios/xlings_res_cn/.xlings.json` -- `tests/e2e/scenarios/xlings_res_project_override/.xlings.json` -- `tests/e2e/scenarios/xlings_res_region_map/.xlings.json` -- `tests/e2e/scenarios/xlings_res_multi_server/.xlings.json` +namespace tk = xlings::testkit; -Scenario scripts: - -- `tests/e2e/project_local_repo_test.sh` -- `tests/e2e/project_global_fallback_test.sh` -- `tests/e2e/project_xlings_res_test.sh` -- `tests/e2e/project_xlings_res_override_test.sh` -- `tests/e2e/project_xlings_res_region_map_test.sh` -- `tests/e2e/project_xlings_res_multi_server_test.sh` -- `tests/e2e/subos_payload_refcount_test.sh` -- `tests/e2e/release_self_install_test.sh` -- `tests/e2e/release_quick_install_test.sh` -- `tests/e2e/release_subos_smoke_test.sh` - -Aggregate scripts: - -- `tests/e2e/project_data_routing_test.sh` runs the local-repo + global-fallback routing checks -- `tests/e2e/project_e2e_test.sh` runs all fixed project scenarios - -Runtime homes: - -- `tests/e2e/runtime/` - -Each scenario script creates and reuses its own isolated `XLINGS_HOME` under this directory, so E2E runs never touch the real machine-global environment. - -What they validate: - -- project-local index repo routing into `.xlings/data` -- global fallback routing into isolated `XLINGS_HOME/data` -- single-command multi-version installs for the same package -- `XLINGS_RES` mirror resolution and install execution -- project-level `XLINGS_RES` string override -- project-level `XLINGS_RES` region-object override -- multi-server resource selection that falls back from an unreachable endpoint to the fastest reachable one -- real shim execution after `xlings use` -- shared payload reuse across multiple subos, with final payload GC only after the last reference is removed -- release archive `self install` into an isolated user home -- quick-install bootstrap into an isolated user home -- extracted release package basic commands, subos lifecycle, d2x reuse, and RPATH verification - -### Run - -```bash -bash tests/e2e/project_data_routing_test.sh -bash tests/e2e/project_e2e_test.sh -bash tests/e2e/release_self_install_test.sh build/release.tar.gz -bash tests/e2e/release_quick_install_test.sh -bash tests/e2e/release_subos_smoke_test.sh build/release.tar.gz +XTEST(SubosExec, ExitCodeIsTheCommands, + .area = "subos", .covers = {"EXIT-CMD"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}) { + auto home = tk::Home::isolated("exec"); // temp dir, never under $HOME + auto r = home.xlings({"subos", "new", "box"}); + ASSERT_EQ(r.exit_code, 0) << r.transcript(); +} ``` -## E2E Bootstrap Home - -Script: `tests/e2e/bootstrap_home_test.sh` - -What it validates: +- `Home::isolated()` gives a fresh home; every run starts from an environment + the test names (`Home::env()` plus what it adds) — nothing is inherited. +- `requires_` names capabilities (`xdev doctor` lists them). Missing on a + developer machine: the test is skipped and says why. Missing on a CI lane + that declared it in `XDEV_LANE_CAPS`: the test fails. +- `covers` names requirement IDs; `proves = "isolation"` marks a test that + runs a real sandbox (a fake provider only proves the flow). +- A failed test keeps its home and copies its config/logs/state to + `target/xtest-artifacts//`. -- a bootstrap package root with only `.xlings.json` and `bin/xlings` is auto-detected as `XLINGS_HOME` -- `xlings self init` materializes `data/`, `subos/`, and `config/shell/` in place -- `xlings self install` copies that bootstrap home into an installed `~/.xlings`-style root -- portable and installed homes share the same top-level structure - -### Run +## xdev ```bash -bash tests/e2e/bootstrap_home_test.sh +mcpp build && mcpp build -p xdev +XDEV=$(find target -path '*/bin/xdev/xdev' | head -1) + +$XDEV doctor # what this machine can test +$XDEV test # all C++ tests, one report +$XDEV test test_testkit # a pattern, passed to mcpp test +$XDEV test --no-mcpp --suite contract-scripts +$XDEV report # re-render target/xdev/run ``` -## Install-time shims - -CI verifies that after `xlings self install`, `subos/default/bin` contains all required shims (xlings, xvm, xvm-shim, xim, xsubos, xself). Shims are created at install time, not in the package, to reduce archive size. +The report (terminal, `target/xdev/run/report.{md,json}`, and the GitHub step +summary in CI) lists pass/fail/skip for test binaries, XTEST cases and legacy +scripts, failures with their output, skips grouped by reason, the slowest +tests and the lane's capabilities. -## E2E macOS Usability Test +## Local runs -Script: `tests/e2e/macos_usability_test.sh` +- Run from a clean environment: a shell inside a subos exports + `XLINGS_ACTIVE_SUBOS`, which some older unit tests still inherit. +- Inside China set `XLINGS_TEST_MIRROR=CN` (AGENTS.md: an unreachable mirror + looks like the command under test hanging). +- Network is opt-in: `XDEV_NETWORK=1`. -```bash -bash tests/e2e/macos_usability_test.sh -SKIP_NETWORK_TESTS=0 bash tests/e2e/macos_usability_test.sh -``` - -## E2E Windows Usability Test -Script: `tests/e2e/windows_usability_test.ps1` - -```powershell -powershell -ExecutionPolicy Bypass -File tests/e2e/windows_usability_test.ps1 -$env:SKIP_NETWORK_TESTS = "0" -powershell -ExecutionPolicy Bypass -File tests/e2e/windows_usability_test.ps1 -``` +`xdev report --requirements tests/requirements.toml --fail-uncovered` 检查需求的测试声明。 +报告另外列出执行证据,跳过的测试不能证明需求已验证。发布验收使用 `--fail-unverified` +检查合并后的各车道记录;隔离需求还要求 `proves=isolation`。JSON 报告中的 +`requirements..declared_by` 与 `passed_by` 分别给出两种证据。 +`xdev test --out` 必须选择空目录,避免覆盖已有现场或用户文件。 diff --git a/tests/e2e/arch_evidence_contract_test.sh b/tests/e2e/arch_evidence_contract_test.sh index e696130e4..756442390 100755 --- a/tests/e2e/arch_evidence_contract_test.sh +++ b/tests/e2e/arch_evidence_contract_test.sh @@ -116,7 +116,9 @@ grep -q "E_UNSUPPORTED_TARGET.*$host_arch" <<<"$strong" \ # weak signal is enough HERE and not enough above, and the difference is who # asked. Both halves are asserted in this file so the asymmetry is deliberate # rather than an accident of two tests that never met. -if [[ "$(uname -s)" == Linux ]]; then +if [[ "$(uname -s)" == Linux ]] && host_has_usable_bwrap; then + log "skip the backend refusal: this host's own bwrap works, nothing is fetched" +elif [[ "$(uname -s)" == Linux ]]; then for backend in bwrap proot; do cat > "$index/pkgs/${backend:0:1}/${backend}.lua" <}")" +fail() { echo "FAIL: $*" >&2; exit 1; } +log() { echo "[aur] $*"; } +[[ -f /etc/arch-release ]] || fail "not Arch Linux" + +pacman -Sy --noconfirm --needed base-devel >/dev/null +for u in builder alice bob; do id "$u" >/dev/null 2>&1 || useradd -m "$u"; done + +# `head` closes the pipe early; tar's SIGPIPE is not a failure of this test. +ver="$( (tar tzf "$TARBALL" || true) | head -1 | sed -E 's|^xlings-(.*)-linux-x86_64/?$|\1|')" +work="$(mktemp -d)" +cp config/aur/PKGBUILD "$work/" +sed -i "s/^pkgver=.*/pkgver=$ver/" "$work/PKGBUILD" +cp "$TARBALL" "$work/xlings-$ver-linux-x86_64.tar.gz" +chown -R builder "$work" +log "makepkg $ver" +su builder -c "cd '$work' && XLINGS_LOCAL_TARBALL=xlings-$ver-linux-x86_64.tar.gz makepkg --noconfirm" >/dev/null +pacman -U --noconfirm "$work"/xlings-"$ver"-*.pkg.tar.zst >/dev/null + +log "1. the package holds the binary and nothing else" +files="$(pacman -Qlq xlings | grep -v '/$')" +[[ "$files" == "/usr/bin/xlings" ]] || fail "unexpected files: $files" +[[ "$(stat -c %U /usr/bin/xlings)" == root ]] || fail "/usr/bin/xlings is not root's" + +for u in alice bob; do + log "2. $u installs and runs a package" + install_log="$work/$u-install.log" + su "$u" -c 'cd ~ && xlings install -y xz' >"$install_log" 2>&1 \ + || { tail -60 "$install_log"; fail "$u: install failed"; } + home="$(getent passwd "$u" | cut -d: -f6)/.xlings" + [[ "$(readlink "$home/bin/xlings")" == /usr/bin/xlings ]] || fail "$u: entry is not a link to /usr/bin/xlings" + su "$u" -c "'$home/subos/default/bin/xz' --version" | grep -q "XZ Utils" || fail "$u: xz does not run" + log "3. $u's self update points at pacman" + out="$(su "$u" -c 'cd ~ && xlings self update' 2>&1 || true)" + grep -q "pacman -Syu xlings" <<<"$out" || fail "$u: self update did not name pacman: $out" +done + +log "4. removing the package leaves the homes" +pacman -R --noconfirm xlings >/dev/null +for u in alice bob; do + home="$(getent passwd "$u" | cut -d: -f6)/.xlings" + [[ -d "$home/data/xpkgs" ]] || fail "$u: the home went with the package" +done +log "PASS: the Arch package is a system package's xlings, per user" diff --git a/tests/e2e/doctor_swept_payload_test.sh b/tests/e2e/doctor_swept_payload_test.sh index fc375863e..1bb49d73c 100755 --- a/tests/e2e/doctor_swept_payload_test.sh +++ b/tests/e2e/doctor_swept_payload_test.sh @@ -162,7 +162,7 @@ log "D4: the directory now equals a fresh install" [[ ! -e "$PAYLOAD_DIR/another-package-9.9.9.lock" ]] \ || fail "D4: the .lock sidecar survived --fix" -fresh_entries="$(ls -A "$PAYLOAD_DIR" | grep -v -E '^\.xpkg' | wc -l | tr -d ' ')" +fresh_entries="$(ls -A "$PAYLOAD_DIR" | grep -v -E '^(\.xpkg-install\.json|\.xpkg\.lua|\.xlings-resolution\.json)$' | wc -l | tr -d ' ')" [[ "$fresh_entries" == "1" ]] \ || fail "D4: swept-fixture's install_dir has $fresh_entries non-bookkeeping entries after --fix, expected exactly 1 (bin); got: $(ls -A "$PAYLOAD_DIR")" diff --git a/tests/e2e/fixtures/glprobe.c b/tests/e2e/fixtures/glprobe.c new file mode 100644 index 000000000..7a65fdbad --- /dev/null +++ b/tests/e2e/fixtures/glprobe.c @@ -0,0 +1,143 @@ +// Copied from xim-pkgindex .agents/tools/graphics/glprobe.c (the graphics stack's probe), +// for the luban-desktop scenario (tests/e2e/rootfs_instance_test.sh step 9). +// glprobe — the S1..S4 assertions from the graphics-stack design, as one binary. +// +// Renders through EGL's surfaceless platform (no X server, no Wayland, no +// window) and reads the framebuffer back. That last part is the point: +// eglInitialize succeeding and a renderer string printing are both things a +// half-working stack does. Only a pixel that came back the colour we asked for +// proves something rendered. +// +// Prints one KEY=VALUE per line so the harness can assert on them: +// GL_VENDOR / GL_RENDERER / GL_VERSION +// PIXEL=RRGGBB +// RESULT=ok|fail: +// LOADED= one per GL/vendor object actually mapped +// +// LOADED is not decoration. "GL_RENDERER=NVIDIA GeForce RTX 4080" is printed +// just as happily by the HOST's stack running inside a subos that overrode +// nothing -- the host binary under the host loader gives exactly that line. +// Only the path each object was mapped from can tell "our payload drove this" +// apart from "we measured the host". Measured 2026-08-06: a `glxinfo` run +// inside a subos printed the NVIDIA renderer while every single object came +// from /usr/lib. +#include +#include +#include +#include +#include + +// Every GL/vendor object mapped into this process, by the path it came from. +static void dump_loaded(void) { + FILE *f = fopen("/proc/self/maps", "r"); + if (!f) { printf("LOADED=\n"); return; } + char line[4096], seen[64][512]; + int n = 0; + while (fgets(line, sizeof line, f)) { + char *p = strchr(line, '/'); + if (!p) continue; + p[strcspn(p, "\n")] = 0; + const char *base = strrchr(p, '/'); + base = base ? base + 1 : p; + if (!(strstr(base, "_nvidia.so") || strstr(base, "libnvidia-") + || strstr(base, "_mesa.so") || strstr(base, "libgallium") + || strstr(base, "libGLX.so") || strstr(base, "libEGL.so") + || strstr(base, "libGLdispatch") || strstr(base, "libGL.so"))) + continue; + int dup = 0; + for (int i = 0; i < n; i++) if (!strcmp(seen[i], p)) { dup = 1; break; } + if (dup || n >= 64) continue; + snprintf(seen[n++], sizeof seen[0], "%s", p); + } + fclose(f); + for (int i = 0; i < n; i++) printf("LOADED=%s\n", seen[i]); +} + +#define W 64 +#define H 64 + +static int fail(const char *why) { + dump_loaded(); + printf("RESULT=fail:%s\n", why); + return 1; +} + +int main(void) { + // Surfaceless rather than EGL_DEFAULT_DISPLAY: the whole point is to run + // with no display server reachable, which is what an empty host looks like. + EGLDisplay dpy = EGL_NO_DISPLAY; + + // Three ways to reach a display, reported separately. Collapsing them into + // one "no-display" hides which layer refused: a missing client extension + // (the loader never advertised surfaceless), a vendor that declined the + // platform, and no vendor at all are three different bugs, and the first + // two are indistinguishable from "the stack is not there" without this. + const char *clientExts = eglQueryString(EGL_NO_DISPLAY, EGL_EXTENSIONS); + printf("EGL_CLIENT_EXTENSIONS=%s\n", clientExts ? clientExts : "(none)"); + + PFNEGLGETPLATFORMDISPLAYEXTPROC getPlatformDisplay = + (PFNEGLGETPLATFORMDISPLAYEXTPROC)eglGetProcAddress("eglGetPlatformDisplayEXT"); + if (getPlatformDisplay) { + dpy = getPlatformDisplay(EGL_PLATFORM_SURFACELESS_MESA, EGL_DEFAULT_DISPLAY, NULL); + if (dpy == EGL_NO_DISPLAY) + printf("EGL_NOTE=surfaceless refused, egl error 0x%x\n", eglGetError()); + } else { + printf("EGL_NOTE=no eglGetPlatformDisplayEXT\n"); + } + if (dpy == EGL_NO_DISPLAY) { + dpy = eglGetDisplay(EGL_DEFAULT_DISPLAY); + if (dpy == EGL_NO_DISPLAY) + printf("EGL_NOTE=default display refused, egl error 0x%x\n", eglGetError()); + } + if (dpy == EGL_NO_DISPLAY) return fail("no-display"); + + EGLint major = 0, minor = 0; + if (!eglInitialize(dpy, &major, &minor)) return fail("eglInitialize"); + printf("EGL_VERSION=%d.%d\n", major, minor); + + if (!eglBindAPI(EGL_OPENGL_API)) return fail("eglBindAPI"); + + const EGLint cfgAttr[] = { + EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, + EGL_RENDERABLE_TYPE, EGL_OPENGL_BIT, + EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, + EGL_NONE + }; + EGLConfig cfg; EGLint n = 0; + if (!eglChooseConfig(dpy, cfgAttr, &cfg, 1, &n) || n < 1) + return fail("eglChooseConfig"); + + EGLContext ctx = eglCreateContext(dpy, cfg, EGL_NO_CONTEXT, NULL); + if (ctx == EGL_NO_CONTEXT) return fail("eglCreateContext"); + + const EGLint pbAttr[] = { EGL_WIDTH, W, EGL_HEIGHT, H, EGL_NONE }; + EGLSurface surf = eglCreatePbufferSurface(dpy, cfg, pbAttr); + if (surf == EGL_NO_SURFACE) return fail("eglCreatePbufferSurface"); + + if (!eglMakeCurrent(dpy, surf, surf, ctx)) return fail("eglMakeCurrent"); + + const char *vendor = (const char *)glGetString(GL_VENDOR); + const char *renderer = (const char *)glGetString(GL_RENDERER); + const char *version = (const char *)glGetString(GL_VERSION); + printf("GL_VENDOR=%s\n", vendor ? vendor : "?"); + printf("GL_RENDERER=%s\n", renderer ? renderer : "?"); + printf("GL_VERSION=%s\n", version ? version : "?"); + + // A colour no default clear would produce by accident. + glClearColor(0.2f, 0.4f, 0.6f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + glFinish(); + + unsigned char px[4] = {0, 0, 0, 0}; + glReadPixels(W / 2, H / 2, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, px); + printf("PIXEL=%02X%02X%02X\n", px[0], px[1], px[2]); + + // 0.2/0.4/0.6 → 51/102/153, ±2 for the rounding a driver is entitled to. + if (px[0] < 49 || px[0] > 53 || px[1] < 100 || px[1] > 104 || + px[2] < 151 || px[2] > 155) { + return fail("pixel-mismatch"); + } + dump_loaded(); + printf("RESULT=ok\n"); + return 0; +} diff --git a/tests/e2e/hookless_install_stages_own_archive_test.sh b/tests/e2e/hookless_install_stages_own_archive_test.sh index 8d1ac9ea2..1315e2b63 100755 --- a/tests/e2e/hookless_install_stages_own_archive_test.sh +++ b/tests/e2e/hookless_install_stages_own_archive_test.sh @@ -185,12 +185,13 @@ MULTI_DIR="$HOME_DIR/data/xpkgs/xim-x-hookless-multi-fixture/1.0.0" # Entry set equals the archive's own top-level entry set: {bin, lib, # README.txt}, nothing else -- except xlings's own bookkeeping -# (.xpkg-install.json, the platform stamp; .xpkg.lua, the recipe +# (.xpkg-install.json, the platform stamp; .xlings-resolution.json, the +# resolved dependency closure; .xpkg.lua, the recipe # snapshot), which every install writes regardless of this fix and is not # part of "the archive's entries". Checked as a count plus membership # (not string equality against a fixed ordering) because `sort`'s # collation of "README.txt" against "bin"/"lib" is locale-dependent. -entry_count="$(ls -A "$MULTI_DIR" | grep -v -E '^\.xpkg' | wc -l | tr -d ' ')" +entry_count="$(ls -A "$MULTI_DIR" | grep -v -E '^(\.xpkg-install\.json|\.xpkg\.lua|\.xlings-resolution\.json)$' | wc -l | tr -d ' ')" [[ "$entry_count" == "3" ]] \ || fail "hookless-multi-fixture install_dir has $entry_count non-bookkeeping entries, expected exactly 3 (bin, lib, README.txt); got: $(ls -A "$MULTI_DIR")" [[ -f "$MULTI_DIR/bin/tool" ]] || fail "hookless-multi-fixture: bin/tool missing" @@ -229,7 +230,7 @@ SINGLE_DIR="$HOME_DIR/data/xpkgs/xim-x-hookless-single-fixture/3.0.0" [[ ! -e "$SINGLE_DIR/bin" ]] \ || fail "LAYOUT REGRESSION: install_dir/bin exists directly -- the top-level directory was stripped" -single_entry_count="$(ls -A "$SINGLE_DIR" | grep -v -E '^\.xpkg' | wc -l | tr -d ' ')" +single_entry_count="$(ls -A "$SINGLE_DIR" | grep -v -E '^(\.xpkg-install\.json|\.xpkg\.lua|\.xlings-resolution\.json)$' | wc -l | tr -d ' ')" [[ "$single_entry_count" == "1" ]] \ || fail "hookless-single-fixture install_dir has $single_entry_count non-bookkeeping entries, expected exactly 1 (hookless-single-3.0.0); got: $(ls -A "$SINGLE_DIR")" diff --git a/tests/e2e/isolation_doctor_fix_test.sh b/tests/e2e/isolation_doctor_fix_test.sh new file mode 100755 index 000000000..7d2129b5f --- /dev/null +++ b/tests/e2e/isolation_doctor_fix_test.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# self doctor --isolation --fix on a stock Ubuntu 24.04 (#640's comment). +# +# CI-only, on a throwaway runner: it installs /usr/lib/xlings/bwrap and an +# AppArmor profile with sudo. The kernel's user-namespace restriction is left +# exactly as the image ships it -- the whole point is that the repair works +# WITHOUT turning it off. +# +# 1. the payload bwrap (`xlings install bwrap`) cannot make a sandbox: it is +# unconfined, and AppArmor denies it user namespaces; +# 2. the doctor says so, names the restriction, and does not advise sysctl; +# 3. --fix -y installs the root-owned copy and its profile; +# 4. a sandbox now enters, through /usr/lib/xlings/bwrap. +# xtest: covers=F12,DOC-ISOLATION requires=linux,xlings-bin,sudo,network +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/project_test_lib.sh" + +[[ "$(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns 2>/dev/null)" == 1 ]] \ + || fail "this CI-only test needs AppArmor-restricted user namespaces" +sudo -n true || fail "needs non-interactive sudo" + +BIN="$(find_xlings_bin)" +RUNTIME_DIR="$(runtime_home_dir isolation_doctor_fix)" +export XLINGS_HOME="$RUNTIME_DIR/home" +rm -rf "$RUNTIME_DIR"; mkdir -p "$XLINGS_HOME" +X() { ( cd /tmp && "$BIN" "$@" ); } + +X self init >/dev/null +X install -y bwrap >/dev/null +X subos new box >/dev/null +# The recipe makes its bwrap setuid when sudo answers without a password -- +# true on a CI runner, not on a user's machine. Leave it as a user has it. +payload="$(find "$XLINGS_HOME/data/xpkgs/xim-x-bwrap" -path '*/bin/bwrap' -type f | head -1)" +[[ -n "$payload" ]] || fail "no payload bwrap" +sudo chown "$(id -u):$(id -g)" "$payload" +chmod 0755 "$payload" + +log "1. before: the doctor reports the restriction and no usable bwrap" +set +e +before="$(X self doctor --isolation 2>&1)"; rc=$? +set -e +printf '%s\n' "$before" | sed 's/^/ | /' +[[ $rc -ne 0 ]] || fail "the doctor reported a usable bwrap before the fix" +grep -q 'apparmor_restrict_unprivileged_userns *1' <<<"$before" || fail "restriction not named" +grep -q 'self doctor --isolation --fix' <<<"$before" || fail "the fix was not offered" +! grep -q 'sysctl -w' <<<"$before" || fail "a global sysctl was advised" + +log "1b. before: entering falls back to proot, never silently (F12)" +set +e +entry="$(X subos exec box --sandbox -- true 2>&1)"; rc=$? +set -e +printf '%s\n' "$entry" | sed 's/^/ | /' +if [[ $rc -eq 0 ]]; then + # proot made it in: the user is told what they got and how to get more. + grep -q 'not a security boundary' <<<"$entry" || fail "the proot fallback was silent" +fi +grep -q 'uid map' <<<"$entry" || fail "bwrap's own words were not quoted" +grep -q 'self doctor --isolation --fix' <<<"$entry" || fail "the fix was not offered" +! grep -q 'sysctl -w' <<<"$entry" || fail "a global sysctl was advised" + +log "2. --fix -y" +X self doctor --isolation --fix -y + +log "3. after: a sandbox enters through the root-owned bwrap" +[[ "$(stat -c '%u %a' /usr/lib/xlings/bwrap)" == "0 755" ]] || fail "not root-owned 0755" +out="$(X subos exec box --sandbox -- /bin/sh -c 'echo inside=$(ls /proc | grep -c "^[0-9]*$")')" +grep -q 'inside=' <<<"$out" || fail "no sandbox: $out" +X self doctor --isolation --json | grep -q '"source":"root-owned"' || fail "not the root-owned bwrap" +log "PASS: self doctor --isolation --fix makes sandboxes work with the restriction left on" diff --git a/tests/e2e/local_query_no_index_sync_test.sh b/tests/e2e/local_query_no_index_sync_test.sh index d270fff59..19062447f 100755 --- a/tests/e2e/local_query_no_index_sync_test.sh +++ b/tests/e2e/local_query_no_index_sync_test.sh @@ -128,7 +128,9 @@ run_local "interface list_packages" '"kind":"result"' \ # On Linux the implicit sandbox-backend decision consults the catalog before # it considers acquisition. Both local fixtures exclude this host, so the # lookup must return the causal local diagnostic without reaching install. -if [[ "$(uname -s)" == Linux ]]; then +if [[ "$(uname -s)" == Linux ]] && host_has_usable_bwrap; then + log "skip the sandbox backend lookup: this host's own bwrap works, nothing is fetched" +elif [[ "$(uname -s)" == Linux ]]; then log "sandbox backend lookup" run_local "sandbox backend lookup" \ 'E_UNSUPPORTED_TARGET.*no sandbox backend' \ diff --git a/tests/e2e/luban_init_test.sh b/tests/e2e/luban_init_test.sh new file mode 100755 index 000000000..b508429f9 --- /dev/null +++ b/tests/e2e/luban_init_test.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# luban-init (SubOS design part 3 §8): stage-0 as its own binary, shipped in +# the Linux release, static, linking nothing of the frontend -- and refusing to +# run as anything but a machine's first process. +# xtest: covers=LUBAN-INIT requires=linux +set -euo pipefail +tarball="${1:?usage: luban_init_test.sh }" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +tar -xzf "$tarball" -C "$work" +init="$(find "$work" -path '*/bin/luban-init' -type f | head -1)" +[[ -x "$init" ]] || { echo "FAIL: the release ships no bin/luban-init"; exit 1; } +if command -v file >/dev/null 2>&1; then + file "$init" | grep -qi 'statically linked' || { echo "FAIL: luban-init is not static: $(file "$init")"; exit 1; } +fi +xl="$(dirname "$init")/xlings" +size_init=$(wc -c < "$init"); size_xl=$(wc -c < "$xl") +# Its own package: a fraction of the client, not a copy of it. +(( size_init * 2 < size_xl )) || { echo "FAIL: luban-init ($size_init) is not much smaller than xlings ($size_xl) -- does it link the frontend?"; exit 1; } +set +e +out="$("$init" 2>&1)"; rc=$? +set -e +[[ $rc -ne 0 ]] || { echo "FAIL: luban-init ran as a non-PID-1 process"; exit 1; } +[[ "$out" == *"luban-init:"*"not PID 1"* ]] || { echo "FAIL: unexpected refusal: $out"; exit 1; } +echo "PASS: luban-init shipped, static, ${size_init} bytes (xlings ${size_xl}); refuses outside PID 1" diff --git a/tests/e2e/project_test_lib.sh b/tests/e2e/project_test_lib.sh index d6a52fd45..c71711345 100644 --- a/tests/e2e/project_test_lib.sh +++ b/tests/e2e/project_test_lib.sh @@ -238,3 +238,14 @@ assert_contains() { local message="$3" echo "$haystack" | strip_ansi | grep -F "$needle" >/dev/null || fail "$message" } + +# A bwrap on this host that can make a sandbox: root-owned, then the system's +# (what entry tries before an index payload, design §20). With one, no +# backend has to be fetched and a "no backend" scenario does not arise. +host_has_usable_bwrap() { + local b + for b in /usr/lib/xlings/bwrap /usr/bin/bwrap /usr/local/bin/bwrap; do + [[ -x "$b" ]] && "$b" --ro-bind / / -- /bin/true >/dev/null 2>&1 && return 0 + done + return 1 +} diff --git a/tests/e2e/remove_self_guard_test.sh b/tests/e2e/remove_self_guard_test.sh index fc04d4530..e5b342d62 100755 --- a/tests/e2e/remove_self_guard_test.sh +++ b/tests/e2e/remove_self_guard_test.sh @@ -10,9 +10,8 @@ # case, which is the only one that hits the broken "no surviving # versions" branch in the installer. # -# Fixture: a fake xpkg called "xlings" (two versions, no URL) that just -# drops a stub file and registers via xvm.add. We never actually replace -# the bootstrap — we only exercise the remove-side decision logic. +# Fixture: two local registrations using the actual candidate binary. +# Commands use XLINGS_BIN throughout, including after entry activation. set -euo pipefail @@ -84,8 +83,8 @@ printf 'xim_indexrepos = {}\n' > "$LOCAL_INDEX_DIR/xim-indexrepos.lua" rm -f "$LOCAL_INDEX_DIR/.xlings-index-cache.json" mkdir -p "$(dirname "$FIXTURE_PKG")" -# Override pkgs/x/xlings.lua with a fixture that just drops a stub and -# registers via xvm.add — no real binary, no self-replace, no downloads. +# Override the recipe with a local candidate copy and no downloads. +cp "$XLINGS_BIN" "$(dirname "$FIXTURE_PKG")/candidate-xlings" cat > "$FIXTURE_PKG" <<'LUA' package = { spec = "1", @@ -107,13 +106,13 @@ package = { import("xim.libxpkg.pkginfo") import("xim.libxpkg.xvm") +import("xim.libxpkg.system") function install() local bindir = path.join(pkginfo.install_dir(), "bin") os.tryrm(pkginfo.install_dir()) os.mkdir(bindir) - io.writefile(path.join(bindir, "xlings"), - "#!/bin/sh\necho 'fixture xlings " .. pkginfo.version() .. "'\n") + os.cp(path.join(system.xpkgdir(), "candidate-xlings"), path.join(bindir, "xlings")) return true end @@ -130,7 +129,7 @@ LUA # Seed XLINGS_HOME pointing at our private (neutralised) index. mkdir -p "$HOME_DIR/subos/default/bin" "$HOME_DIR/bin" -cp "$XLINGS_BIN" "$HOME_DIR/xlings" +cp "$XLINGS_BIN" "$HOME_DIR/bin/xlings" cat > "$HOME_DIR/.xlings.json" < "$HOME_DIR/data/xim-index-repos/xim-indexrepos.json" # ── Scenario 1: install one version, try to remove it → guard refuses ─── log "scenario 1: single-version remove must be refused" -RUN install "xim:xlings@1.0.0" -y >/dev/null 2>&1 \ - || fail "install xim:xlings@1.0.0 failed" +RUN install "xim:xlings@1.0.0" -y > "$RUNTIME_DIR/install.log" 2>&1 \ + || { cat "$RUNTIME_DIR/install.log"; fail "install xim:xlings@1.0.0 failed"; } [[ "$(xlings_versions)" == "1.0.0" ]] \ || fail "post-install: expected versions=1.0.0, got '$(xlings_versions)'" @@ -177,8 +176,8 @@ log " ok: refused with rc=2, version DB intact" # ── Scenario 2: install a second version, multi-version remove succeeds ─ log "scenario 2: multi-version remove must auto-switch (existing semantics)" -RUN install "xim:xlings@2.0.0" -y >/dev/null 2>&1 \ - || fail "install xim:xlings@2.0.0 failed" +RUN install "xim:xlings@2.0.0" -y > "$RUNTIME_DIR/install.log" 2>&1 \ + || { cat "$RUNTIME_DIR/install.log"; fail "install xim:xlings@2.0.0 failed"; } RUN use xlings 2.0.0 >/dev/null 2>&1 \ || fail "use xlings 2.0.0 failed" diff --git a/tests/e2e/rootfs_boot_test.sh b/tests/e2e/rootfs_boot_test.sh new file mode 100644 index 000000000..2908f8e9c --- /dev/null +++ b/tests/e2e/rootfs_boot_test.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# A root booted by a kernel (design part 2 §8): stage-0, the boot entries, +# and one declaration giving the same tree wherever it is presented. +# +# The disk is `subos export --rootfs` plus two SubOS of its own (copies of +# its default: `trial`, and `broken`, whose generation lost its init), made +# into ext4 as root inside a user namespace. The kernel is the root's own +# (/usr/lib/modules//vmlinuz, from xim:linux-kernel). A state file in +# /root carries the scenario across boots; /etc/rc.local (machine state) +# prints what each boot was and powers off. +# +# boot 1: the trial is `broken` -- stage-0 skips it (no init), drops the +# trial, boots `default`; then `subos boot trial --once`; +# boot 2: `trial`, once; inside it, `subos boot default --now`: init +# re-execs stage-0 and user space is `default` without a new kernel; +# boot 3: not confirmed, the trial is gone: `default` again. +# And the tree of /usr is the same in the bwrap instance, the container and +# the booted machine. +# +# xtest: covers=BOOT-STAGE0,BOOT-ONCE-FALLBACK,BOOT-NOW,EXPORT-DISK,ROOT-SAME-TREE,LUBAN-TINY,PERF-STAGE0 requires=linux,qemu,bwrap,docker,network +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/project_test_lib.sh" +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/rootfs_lib.sh" + +BIN="$(find_xlings_bin)" +RUNTIME_DIR="$(runtime_home_dir rootfs_boot)" +rm -rf "$RUNTIME_DIR"; mkdir -p "$RUNTIME_DIR" +IMG=xlings-e2e-luban-boot +trap 'docker rmi -f "$IMG" >/dev/null 2>&1 || true' EXIT + +rootfs_home "$RUNTIME_DIR/home" +H="$RH" +rootfs_new tiny subos:luban-tiny "$RUNTIME_DIR/new.log" || fail "subos new --from subos:luban-tiny" +S="$H/subos/tiny" +KERNEL="$(ls "$S"/root/usr/lib/modules/*/vmlinuz | head -1)" +[[ -f "$KERNEL" ]] || fail "the root has no kernel" + +log "the tree, in a bwrap instance" +tree_instance="$(X subos exec tiny -- /bin/sh -c "$tree_lines_script" | md5sum | cut -d' ' -f1)" + +cat > "$S/rootfs/etc/rc.local" <<'EOF' +#!/bin/sh +# The scenario's driver: what this boot is, then the next step. +state=$(cat /root/.stage 2>/dev/null || echo 0) +boot=$(xlings subos boot 2>/dev/null | sed -n 's/^this boot \([^ ]*\) via \([^ ,]*\).*/\1:\2/p') +tree=$(cd /usr && for f in bin/* lib/*; do printf "%s %s\n" "$f" "$(readlink "$f")"; done \ + | sed -E "s|/subos/[^/]+/|/subos/*/|g" | md5sum | cut -d' ' -f1) +elapsed=$(grep '"event":"boot"' "$XLINGS_HOME/logs/boot.ndjson" | tail -1 | sed -n 's/.*"stage0_elapsed_us":\([0-9][0-9]*\).*/\1/p') +echo "LUBAN-BOOT stage=$state boot=$boot tree=$tree stage0_us=$elapsed" +case "$state" in + 0) echo 1 > /root/.stage; xlings subos boot trial --once >/dev/null 2>&1 ;; + 1) echo 2 > /root/.stage; sync + # After this script (and init's sysinit) has finished: init then takes + # the signal, re-execs stage-0, and the next user space is default's. + ( sleep 2; xlings subos boot default --now ) /dev/console 2>&1 & + exit 0 ;; + 2) echo 3 > /root/.stage ;; + *) ;; +esac +sync +poweroff -f +EOF +chmod +x "$S/rootfs/etc/rc.local" + +log "export --rootfs, two more SubOS, ext4" +X subos export tiny --rootfs "$RUNTIME_DIR/root" >"$RUNTIME_DIR/export.log" 2>&1 \ + || { cat "$RUNTIME_DIR/export.log"; fail "export --rootfs"; } +R="$RUNTIME_DIR/root" +IH="$R$H" +for n in trial broken; do + cp -a "$IH/subos/default" "$IH/subos/$n" + mkdir -p "$IH/config/subos/$n" + cp "$IH/config/subos/default/instance.json" "$IH/config/subos/$n/" + # Its own generation: the copied pointer names root.gen inside the copy. +done +gen="$(readlink "$IH/subos/broken/root")" +rm -f "$IH/subos/broken/$gen/usr/bin/init" +# boot 1's trial: a SubOS with no init. +printf '{"default":"default","fallback":"default","once":"broken","tries":{}}\n' > "$IH/boot.json" +X subos export tiny --disk "$RUNTIME_DIR/probe.img" >/dev/null 2>&1 || true # (exercises --disk itself) +[[ -s "$RUNTIME_DIR/probe.img" ]] || fail "export --disk wrote nothing" +bwrap --unshare-user --uid 0 --gid 0 --dev-bind / / -- \ + "$(command -v mkfs.ext4 || ls /usr/sbin/mkfs.ext4 /sbin/mkfs.ext4 2>/dev/null | head -1)" -q -F -L luban -d "$R" "$RUNTIME_DIR/disk.img" 1G \ + || fail "mkfs.ext4" + +qemu_boot() { + local accel=() + [[ -w /dev/kvm ]] && accel=(-enable-kvm -cpu host) + timeout 600 qemu-system-x86_64 "${accel[@]}" -m 1024 -smp 2 -nographic -no-reboot -nic none \ + -kernel "$KERNEL" -drive "file=$RUNTIME_DIR/disk.img,format=raw,if=virtio" \ + -append "root=/dev/vda rw console=ttyS0 panic=-1 init=$H/boot/xlings-init" \ + > "$RUNTIME_DIR/boot-$1.log" 2>&1 || true + tr -d '\r' < "$RUNTIME_DIR/boot-$1.log" | grep -E "xlings-init:|LUBAN-BOOT" || true +} + +log "boot 1: a trial with no init" +b1="$(qemu_boot 1)"; echo "$b1" | sed 's/^/ | /' +grep -q "'broken' has no init" <<<"$b1" || fail "boot 1: stage-0 did not skip the broken trial" +grep -q "LUBAN-BOOT stage=0 boot=default:default" <<<"$b1" || fail "boot 1: default was not booted" +tree_vm="$(sed -n 's/.*LUBAN-BOOT stage=0 .* tree=\([0-9a-f]*\).*/\1/p' <<<"$b1" | head -1)" + +log "boot 2: the trial, once; then --now back to default" +b2="$(qemu_boot 2)"; echo "$b2" | sed 's/^/ | /' +grep -q "LUBAN-BOOT stage=1 boot=trial:once" <<<"$b2" || fail "boot 2: the trial did not boot once" +grep -q "LUBAN-BOOT stage=2 boot=default:once" <<<"$b2" || fail "boot 2: --now did not hand / to default" +[[ "$(grep -c 'Linux version' "$RUNTIME_DIR/boot-2.log")" -le 1 ]] || fail "boot 2: --now restarted the kernel" + +log "boot 3: the trial was not made the default" +b3="$(qemu_boot 3)"; echo "$b3" | sed 's/^/ | /' +grep -q "LUBAN-BOOT stage=3 boot=default:default" <<<"$b3" || fail "boot 3: not default" + +log "stage-0 budget: startup through preparing exec init <= 200 ms" +samples="$(printf '%s\n' "$b1" "$b2" "$b3" | sed -n 's/.*LUBAN-BOOT .* stage0_us=\([0-9][0-9]*\).*/\1/p')" +[[ "$(wc -l <<<"$samples")" -eq 4 ]] || fail "every boot, including --now, must report stage-0 timing" +while IFS= read -r elapsed; do + [[ "$elapsed" =~ ^[0-9]+$ && "$elapsed" -le 200000 ]] \ + || fail "stage-0 exceeded 200 ms: ${elapsed} us" +done <<<"$samples" + +log "the same tree: instance, container, machine" +X subos export tiny --tar "$RUNTIME_DIR/tiny.tar.gz" >/dev/null 2>&1 || fail "export --tar" +docker import "$RUNTIME_DIR/tiny.tar.gz" "$IMG" >/dev/null +tree_container="$(docker run --rm --network none "$IMG" /bin/sh -c "$tree_lines_script" | md5sum | cut -d' ' -f1)" +log " instance $tree_instance container $tree_container machine $tree_vm" +[[ -n "$tree_vm" && "$tree_instance" == "$tree_container" && "$tree_container" == "$tree_vm" ]] \ + || fail "one declaration, three trees" + +log "PASS: the machine boots the SubOS boot.json chooses, and switches without a reboot" diff --git a/tests/e2e/rootfs_image_test.sh b/tests/e2e/rootfs_image_test.sh new file mode 100644 index 000000000..edc83c0a1 --- /dev/null +++ b/tests/e2e/rootfs_image_test.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# A root exported as an image and run as a machine's whole userland (design +# part 2 §5, §6, §11): deployment R in a container, where xlings is the only +# package manager there is. +# +# 1. `subos export --tar`, `docker import`: the image's /usr is the +# projection, its xlings the static build, its glibc ours; +# 2. inside, xlings installs with the root's own tools (busybox, patchelf): +# nothing of a host under it; +# 3. a host-built program (PT_INTERP /lib64/ld-linux-x86-64.so.2) finds its +# libraries through /lib64 and the logical-root loader; +# 4. a half-written /usr (libc gone from the current generation): programs +# fail, the static xlings rolls the root back, they run again; +# 5. no patchelf and no way to get one: an install fails and says why, +# instead of reporting a payload it could not relocate as installed; +# 6. both layouts: single (the builder's home path) and multi (/xlings, +# built in an owner-private namespace), each recorded in the image and running; +# 7. self update in deployment R is a new generation: /usr/bin/xlings +# moves from a published payload to the candidate release, and rollback +# restores the old version without replacing the static stage0 entry. +# +# xtest: covers=EXPORT-OCI,DOM-BOOTSTRAP,ROOT-LIBSEARCH,ROOT-SURVIVES-GLIBC,DOM-ELFPATCH-FAILCLOSED,DOM-LAYOUTS,DOM-BUILD-INSIDE,ROOT-SELF-UPDATE requires=linux,docker,sudo,network +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/project_test_lib.sh" +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/rootfs_lib.sh" + +BIN="$(find_xlings_bin)" +RUNTIME_DIR="$(runtime_home_dir rootfs_image)" +rm -rf "$RUNTIME_DIR"; mkdir -p "$RUNTIME_DIR" +IMG=xlings-e2e-luban +cleanup() { + if [[ -n "${ROOTFS_HTTP_PID:-}" ]]; then + kill "$ROOTFS_HTTP_PID" 2>/dev/null || true + wait "$ROOTFS_HTTP_PID" 2>/dev/null || true + fi + docker rm -f warm >/dev/null 2>&1 || true + docker rmi -f "$IMG" "$IMG-warm" "$IMG-multi" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +rootfs_home "$RUNTIME_DIR/home" +H="$RH" +rootfs_new tiny subos:luban-tiny "$RUNTIME_DIR/new.log" || fail "subos new --from subos:luban-tiny" + +log "1. export --tar, docker import" +X subos export tiny --tar "$RUNTIME_DIR/tiny.tar.gz" >"$RUNTIME_DIR/export.log" 2>&1 \ + || { cat "$RUNTIME_DIR/export.log"; fail "export --tar"; } +docker import "$RUNTIME_DIR/tiny.tar.gz" "$IMG" >/dev/null +D() { docker run --rm "$@"; } +out="$(D --network none "$IMG" /bin/sh -c '. /etc/os-release; echo "os=$ID/$VARIANT_ID"; readlink /usr; xlings --version; getconf GNU_LIBC_VERSION; cat /etc/xlings/root.json' 2>&1)" \ + || fail "the image does not run: $out" +grep -q 'os=luban/tiny' <<<"$out" || fail "not luban tiny: $out" +grep -q "$H/subos/default/root/usr" <<<"$out" || fail "/usr is not the default SubOS's projection: $out" +grep -q "^xlings " <<<"$out" || fail "no xlings in the image: $out" +grep -q 'glibc 2.44' <<<"$out" || fail "not the xlings glibc: $out" + +log "2. xlings installs inside, with the root's own tools" +out="$(D "$IMG" /bin/sh -c 'xlings install -y xz 2>&1 | tail -3; xz --version | head -1; readlink -f /usr/bin/xz' 2>&1)" \ + || fail "install inside the image: $out" +grep -q 'XZ Utils' <<<"$out" || fail "xz did not install or run: $out" +grep -q "$H/data/xpkgs/xim-x-xz/" <<<"$out" || fail "xz is not linked from its payload: $out" + +log "3. a host-built program in the root" +cp /usr/bin/true "$RUNTIME_DIR/host-true" +out="$(D -v "$RUNTIME_DIR/host-true:/root/host-true:ro" "$IMG" /bin/sh -c \ + '/root/host-true && echo host-binary-ran' 2>&1)" || true +grep -q host-binary-ran <<<"$out" || fail "a host-built binary did not run against /lib64: $out" + +log "4. a half-written /usr, rolled back by the static xlings" +# A newer generation first, so there is a good one to go back to. +out="$(D -e H="$H" -v "$RUNTIME_DIR/host-true:/root/host-true:ro" "$IMG" /bin/sh -c ' + xlings install -y zlib >/dev/null 2>&1 || exit 1 + rm -f "$H/subos/default/root/usr/lib/libc.so.6" /etc/ld.so.cache + /root/host-true >/dev/null 2>&1 && echo still-works || echo broken + xlings subos rollback default && /root/host-true >/dev/null 2>&1 && echo restored' 2>&1)" || true +grep -q broken <<<"$out" || fail "removing libc from /usr broke nothing: $out" +grep -q restored <<<"$out" || fail "rollback did not restore the root: $out" + +log "5. no patchelf, none to fetch" +docker run --name warm "$IMG" /bin/sh -c 'xlings install -y zlib >/dev/null 2>&1 && xlings remove -y zlib >/dev/null 2>&1' \ + || fail "warming the download cache" +docker commit warm "$IMG-warm" >/dev/null +out="$(D --network none "$IMG-warm" /bin/sh -c 'xlings remove -y patchelf >/dev/null 2>&1; xlings install -y zlib; echo rc=$?' 2>&1)" || true +grep -q 'rc=0' <<<"$out" && fail "zlib reported installed without a patchelf to relocate it: $out" +grep -q 'patchelf' <<<"$out" || fail "the failure does not name patchelf: $out" + +log "6. the multi layout: the system home at /xlings" +rootfs_home "$RUNTIME_DIR/domain-owner" +X subos new luban --rootfs --domain /xlings --from subos:luban-tiny >"$RUNTIME_DIR/new-multi.log" 2>&1 \ + || { cat "$RUNTIME_DIR/new-multi.log"; fail "multi: namespace producer"; } +X subos export luban --tar "$RUNTIME_DIR/multi.tar.gz" >/dev/null 2>&1 || fail "multi: export" +docker import "$RUNTIME_DIR/multi.tar.gz" "$IMG-multi" >/dev/null +out="$(D --network none "$IMG-multi" /bin/sh -c 'cat /etc/xlings/root.json /xlings/.xlings-home; readlink /usr; xz --version 2>/dev/null; ls /usr/bin/sh' 2>&1)" \ + || fail "multi image: $out" +grep -q '"home": "/xlings"' <<<"$out" || fail "multi: root.json: $out" +grep -q '"root_layout": "multi"' <<<"$out" || fail "multi: marker: $out" +grep -q '^/xlings/subos/default/root/usr' <<<"$out" || fail "multi: /usr: $out" +grep -q '"root_layout": "single"' "$H/.xlings-home" 2>/dev/null && fail "the builder's home was marked" +marker="$(tar -xzOf "$RUNTIME_DIR/tiny.tar.gz" ".${H}/.xlings-home" 2>/dev/null || true)" +grep -q '"root_layout": "single"' <<<"$marker" || fail "single: the first image is not marked single" + +log "7. self update is a generation" +rootfs_update_fixture "$RUNTIME_DIR/self-update" "$H" +# The candidate stage0 owns the new root update semantics. The published old +# payload is a real /usr consumer; it is not asked to implement this PR's layout. +out="$(D --network host -e H="$H" -e OLD_VERSION="$ROOTFS_OLD_VERSION" \ + -e CANDIDATE_VERSION="$ROOTFS_CANDIDATE_VERSION" \ + -v "$RUNTIME_DIR/self-update:/root/update-fixture:ro" "$IMG" /bin/sh -ec ' + export XLINGS_HOME="$H" PATH="$H/bin:/usr/bin:/bin" + entry="$H/bin/xlings" + stage0_hash=$(sha256sum "$entry") + stage0_inode=$(stat -Lc "%d:%i" "$entry") + "$entry" config --index-repo xim:/root/update-fixture/index >/tmp/index.log 2>&1 \ + || { cat /tmp/index.log; exit 1; } + "$entry" update >/tmp/index.log 2>&1 || { cat /tmp/index.log; exit 1; } + "$entry" install "xim:xlings@$OLD_VERSION" -y --use >/tmp/old.log 2>&1 \ + || { cat /tmp/old.log; exit 1; } + before=$(readlink -f /usr/bin/xlings) + before_gen=$(readlink "$H/subos/default/root") + before_version=$(/usr/bin/xlings --version) + echo "before=$before"; echo "before_gen=$before_gen"; echo "before_version=$before_version" + test "$before_version" = "xlings $OLD_VERSION" + test "$before" = "$H/data/xpkgs/xim-x-xlings/$OLD_VERSION/bin/xlings" + "$entry" self update >/tmp/u.log 2>&1 || { cat /tmp/u.log; exit 1; } + after=$(readlink -f /usr/bin/xlings) + after_gen=$(readlink "$H/subos/default/root") + after_version=$(/usr/bin/xlings --version) + echo "after=$after"; echo "after_gen=$after_gen"; echo "after_version=$after_version" + test "$after_version" = "xlings $CANDIDATE_VERSION" + test "$after" = "$H/data/xpkgs/xim-x-xlings/$CANDIDATE_VERSION/bin/xlings" + test "$after_gen" != "$before_gen" + test "$(sha256sum "$entry")" = "$stage0_hash" + test "$(stat -Lc "%d:%i" "$entry")" = "$stage0_inode" + "$entry" subos rollback default --to "${before_gen##*/}" >/tmp/rollback.log 2>&1 \ + || { cat /tmp/rollback.log; exit 1; } + rolled=$(readlink -f /usr/bin/xlings) + rolled_gen=$(readlink "$H/subos/default/root") + rolled_version=$(/usr/bin/xlings --version) + echo "rolled=$rolled"; echo "rolled_gen=$rolled_gen"; echo "rolled_version=$rolled_version" + test "$rolled" = "$before" + test "$rolled_gen" = "$before_gen" + test "$rolled_version" = "$before_version" + test "$(sha256sum "$entry")" = "$stage0_hash" + test "$(stat -Lc "%d:%i" "$entry")" = "$stage0_inode" + ' 2>&1)" || fail "self update or rollback failed: $out" +cat <<<"$out" +grep -q 'GET /xlings-.*\.tar\.gz HTTP/1\.[01]" 200' "$RUNTIME_DIR/self-update/http.log" \ + || fail "the candidate was not transferred through the actual HTTP downloader" + +log "PASS: an exported root is a machine whose only package manager is xlings" diff --git a/tests/e2e/rootfs_instance_test.sh b/tests/e2e/rootfs_instance_test.sh new file mode 100644 index 000000000..9c7985e25 --- /dev/null +++ b/tests/e2e/rootfs_instance_test.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash +# A SubOS presented as / (design part 2 §3, §6): Luban roots made from the +# published editions, entered in bwrap, changed from inside, rolled back. +# +# 1. luban-tiny: the projection's shape (merged-usr, /usr at the current +# generation, links straight into payloads), its kernel, its /etc; +# 2. entered: uid 0, the root's own userland and nothing of the host's; +# 3. an install from inside goes through the broker and is in /usr at once; +# rollback moves the pointer back; +# 4. the role table at the command line: a boot entry is not removed, a +# view has no root to boot, export or roll back; +# 5. luban-core, `from` tiny: the chain merged (upper wins), GNU tools over +# busybox, a C program compiled and run inside, mapping nothing of the host; +# 6. every form-X executable of core resolves its closure in an empty root, +# and the gcc shim (an alias) runs with no /bin/sh at all; +# 7. what is searched (interpreters, run paths, shebangs) names only this +# home -- no build machine; +# 8. fetch = layer: a root's own scope; a view refuses it; +# 9. luban-desktop, `from` core: a GL program compiled inside renders a +# frame offscreen (llvmpipe), every GL object from the payloads. +# +# xtest: covers=ROOT-PROJECT,INST-ROOTFS,ISO-ROOTFS,ROOT-NO-HOST,ROOT-ROLE-TABLE,ROOT-ROLLBACK,LUBAN-TINY,LUBAN-CORE,LUBAN-FROM-CHAIN,ECO-HOST-INDEPENDENT,SHIM-NO-SHELL,DOM-PREFIX,PERM-FETCH-LAYER,ROOT-ETC-FACTORY,ROOT-LIBSEARCH,LUBAN-DESKTOP-RENDER requires=linux,bwrap,network proves=isolation +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/project_test_lib.sh" +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/rootfs_lib.sh" + +BIN="$(find_xlings_bin)" +RUNTIME_DIR="$(runtime_home_dir rootfs_instance)" +rm -rf "$RUNTIME_DIR"; mkdir -p "$RUNTIME_DIR" +rootfs_home "$RUNTIME_DIR/home" +H="$RH" + +log "1. luban-tiny" +rootfs_new tiny subos:luban-tiny "$RUNTIME_DIR/new-tiny.log" || fail "subos new --rootfs --from subos:luban-tiny" +S="$H/subos/tiny" +[[ "$(readlink "$S/rootfs/usr")" == "$S/root/usr" ]] || fail "rootfs/usr is $(readlink "$S/rootfs/usr")" +[[ "$(readlink "$S/root")" == root.gen/* ]] || fail "the pointer is $(readlink "$S/root")" +for l in bin:usr/bin sbin:usr/bin lib:usr/lib lib64:usr/lib64; do + [[ "$(readlink "$S/rootfs/${l%%:*}")" == "${l#*:}" ]] || fail "/${l%%:*} is not -> ${l#*:}" +done +sh_target="$(readlink "$S/root/usr/bin/sh")" +[[ "$sh_target" == "$H/data/xpkgs/xim-x-busybox/"*"/bin/sh" ]] \ + || fail "/usr/bin/sh is not a direct link into busybox: $sh_target" +[[ -e "$S/root/usr/lib/modules/6.8.0-71-generic/vmlinuz" ]] || fail "no kernel at /usr/lib/modules" +[[ -L "$S/rootfs/etc/inittab" && -x "$S/rootfs/etc/init.d/rcS" ]] || fail "factory /etc missing" +grep -q '^ID=luban' "$S/rootfs/etc/os-release" || fail "no luban os-release" +echo "mine" > "$S/rootfs/etc/hostname.local" +grep -q '^root:x:0:0:' "$S/rootfs/etc/passwd" || fail "no root user" +grep -q '^nobody:' "$S/rootfs/etc/passwd" || fail "sysusers did not add nobody" + +log "2. entered as its own root" +out="$(X subos exec tiny -- /bin/sh -c 'id -u; . /etc/os-release; echo "$ID/$VARIANT_ID"; test -e /usr/bin/apt-get && echo HOST-USERLAND; echo done' 2>&1)" \ + || fail "exec failed: $out" +grep -qx 0 <<<"$out" || fail "not uid 0 inside: $out" +grep -qx 'luban/tiny' <<<"$out" || fail "not the root's os-release: $out" +! grep -q HOST-USERLAND <<<"$out" || fail "the host's /usr is visible" +audit="$(X subos log tiny --json 2>/dev/null || true)" +grep -q '"session-start"' <<<"$audit" || fail "no audit for the root" + +log "3. install from inside, then roll back" +before="$(readlink "$S/root")" +out="$(X subos exec tiny -- /bin/sh -c 'xlings install -y make >/dev/null 2>&1; make --version | head -1' 2>&1)" \ + || fail "install inside failed: $out" +grep -q 'GNU Make' <<<"$out" || fail "make not in /usr/bin after installing it inside: $out" +after="$(readlink "$S/root")" +[[ "$after" != "$before" ]] || fail "no new generation" +X subos rollback tiny >/dev/null 2>&1 || fail "rollback failed" +[[ "$(readlink "$S/root")" != "$after" ]] || fail "rollback did not move the pointer" +[[ ! -e "$S/root/usr/bin/make" ]] || fail "make still in the rolled-back generation" +X subos rollback tiny --to "${after##*/}" >/dev/null 2>&1 || fail "rollback --to failed" +[[ -e "$S/root/usr/bin/make" ]] || fail "rollback --to did not bring make back" + +log "4. the role table" +X subos boot tiny >/dev/null 2>&1 || fail "subos boot tiny" +out="$(X subos remove -y tiny 2>&1)" && fail "a boot entry was removed" +grep -q "boot entry" <<<"$out" || fail "the refusal does not say why: $out" +for verb in "rollback default" "boot default" "export default --tar $RUNTIME_DIR/x.tar"; do + out="$(X subos $verb 2>&1)" && fail "subos $verb on a view succeeded" + grep -q "rootfs\|root" <<<"$out" || fail "subos $verb: no reason given: $out" +done + +log "5. luban-core from tiny" +rootfs_new core subos:luban-core "$RUNTIME_DIR/new-core.log" || fail "subos new --from subos:luban-core" +C="$H/subos/core" +grep -q '^VARIANT_ID=core' "$C/rootfs/etc/os-release" || fail "core's os-release did not win" +[[ -e "$C/rootfs/etc/inittab" ]] || fail "tiny's inittab did not come down the chain" +cat > "$C/rootfs/root/hello.c" <<'EOF' +#include +int main(void) { + FILE *m = fopen("/proc/self/maps", "r"); + char line[4096]; + while (m && fgets(line, sizeof line, m)) { + char *p = line; while (*p && *p != '/') ++p; + if (*p) fputs(p, stdout); + } + puts("hello from luban-core"); + return 0; +} +EOF +out="$(X subos exec core -- /bin/sh -c 'ls --version | head -1; bash -c "echo bash-ok"; cd /root && gcc hello.c -o hello && ./hello' 2>&1)" \ + || fail "core: $out" +grep -q 'GNU coreutils' <<<"$out" || fail "ls is not GNU coreutils: $out" +grep -q 'bash-ok' <<<"$out" || fail "no bash: $out" +grep -q 'hello from luban-core' <<<"$out" || fail "the program did not run: $out" +# What it mapped: the root's own files and the home's payloads, nothing else. +foreign="$(grep '^/' <<<"$out" | grep -v "^$H/\|^/root/\|^/usr/" || true)" +[[ -z "$foreign" ]] || fail "mapped from outside the root: $foreign" + +log "5a. root compiler uses the logical loader and the root cache" +# A library outside the projection and payload RUNPATHs can only be found +# through this machine's /etc/ld.so.conf and its generated cache. +mkdir -p "$C/rootfs/opt/cache-probe" +cat > "$C/rootfs/root/cache-library.c" <<'EOF' +int cache_probe_641(void) { return 641; } +EOF +cat > "$C/rootfs/root/cache-main.c" <<'EOF' +#include +extern int cache_probe_641(void); +int main(void) { printf("cache-probe=%d\n", cache_probe_641()); return cache_probe_641() == 641 ? 0 : 1; } +EOF +printf '/opt/cache-probe\n' > "$C/rootfs/etc/ld.so.conf" +out="$(X subos exec core -- /bin/sh -ec ' + cd /root + gcc -shared -fPIC cache-library.c -Wl,-soname,libcache_probe_641.so -o /opt/cache-probe/libcache_probe_641.so + gcc cache-main.c -L/opt/cache-probe -lcache_probe_641 -o cache-main + xlings install -y --reconfig glibc >/tmp/cache-config.log 2>&1 || { cat /tmp/cache-config.log; exit 1; } + env -u LD_LIBRARY_PATH -u LD_PRELOAD ./cache-main' 2>&1)" \ + || fail "logical loader/cache: $out" +grep -qx 'cache-probe=641' <<<"$out" || fail "the root cache did not supply the private library: $out" +interp="$(readelf -lW "$C/rootfs/root/cache-main" | sed -n 's/.*interpreter: \(.*\)]/\1/p')" +case "$(uname -m)" in + aarch64) expected_interp=/lib/ld-linux-aarch64.so.1 ;; + x86_64) expected_interp=/lib64/ld-linux-x86-64.so.2 ;; + *) fail "unsupported root consumer architecture" ;; +esac +[[ "$interp" == "$expected_interp" ]] || fail "root gcc selected $interp instead of $expected_interp" +[[ -s "$C/rootfs/etc/ld.so.cache" ]] || fail "no root ld.so.cache was published" +managed_loader="$(readlink -f "$C/root/usr/lib/$(basename "$expected_interp")")" +out="$(X subos exec core -- "$managed_loader" --list /root/cache-main 2>&1)" && \ + fail "the managed loader unexpectedly searched the root cache: $out" +grep -q 'libcache_probe_641.so' <<<"$out" || fail "managed-loader failure did not identify the private library: $out" + +log "6. closures in an empty root; the gcc shim without a shell" +loader="$(readlink -f "$C/root/usr/lib64/ld-linux-x86-64.so.2")" +checked=0 +while IFS= read -r f; do + interp="$(readelf -lW "$f" 2>/dev/null | sed -n 's/.*interpreter: \(.*\)]/\1/p' || true)" + [[ "$interp" == "$H/"* ]] || continue + res="$(bwrap --tmpfs /tmp --ro-bind "$H" "$H" --proc /proc --dev /dev "$loader" --list "$f" 2>&1)" \ + || fail "$f: $res" + grep -q "not found" <<<"$res" && fail "$f: $res" + outside="$(grep '=>' <<<"$res" | grep -v "=> $H/" || true)" + [[ -z "$outside" ]] || fail "$f resolves outside the home: $outside" + checked=$((checked + 1)) +done < <(for l in "$C"/root/usr/bin/*; do readlink -f "$l"; done | sort -u) +(( checked > 50 )) || fail "only $checked form-X executables checked" +log " $checked executables, every closure inside the home" +printf 'int main(void){return 0;}\n' > "$RUNTIME_DIR/a.c" +# core's own shims, in a root with nothing but the home: no /bin, no /usr. +out="$(bwrap --tmpfs /tmp --bind "$H" "$H" --ro-bind "$RUNTIME_DIR/a.c" /tmp/a.c --proc /proc --dev /dev \ + --clearenv --setenv PATH "$C/bin" --setenv XLINGS_HOME "$H" --setenv XLINGS_ACTIVE_SUBOS core \ + --setenv HOME /tmp "$C/root/usr/bin/busybox" sh -c \ + 'gcc -c /tmp/a.c -o /tmp/a.o && echo shim-ok; ls /bin >/dev/null 2>&1 || echo no-bin' 2>&1)" || true +grep -q shim-ok <<<"$out" || fail "the gcc shim did not compile without /bin/sh: $out" +grep -q no-bin <<<"$out" || fail "the root had a /bin after all: $out" + +log "7. only this home is named where things are looked up" +bad="" +while IFS= read -r f; do + if [[ "$(head -c 4 "$f" 2>/dev/null)" == $'\x7fELF' ]]; then + for p in $(readelf -lW "$f" 2>/dev/null | sed -n 's/.*interpreter: \(.*\)]/\1/p') \ + $(readelf -dW "$f" 2>/dev/null | sed -n 's/.*R[UN]*PATH.*\[\(.*\)\]/\1/p' | tr ':' ' '); do + [[ "$p" == "$H/"* || "$p" == '$ORIGIN'* ]] || bad+="$f: $p"$'\n' + done + elif [[ "$(head -c 2 "$f" 2>/dev/null)" == '#!' ]]; then + interp="$(head -1 "$f" | sed -E 's/^#! *([^ ]+).*/\1/')" + case "$interp" in + "$H/"*|/bin/sh|/bin/bash|/usr/bin/env|/usr/bin/perl|/usr/bin/python3) ;; + *) bad+="$f: shebang $interp"$'\n' ;; + esac + fi +done < <(for l in "$C"/root/usr/bin/*; do readlink -f "$l"; done | sort -u) +[[ -z "$bad" ]] || fail "names outside this home:"$'\n'"$bad" + +log "8. fetch = layer" +X subos config core --fetch layer >/dev/null 2>&1 || fail "fetch=layer refused on a root" +out="$(X subos exec core -- /bin/sh -c 'xlings install -y xz >/dev/null 2>&1; xz --version | head -1' 2>&1)" \ + || fail "install into the layer: $out" +grep -q 'XZ Utils' <<<"$out" || fail "xz not in the root: $out" +X subos new aview >/dev/null 2>&1 || fail "subos new aview" +X subos config aview --fetch layer >/dev/null 2>&1 || fail "subos config --fetch layer" +out="$(X subos exec aview -- true 2>&1)" && fail "a view with fetch=layer was entered" +grep -q "system scope" <<<"$out" || fail "no reason given: $out" + +log "9. luban-desktop renders offscreen" +rootfs_new desk subos:luban-desktop "$RUNTIME_DIR/new-desk.log" || fail "subos new --from subos:luban-desktop" +cp "$ROOT_DIR/tests/e2e/fixtures/glprobe.c" "$H/subos/desk/rootfs/root/glprobe.c" +out="$(X subos exec desk -- /bin/sh -c 'cd /root && gcc glprobe.c -o glprobe -lEGL -lGL && ./glprobe' 2>&1)" \ + || fail "desktop: $out" +grep -q '^RESULT=ok' <<<"$out" || fail "desktop: no frame rendered: $out" +grep -q '^PIXEL=336699' <<<"$out" || fail "desktop: the pixel is not the one asked for: $out" +foreign="$(grep '^LOADED=' <<<"$out" | grep -v "^LOADED=$H/" || true)" +[[ -z "$foreign" ]] || fail "desktop: GL objects from outside the home: $foreign" + +log "PASS: roots are made, entered, changed from inside and rolled back" diff --git a/tests/e2e/rootfs_lib.sh b/tests/e2e/rootfs_lib.sh new file mode 100644 index 000000000..fd9a260cf --- /dev/null +++ b/tests/e2e/rootfs_lib.sh @@ -0,0 +1,132 @@ +# Shared by the root-projection scenarios (SubOS design part 2): a home whose +# entry is the static xlings under test, a Luban root made from the published +# editions, and the checks every scenario makes on what a root contains. +# +# Sourced, not run. Expects project_test_lib.sh first. + +# A home of its own whose entry is $BIN (the release build: static, so an +# exported image's xlings runs with no host under it). +rootfs_home() { + local dir="$1" + # The caller creates a new private test home; never empty an existing home. + [[ ! -e "$dir" ]] || fail "test home already exists: $dir" + mkdir -p "$dir" + mkdir -p "$dir/bin" + cp "$BIN" "$dir/bin/xlings" + # XLINGS_TEST_INDEX: an index to use instead of the published one (a + # local checkout while its PR is open). + if [[ -n "${XLINGS_TEST_INDEX:-}" ]]; then + printf '{"mirror":"%s","index_repos":[{"name":"xim","url":"%s"}]}\n' \ + "${XLINGS_TEST_MIRROR:-GLOBAL}" "$XLINGS_TEST_INDEX" > "$dir/.xlings.json" + else + printf '{"mirror":"%s"}\n' "${XLINGS_TEST_MIRROR:-GLOBAL}" > "$dir/.xlings.json" + fi + RH="$dir" + X self init >/dev/null 2>&1 || fail "self init of $dir failed" +} + +# xlings against $RH, from a neutral directory, with nothing inherited that +# names another home or SubOS. +X() { + ( cd /tmp && env -u XLINGS_ACTIVE_SUBOS -u XLINGS_PROJECT_DIR -u XLINGS_SUBOS_MODE \ + -u XLINGS_BROKER_SOCKET -u XLINGS_SESSION_FD XLINGS_HOME="$RH" \ + "$RH/bin/xlings" "$@" ) +} + +# What a root's /usr holds, as one line per link -- with the SubOS's own name +# taken out (an exported instance is its image's `default`). Every +# presentation of the same declaration must print the same lines. +# $1: a command prefix that runs `sh -c` inside the root +tree_lines_script='cd /usr && for f in bin/* lib/*; do printf "%s %s\n" "$f" "$(readlink "$f")"; done | sed -E "s|/subos/[^/]+/|/subos/*/|g"' + +# Retry what talks to the network: a mirror's bad minute is not the code's. +retry() { + local n=0 + until "$@"; do + n=$((n + 1)) + [[ $n -ge 3 ]] && return 1 + sleep 5 + done +} + +# `subos new --rootfs --from `, retried from scratch: a +# mirror's bad minute leaves a half-made root, which the next try removes. +rootfs_new() { + local name="$1" from="$2" log="$3" n=0 + until X subos new "$name" --rootfs --from "$from" >"$log" 2>&1; do + n=$((n + 1)) + [[ $n -ge 3 ]] && { tail -30 "$log"; return 1; } + X subos remove -y "$name" >/dev/null 2>&1 || true + sleep 5 + done +} + +# Keep the published recipe's install/config hooks. Only the candidate resource +# and its latest ref differ; the binary still goes through download + extraction. +rootfs_update_fixture() { + local dir="$1" home="$2" archive="${ROOTFS_UPDATE_ARCHIVE:-$BUILD_DIR/release.tar.gz}" + local recipe="$home/data/xim-pkgindex/pkgs/x/xlings.lua" package_name digest + [[ -f "$recipe" ]] || fail "the root's official xlings recipe is missing: $recipe" + [[ -f "$archive" ]] || fail "self-update needs the candidate release archive: $archive" + command -v python3 >/dev/null || fail "self-update fixture requires python3 on the host" + ROOTFS_CANDIDATE_VERSION="$("$BIN" --version | awk 'NR == 1 { print $2 }')" + [[ "$ROOTFS_CANDIDATE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] \ + || fail "cannot identify the candidate release version" + package_name="xlings-$ROOTFS_CANDIDATE_VERSION-linux-$(uname -m)" + digest="$(tar -xzOf "$archive" "$package_name/bin/xlings" | sha256sum | awk '{ print $1 }')" \ + || fail "candidate archive does not contain $package_name/bin/xlings" + [[ "$digest" == "$(sha256sum "$BIN" | awk '{ print $1 }')" ]] \ + || fail "candidate archive and XLINGS_BIN differ" + mkdir -p "$dir/index/pkgs/x" + cp "$recipe" "$dir/index/pkgs/x/xlings.lua" + cp "$archive" "$dir/$package_name.tar.gz" + python3 - "$dir" >"$dir/http.log" 2>&1 <<'PY' & +import functools +import http.server +import pathlib +import sys + +root = pathlib.Path(sys.argv[1]) +handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(root)) +with http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) as server: + (root / "port").write_text(str(server.server_port)) + server.serve_forever() +PY + ROOTFS_HTTP_PID=$! + local attempt + for attempt in $(seq 1 100); do + [[ -s "$dir/port" ]] && break + kill -0 "$ROOTFS_HTTP_PID" 2>/dev/null || fail "candidate HTTP server exited" + sleep 0.05 + done + [[ -s "$dir/port" ]] || fail "candidate HTTP server did not become ready" + digest="$(sha256sum "$dir/$package_name.tar.gz" | awk '{ print $1 }')" + ROOTFS_OLD_VERSION="$(python3 - "$dir/index/pkgs/x/xlings.lua" \ + "$ROOTFS_CANDIDATE_VERSION" "$(cat "$dir/port")" "$package_name" "$digest" <<'PY' +import pathlib +import re +import sys + +recipe = pathlib.Path(sys.argv[1]) +candidate, port, package_name, digest = sys.argv[2:] +text = recipe.read_text() +linux = re.search(r'(?m)^([ \t]*)linux\s*=\s*\{', text) +if not linux: + raise SystemExit("fixture needs the official Linux platform table") +next_platform = re.search(r'(?m)^' + re.escape(linux[1]) + r'(?:macosx|windows)\s*=\s*\{', text[linux.end():]) +table = text[linux.end():linux.end() + next_platform.start()] if next_platform else text[linux.end():] +published = set(re.findall(r'\["(\d+\.\d+\.\d+\.\d+)"\]\s*=\s*\{', table)) +number = lambda version: tuple(map(int, version.split('.'))) +older = [version for version in published if number(version) < number(candidate)] +if not older: + raise SystemExit("fixture needs a distinct published predecessor") +latest = re.search(r'\["latest"\]\s*=\s*\{\s*ref\s*=\s*"([^"]+)"', table) +predecessor = latest[1] if latest and latest[1] in older else max(older, key=number) +recipe.write_text(text + '\npackage.xpm.linux["' + candidate + '"] = {\n' + + ' url = "http://127.0.0.1:' + port + '/' + package_name + '.tar.gz",\n' + + ' sha256 = "' + digest + '",\n' + + '}\npackage.xpm.linux["latest"] = { ref = "' + candidate + '" }\n') +print(predecessor) +PY + )" || fail "cannot prepare the official self-update recipe" +} diff --git a/tests/e2e/run_all.sh b/tests/e2e/run_all.sh index daf8cf828..1f95f8b19 100755 --- a/tests/e2e/run_all.sh +++ b/tests/e2e/run_all.sh @@ -352,9 +352,16 @@ ORPHAN_EXEMPT=( # Run by a dedicated workflow. "aarch64_compat_contract_test.sh" # xlings-ci-aarch64.yml "bootstrap_home_test.sh" # linux-e2e / macos / windows - "mcpp_build_xlings_test.sh" # xlings-ci-linux-e2e.yml + "mcpp_build_xlings_test.sh" # xlings-ci-linux.yml (unit-asan, on push) "root_usability_test.sh" # xlings-ci-linux-root.yml - "subos_cmd_contract_test.sh" # windows / macos + "isolation_doctor_fix_test.sh" # xlings-ci-linux.yml (isolation-fix, uses sudo) + "system_install_test.sh" # xlings-ci-linux.yml (isolation-fix, uses sudo) + "aur_package_test.sh" # xlings-ci-linux.yml (arch-package, root in archlinux) + "system_layer_test.sh" # xlings-ci-linux.yml (isolation-fix, uses sudo) + "rootfs_instance_test.sh" # xlings-ci-linux.yml (distro) + "rootfs_image_test.sh" # xlings-ci-linux.yml (distro, docker + sudo) + "rootfs_boot_test.sh" # xlings-ci-linux.yml (distro, qemu) + "luban_init_test.sh" # tests/suites.toml e2e-shell (the release tarball) # Sub-tests invoked by project_e2e_test.sh (E2E-05), not standalone. "project_global_fallback_test.sh" "project_home_test.sh" @@ -407,13 +414,24 @@ for entry in "${TESTS[@]}"; do if [[ $rc -eq 0 ]]; then echo "PASS: $label ($script, $d)" ((PASS++)) + _status=pass elif [[ "$flags" == *allowfail* ]]; then echo "SOFT-FAIL: $label ($script, exit $rc, $d) — tolerated (e.g. network/rate-limit)" ((SOFTFAIL++)) + _status=skip else echo "FAIL: $label ($script, exit $rc, $d)" ((FAIL++)) FAILED_TESTS+=("$label ($script, exit $rc)") + _status=fail + fi + # One record per test for `xdev report` (tests/README.md). Labels and + # script names are [A-Za-z0-9_.-], so they need no JSON escaping. + if [[ -n "${XDEV_RECORDS:-}" ]]; then + printf '{"test":"e2e-shell: %s %s","status":"%s","ms":%d,"exit_code":%d%s}\n' \ + "$label" "$script" "$_status" "$_dur" "$rc" \ + "$([[ $_status == skip ]] && printf ',"message":"soft-fail (exit %d), tolerated"' "$rc")" \ + >> "$XDEV_RECORDS" fi done diff --git a/tests/e2e/subos_cmd_contract_test.ps1 b/tests/e2e/subos_cmd_contract_test.ps1 deleted file mode 100644 index 0b48ed7c4..000000000 --- a/tests/e2e/subos_cmd_contract_test.ps1 +++ /dev/null @@ -1,28 +0,0 @@ -$ErrorActionPreference = "Stop" -$bin = Get-ChildItem target -Recurse -Filter xlings.exe | Where-Object FullName -Like '*\bin\xlings.exe' | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -if (-not $bin) { throw "xlings.exe not found" } -$root = Join-Path ([IO.Path]::GetTempPath()) "xlings-subos-cmd-$([guid]::NewGuid())" -try { - $env:XLINGS_HOME = Join-Path $root ".xlings" - $env:USERPROFILE = $root - & $bin.FullName self init - & $bin.FullName subos new probe - & $bin.FullName subos use probe --sandbox --cmd '$env:USERPROFILE | Set-Content -NoNewline "$env:USERPROFILE\marker"; exit 37' - if ($LASTEXITCODE -ne 37) { throw "expected exit 37, got $LASTEXITCODE" } - $sandboxUser = if ([string]::IsNullOrEmpty($env:USERNAME)) { "user" } else { $env:USERNAME } - $sandboxHome = Join-Path $env:XLINGS_HOME "subos\probe\home\$sandboxUser" - if ((Get-Content -Raw (Join-Path $sandboxHome "marker")) -ne $sandboxHome) { throw "USERPROFILE was not redirected" } - - # Force the cmd.exe fallback and exercise cmd's own quote/metacharacter - # grammar. The CreateProcess CRT quoting used for PowerShell must not be - # reused for the source following cmd /s /c. - $env:XLINGS_SHELL = "cmd.exe" - & $bin.FullName subos use probe --sandbox --cmd 'echo quoted^&pipe>"%USERPROFILE%\cmd-marker" & exit /b 37' - if ($LASTEXITCODE -ne 37) { throw "cmd fallback expected exit 37, got $LASTEXITCODE" } - if ((Get-Content -Raw (Join-Path $sandboxHome "cmd-marker")).Trim() -ne 'quoted&pipe') { - throw "cmd fallback changed quoting or metacharacter semantics" - } - Remove-Item Env:XLINGS_SHELL - Write-Host "subos cmd Windows contract: ok" -} finally { Remove-Item -Recurse -Force $root -ErrorAction SilentlyContinue } -exit 0 diff --git a/tests/e2e/subos_cmd_contract_test.sh b/tests/e2e/subos_cmd_contract_test.sh deleted file mode 100644 index 499ef339f..000000000 --- a/tests/e2e/subos_cmd_contract_test.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ "$(uname -s)" == Darwin ]] || { echo "subos cmd macOS contract: skipped"; exit 0; } -source "$(dirname "$0")/project_test_lib.sh" -bin=$(find_xlings_bin) -root=$(mktemp -d) -trap 'rm -rf "$root"' EXIT -home="$root/.xlings" -XLINGS_HOME="$home" HOME="$root" "$bin" self init -XLINGS_HOME="$home" HOME="$root" "$bin" subos new probe -set +e -XLINGS_HOME="$home" HOME="$root" "$bin" subos use probe --sandbox \ - --cmd 'printf "%s" "$HOME" > "$HOME/marker"; exit 37' -status=$? -set -e -[[ $status -eq 37 ]] -[[ "$(cat "$home/subos/probe/sandbox-home/marker")" == "$home/subos/probe/sandbox-home" ]] -echo "subos cmd macOS contract: ok" diff --git a/tests/e2e/subos_events_test.sh b/tests/e2e/subos_events_test.sh index d71dfe2f5..bf1baa82b 100755 --- a/tests/e2e/subos_events_test.sh +++ b/tests/e2e/subos_events_test.sh @@ -36,6 +36,8 @@ RUN() { mkdir -p "$HOME_DIR/subos/default/bin" cp "$XLINGS_BIN" "$HOME_DIR/xlings" +require_fixture_index +write_home_config "$HOME_DIR" log "Initializing sandbox XLINGS_HOME at $HOME_DIR" RUN self init >/dev/null 2>&1 || fail "self init failed" diff --git a/tests/e2e/subos_runtime_binding_test.sh b/tests/e2e/subos_runtime_binding_test.sh index aad0e31f7..a9dc27abd 100644 --- a/tests/e2e/subos_runtime_binding_test.sh +++ b/tests/e2e/subos_runtime_binding_test.sh @@ -36,7 +36,7 @@ set -uo pipefail source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/project_test_lib.sh" EXPECTED_DEFAULT="$(sed -n 's/.*DEFAULT_RUNTIME_FALLBACK = "\(.*\)".*/\1/p' \ - "$ROOT_DIR/src/core/subos/manifest.cppm" | head -1)" + "$ROOT_DIR/modules/subos/src/model/manifest.cppm" | head -1)" # Guarded, because an empty expectation does not fail -- it makes S1a compare # "" against a subos that also recorded nothing, and passes. A constant renamed # out from under this sed would then be reported as the feature working. diff --git a/tests/e2e/subos_runtime_describe_test.sh b/tests/e2e/subos_runtime_describe_test.sh index a2a349752..5e3a44142 100755 --- a/tests/e2e/subos_runtime_describe_test.sh +++ b/tests/e2e/subos_runtime_describe_test.sh @@ -124,7 +124,7 @@ S1_RUNTIME="$(block_field "$H1/subos/default/.xlings.json" runtime)" # asserts WHAT the default is. This one asserts the default is not used, so it # has to follow the default. CURRENT_DEFAULT="$(sed -n 's/.*DEFAULT_RUNTIME_FALLBACK = "\(.*\)".*/\1/p' \ - "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/src/core/subos/manifest.cppm" \ + "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/modules/subos/src/model/manifest.cppm" \ | head -1)" [[ -n "$CURRENT_DEFAULT" ]] \ || note_fail "S1: could not read DEFAULT_RUNTIME_FALLBACK out of manifest.cppm" diff --git a/tests/e2e/subos_user_data_test.sh b/tests/e2e/subos_user_data_test.sh index 8b351fbfb..60b456647 100644 --- a/tests/e2e/subos_user_data_test.sh +++ b/tests/e2e/subos_user_data_test.sh @@ -68,6 +68,7 @@ grep -q '"confirmedBy":"yes:true"' "$LOG" || fail "AC18: no log line for the int log " ok AC10: remove_subos without yes informs the agent; with yes:true it removes" # ── AC12: the one deletion entry point refuses what is not a subos ───── +seed "$HOME_DIR/subos/default" python3 - "$HOME_DIR/.xlings.json" <<'PY' import json, sys p = sys.argv[1]; d = json.load(open(p)) @@ -78,17 +79,20 @@ PY set +e; OUT="$(X subos remove .. -y 2>&1)"; RC=$?; set -e [[ $RC -ne 0 ]] || fail "AC12: removing '..' succeeded" [[ -f "$HOME_DIR/.xlings.json" && -d "$HOME_DIR/subos/default" ]] || fail "AC12: '..' reached the home" +has_data "$HOME_DIR/subos/default" || fail "AC12: '..' changed the default home" +case "$OUT" in *"refusing to delete"*|*"invalid subos name"*) ;; *) fail "AC12: no invalid-name refusal: $OUT" ;; esac set +e; OUT2="$(X subos remove current -y 2>&1)"; RC=$?; set -e [[ $RC -ne 0 ]] || fail "AC12: removing 'current' succeeded" [[ -d "$HOME_DIR/subos/default" ]] || fail "AC12: removing 'current' deleted what it points at" -case "$OUT$OUT2" in *"refusing to delete"*) ;; *) fail "AC12: no refusal reason: $OUT $OUT2" ;; esac +has_data "$HOME_DIR/subos/default" || fail "AC12: 'current' changed the default home" +case "$OUT2" in *"refusing to delete"*|*"reserved subos name"*) ;; *) fail "AC12: no reserved-name refusal: $OUT2" ;; esac python3 - "$HOME_DIR/.xlings.json" <<'PY' import json, sys p = sys.argv[1]; d = json.load(open(p)) d["subos"].pop("..", None); d["subos"].pop("current", None) json.dump(d, open(p, "w")) PY -log " ok AC12: '..' and 'current' are refused by the deletion entry point itself" +log " ok AC12: '..' and 'current' are refused before any deletion; the default home is intact" # ── AC13: never delete through a live mount (Linux) ──────────────────── if [[ "$(uname -s)" == "Linux" ]] && unshare -rm true 2>/dev/null; then diff --git a/tests/e2e/subos_xpkg_keeper_test.sh b/tests/e2e/subos_xpkg_keeper_test.sh index a5ec7374c..01134106b 100755 --- a/tests/e2e/subos_xpkg_keeper_test.sh +++ b/tests/e2e/subos_xpkg_keeper_test.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash -# E2E test: keeper CLI surface (M4 + M5). +# E2E test: `subos stop` and the pre-session keeper's files (COMPAT: an older +# xlings's keeper is still ended by `subos stop`; see stop_legacy_keeper_). # # Validates the CLI plumbing that's safe to test without an actual # bwrap/proot install: diff --git a/tests/e2e/system_install_test.sh b/tests/e2e/system_install_test.sh new file mode 100755 index 000000000..076d0b440 --- /dev/null +++ b/tests/e2e/system_install_test.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Deployment S (design §4): xlings installed by a system package. +# +# CI-only, on a throwaway runner: it installs a root-owned entry with sudo. +# +# 1. `self update` leaves a system package's binary to its package manager: +# it says so, names the alternative, and downloads nothing; +# 2. `self update --user` is the explicit ask and is not refused; +# 3. `self doctor` says which binary is answering; +# 4. a home that has never been laid out is told it has no entry, and the +# first install links it to the system binary, so what it installs runs. +# +# xtest: covers=HOME-SYSTEM-MODE,DEPLOY-S-ENTRY requires=sudo,network +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/project_test_lib.sh" + +sudo -n true || fail "needs non-interactive sudo" + +BIN="$(find_xlings_bin)" +RUNTIME_DIR="$(runtime_home_dir system_install)" +export XLINGS_HOME="$RUNTIME_DIR/home" +rm -rf "$RUNTIME_DIR"; mkdir -p "$XLINGS_HOME" +echo '{"mirror":"GLOBAL"}' > "$XLINGS_HOME/.xlings.json" + +SYS_DIR=/usr/local/lib/xlings-system-install-test +cleanup() { sudo rm -rf "$SYS_DIR"; } +trap cleanup EXIT +sudo install -d -m 755 "$SYS_DIR" +sudo install -m 755 "$BIN" "$SYS_DIR/xlings" +X() { ( cd /tmp && "$SYS_DIR/xlings" "$@" ); } + +log "1. self update refuses to replace a system package's binary" +set +e +start=$SECONDS +out="$(X self update 2>&1)"; rc=$? +set -e +printf '%s\n' "$out" | sed 's/^/ | /' +[[ $rc -ne 0 ]] || fail "self update reported success on a system package's binary" +grep -q 'belongs to a system package' <<<"$out" || fail "the refusal does not say why" +grep -q 'self update --user' <<<"$out" || fail "the alternative was not named" +! grep -qi 'updating package index' <<<"$out" || fail "it started updating anyway" +(( SECONDS - start < 20 )) || fail "the refusal took $((SECONDS - start))s" + +log "2. --user is accepted (it goes on to the usual update)" +set +e +out="$(timeout 20 bash -c "cd /tmp && '$SYS_DIR/xlings' self update --user" 2>&1)" +set -e +! grep -q 'belongs to a system package' <<<"$out" || fail "--user was refused" + +log "3. self doctor names the entry" +out="$(X self doctor 2>&1 || true)" +grep -q "entry: $SYS_DIR/xlings (a system package's" <<<"$out" || fail "the doctor did not name the entry" +log "4. a fresh home links its entry to the system binary" +FRESH="$RUNTIME_DIR/fresh" +mkdir -p "$FRESH" +echo '{"mirror":"GLOBAL"}' > "$FRESH/.xlings.json" +F() { ( cd /tmp && XLINGS_HOME="$FRESH" "$SYS_DIR/xlings" "$@" ); } +out="$(F self doctor 2>&1 || true)" +grep -q "this home has no entry" <<<"$out" || fail "doctor did not say the home has no entry" +[[ ! -e "$FRESH/bin/xlings" ]] || fail "a read-only doctor created the entry" +F install -y xz >/dev/null 2>&1 || fail "install into a fresh home failed" +[[ "$(readlink "$FRESH/bin/xlings")" == "$SYS_DIR/xlings" ]] \ + || fail "the entry is not a link to the system binary: $(ls -l "$FRESH/bin/xlings" 2>&1)" +[[ -x "$FRESH/subos/default/bin/xz" ]] || fail "no shim for what was installed" +"$FRESH/subos/default/bin/xz" --version | grep -q "xz (XZ Utils)" \ + || fail "the installed xz does not run through its shim" +out="$( (cd /tmp && XLINGS_HOME="$FRESH" "$FRESH/bin/xlings" self update) 2>&1 || true)" +grep -q 'belongs to a system package' <<<"$out" || fail "self update through the link did not refuse" + +log "PASS: a system package's xlings is updated by the system" diff --git a/tests/e2e/system_layer_test.sh b/tests/e2e/system_layer_test.sh new file mode 100644 index 000000000..c2c64dc02 --- /dev/null +++ b/tests/e2e/system_layer_test.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Deployment M (design part 2 §10): a root-owned system layer every user of +# the machine shares. +# +# 1. `sudo xlings install --system xz` lays /xlings out (mode multi, its +# own entry) and installs into it, root's files; +# 2. a user's shell (their profile) finds the system's xz after their own +# home and before the host, without a copy in their home; +# 3. the user cannot write the system layer; +# 4. the user's own install wins over the system's; +# 5. self doctor names the layer. +# +# CI-only: it creates /xlings with sudo. +# +# xtest: covers=DEPLOY-M-SYSTEM,HOME-LAYER-RESOLVE requires=sudo,network +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/project_test_lib.sh" + +sudo -n true || fail "needs non-interactive sudo" +BIN="$(find_xlings_bin)" +RUNTIME_DIR="$(runtime_home_dir system_layer)" +rm -rf "$RUNTIME_DIR"; mkdir -p "$RUNTIME_DIR" +cleanup() { sudo -n rm -rf /xlings; } +trap cleanup EXIT +sudo -n rm -rf /xlings + +log "1. sudo install --system" +sudo -n env -u XLINGS_HOME -u XLINGS_ACTIVE_SUBOS "$BIN" install --system -y xz > "$RUNTIME_DIR/system.log" 2>&1 \ + || { tail -30 "$RUNTIME_DIR/system.log"; fail "install --system"; } +[[ "$(stat -c %U /xlings/data/xpkgs)" == root ]] || fail "the system layer is not root's" +grep -q '"mode": "multi"' /xlings/.xlings-home || fail "no multi marker" +[[ -x /xlings/subos/default/bin/xz ]] || fail "no xz in the system layer" + +log "2. a user's shell resolves it, no copy" +U="$RUNTIME_DIR/home" +mkdir -p "$U/bin"; cp "$BIN" "$U/bin/xlings" +echo '{"mirror":"GLOBAL"}' > "$U/.xlings.json" +( cd /tmp && XLINGS_HOME="$U" "$U/bin/xlings" self init >/dev/null 2>&1 ) || fail "user self init" +shell() { env -i PATH=/usr/bin:/bin HOME="$RUNTIME_DIR" bash -c ". '$U/config/shell/xlings-profile.sh'; $1"; } +out="$(shell 'command -v xz; xz --version | head -1')" +grep -q '^/xlings/subos/default/bin/xz$' <<<"$out" || fail "the shell does not find the system's xz first: $out" +grep -q 'XZ Utils' <<<"$out" || fail "the system's xz does not run for the user: $out" +[[ ! -d "$U/data/xpkgs/xim-x-xz" ]] || fail "the user's home got a copy" + +log "3. the user cannot write it" +touch /xlings/data/xpkgs/probe 2>/dev/null && fail "a user wrote into the system layer" +out="$(env -u XLINGS_HOME "$BIN" install --system -y zlib 2>&1)" && fail "a user installed into the system layer" +grep -q "root's" <<<"$out" || fail "the refusal does not say why: $out" + +log "4. the user's own wins" +( cd /tmp && XLINGS_HOME="$U" "$U/bin/xlings" install -y xz >/dev/null 2>&1 ) || fail "user install xz" +out="$(shell 'command -v xz')" +[[ "$out" == "$U/subos/current/bin/xz" ]] || fail "the user's xz does not come first: $out" + +log "5. doctor names the layer" +out="$( (cd /tmp && XLINGS_HOME="$U" "$U/bin/xlings" self doctor) 2>&1 || true)" +grep -q "system layer: /xlings" <<<"$out" || fail "doctor does not name the layer: $out" +log "PASS: one system layer, every user, their own first" diff --git a/tests/e2e/test_agent_contract.cpp b/tests/e2e/test_agent_contract.cpp new file mode 100644 index 000000000..fd832aa63 --- /dev/null +++ b/tests/e2e/test_agent_contract.cpp @@ -0,0 +1,148 @@ +// The agent contract (C13 + T4, design §13.3): with the audience declared as +// an agent, no command waits for input -- on a pseudo-terminal where nobody +// types, every command finishes. A command that needs an answer it was not +// given refuses with exit 2 and says which flag gives it. +// +// The scan walks the CLI's own spec (`--command-reference-json`), so a +// command added later is scanned without anyone remembering to add it here. +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.libs.json; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; + +namespace { + +// Commands that reach the network (the index, a download) are out of a +// hermetic scan; they get the same treatment on the network lane. +const std::set kNetwork{ + "install", "update", "search", "info", "why", "self install", "self update", "self init", + "index list", "index use", "self doctor", "subos runtime", "use"}; + +std::vector>> leaf_commands(const tk::Home& home) { + auto r = home.xlings({"--command-reference-json"}); + auto root = nlohmann::json::parse(r.out, nullptr, false); + std::vector>> out; + std::function)> walk = + [&](const nlohmann::json& n, std::vector path) { + if (n["name"] != "xlings") path.push_back(n["name"].get()); + const auto kids = n.value("subcommands", nlohmann::json::array()); + if (kids.empty()) { + std::vector args = path; + for (auto& a : n["arguments"]) { + if (!a.value("required", false)) continue; + // Instance names get the instance that exists; the rest a placeholder. + args.push_back(path.front() == "subos" && a["name"] == "name" ? "box" : "x"); + } + std::string joined; + for (auto& p : path) joined += (joined.empty() ? "" : " ") + p; + out.emplace_back(joined, args); + } + for (auto& k : kids) walk(k, path); + }; + if (!root.is_discarded()) walk(root, {}); + return out; +} + +tk::RunResult agent_on_pty(const tk::Home& home, std::vector args, + std::map extra = {}) { + tk::RunOptions o; + o.argv = std::move(args); + o.env = std::move(extra); + o.env["XLINGS_AGENT_MODE"] = "1"; + o.pty = true; // a terminal, and nobody at it + o.timeout = std::chrono::seconds(30); + return home.xlings(o); +} + +} // namespace + +XTEST(AgentContract, NoCommandWaitsForInput, + .area = "cli", .cost = tk::Cost::Slow, .covers = {"AGENT-NO-PROMPT"}, + .requires_ = {"pty", "xlings-bin"}) { + auto home = tk::Home::isolated("agent-scan"); + fs::create_directories(home.dir() / "subos" / "default"); + ASSERT_EQ(home.xlings({"subos", "new", "box"}).exit_code, 0); + // Without a backend, `subos start` would go and fetch one -- the network, + // not a question. Seeded, it runs here; not seedable, it is skipped. + const bool backend = home.seed_sandbox_backend() && !tk::probe("sandbox"); + auto commands = leaf_commands(home); + ASSERT_GT(commands.size(), 20u) << "the spec walk found too little"; + std::size_t scanned = 0; + for (auto& [path, args] : commands) { + if (kNetwork.contains(path)) continue; + if (path == "subos remove" || path == "self uninstall") continue; // below, on purpose + if (path == "subos start" && !backend) continue; + auto r = agent_on_pty(home, args); + if (path == "subos start") (void)home.xlings({"subos", "stop", "box"}); + EXPECT_FALSE(r.timed_out) << "`xlings " << path << "` waited for input in agent mode\n" + << r.transcript(); + ++scanned; + } + EXPECT_GT(scanned, 15u); +} + +XTEST(AgentContract, AMissingConfirmationRefusesWithExit2AndDeletesNothing, + .area = "cli", .cost = tk::Cost::Medium, .covers = {"EXIT-2-CONFIRM", "AGENT-NO-PROMPT"}, + .requires_ = {"pty", "xlings-bin"}) { + auto home = tk::Home::isolated("agent-confirm"); + fs::create_directories(home.dir() / "subos" / "default"); + ASSERT_EQ(home.xlings({"subos", "new", "box"}).exit_code, 0); + auto r = agent_on_pty(home, {"subos", "remove", "box"}); + EXPECT_FALSE(r.timed_out) << r.transcript(); + EXPECT_EQ(r.exit_code, 2) << r.transcript(); + EXPECT_TRUE(fs::exists(home.dir() / "subos" / "box")); + EXPECT_EQ(agent_on_pty(home, {"subos", "remove", "box", "-y"}).exit_code, 0); + EXPECT_FALSE(fs::exists(home.dir() / "subos" / "box")); +} + +XTEST(AgentContract, TheAudienceIsDeclaredByTheEnvironmentToo, + .area = "cli", .cost = tk::Cost::Medium, .covers = {"AGENT-DECLARED"}, + .requires_ = {"pty", "xlings-bin"}) { + auto home = tk::Home::isolated("agent-declared"); + fs::create_directories(home.dir() / "subos" / "default"); + ASSERT_EQ(home.xlings({"subos", "new", "box"}).exit_code, 0); + // An interactive shell is the one entry that waits by design: an agent is + // told what to run instead, and nothing starts. + auto r = agent_on_pty(home, {"subos", "use", "box"}); + EXPECT_FALSE(r.timed_out); + EXPECT_EQ(r.exit_code, 2) << r.transcript(); + EXPECT_NE(r.out.find("subos exec box"), std::string::npos) << r.transcript(); + // XLINGS_AGENT_MODE=0 declares a human: the same command is not refused + // for being interactive (it fails later, here, for having no terminal + // shell -- what matters is the refusal above is gone). + tk::RunOptions human; + human.argv = {"subos", "use", "box", "--cmd", "true"}; + human.env = {{"XLINGS_AGENT_MODE", "0"}}; + EXPECT_EQ(home.xlings(human).exit_code, 0); +} + +XTEST(AgentContract, AChoiceWithoutADefaultIsAnErrorWithCandidates, + .area = "cli", .cost = tk::Cost::Medium, .covers = {"AGENT-CANDIDATES"}, + .requires_ = {"pty", "xlings-bin"}) { + auto home = tk::Home::isolated("agent-candidates"); + fs::create_directories(home.dir() / "subos" / "default"); + ASSERT_EQ(home.xlings({"subos", "new", "box-a"}).exit_code, 0); + ASSERT_EQ(home.xlings({"subos", "new", "box-b"}).exit_code, 0); + auto r = agent_on_pty(home, {"subos", "exec", "box", "--", "true"}); + EXPECT_FALSE(r.timed_out); + EXPECT_EQ(r.exit_code, 125) << r.transcript(); + EXPECT_NE(r.out.find("box-a"), std::string::npos) << r.transcript(); + EXPECT_NE(r.out.find("box-b"), std::string::npos) << r.transcript(); +} + +XTEST(AgentContract, TheDeclarationReachesTheXlingsInsideASandbox, + .area = "cli", .cost = tk::Cost::Medium, .covers = {"AGENT-ENV-PROPAGATES"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}) { + auto home = tk::Home::isolated("agent-inside"); + home.seed_sandbox_backend(); + ASSERT_EQ(home.xlings({"subos", "new", "box"}).exit_code, 0); + auto r = home.xlings({"--agent", "subos", "exec", "box", "--sandbox", "--", + "/bin/sh", "-c", "echo mode=$XLINGS_AGENT_MODE"}); + EXPECT_EQ(r.exit_code, 0) << r.transcript(); + EXPECT_NE(r.out.find("mode=1"), std::string::npos) << r.transcript(); +} diff --git a/tests/e2e/test_carrier.cpp b/tests/e2e/test_carrier.cpp new file mode 100644 index 000000000..4c2e62f88 --- /dev/null +++ b/tests/e2e/test_carrier.cpp @@ -0,0 +1,115 @@ +// Carriers (SubOS design part 3 §5): where a SubOS runs, and how this xlings +// reaches it -- through one launcher prefix and the NDJSON interface. +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.libs.json; +import xlings.carrier; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; +namespace cr = xlings::carrier; + +namespace { + +cr::Probe yes(std::string_view) { return {.supported = true}; } +cr::Probe no_wsl(std::string_view name) { + if (name == "local") return {.supported = true}; + return {.supported = false, .reason = "WSL is not installed", .route = "wsl --install --no-distribution"}; +} + +} // namespace + +XTEST(Carrier, TheChoiceFollowsWhatTheSubosIsNotWhereItIsAskedFrom, + .area = "subos", .covers = {"CARRIER-LOCAL", "CARRIER-UNAVAILABLE"}) { + // Linux: one kernel, every SubOS here. + auto lin = cr::choose("linux", {.abi = "native", .root = true}, yes); + ASSERT_TRUE(lin); + EXPECT_EQ(lin->carrier, "local"); + // Windows: its own programs stay here; a Linux one or a root goes to WSL2. + EXPECT_EQ(cr::choose("windows", {}, yes)->carrier, "local"); + EXPECT_EQ(cr::choose("windows", {.abi = "linux"}, yes)->carrier, "wsl2"); + EXPECT_EQ(cr::choose("windows", {.root = true}, yes)->carrier, "wsl2"); + EXPECT_EQ(cr::choose("macos", {.root = true}, yes)->carrier, "vz"); + EXPECT_EQ(cr::choose("macos", {.strong = true}, yes)->carrier, "vz"); + // Unavailable: refused with the route, and a native SubOS is unaffected. + auto refused = cr::choose("windows", {.abi = "linux"}, no_wsl); + ASSERT_FALSE(refused); + EXPECT_EQ(refused.error().route, "wsl --install --no-distribution"); + EXPECT_TRUE(cr::choose("windows", {}, no_wsl)); + // Asked for by name: honoured or refused, never silently replaced. + EXPECT_FALSE(cr::choose("linux", {.requested = "wsl2"}, yes)); + EXPECT_FALSE(cr::choose("windows", {.requested = "local", .abi = "linux"}, yes)); + EXPECT_EQ(cr::choose("windows", {.requested = "wsl2"}, yes)->carrier, "wsl2"); +} + +XTEST(Carrier, ALocalSubosRecordsWhereItRunsAndAForeignCarrierIsRefusedWithItsRoute, + .area = "subos", .covers = {"CARRIER-LOCAL", "CARRIER-UNAVAILABLE"}, .requires_ = {"linux", "xlings-bin"}) { + auto home = tk::Home::isolated("carrier-local"); + auto made = home.xlings({"subos", "new", "box", "--carrier", "local"}); + ASSERT_EQ(made.exit_code, 0) << made.transcript(); + auto instance = nlohmann::json::parse(tk::read_file(home.dir() / "config/subos/box/instance.json")); + EXPECT_EQ(instance["carrier"], "local"); + EXPECT_EQ(instance["abi"], "native"); + // A Linux ABI on Linux is the native one: nothing to record. + ASSERT_EQ(home.xlings({"subos", "new", "plain", "--abi", "linux"}).exit_code, 0); + EXPECT_FALSE(fs::exists(home.dir() / "config/subos/plain/instance.json")); + + auto listed = home.xlings({"interface", "list_subos"}); + ASSERT_EQ(listed.exit_code, 0) << listed.transcript(); + bool seen = false; + for (const auto& j : listed.json_lines()) { + if (j.value("dataKind", "") != "subos_list") continue; + for (const auto& e : j["payload"]["entries"]) { + if (e["name"] != "box") continue; + seen = true; + EXPECT_EQ(e["carrier"], "local"); + EXPECT_EQ(e["abi"], "native"); + EXPECT_EQ(e["view"], "overlay"); + } + } + EXPECT_TRUE(seen) << listed.transcript(); + + auto foreign = home.xlings({"subos", "new", "far", "--carrier", "wsl2"}); + EXPECT_EQ(foreign.exit_code, 125) << foreign.transcript(); + EXPECT_NE(foreign.transcript().find("does not exist on linux"), std::string::npos) << foreign.transcript(); + EXPECT_NE(foreign.transcript().find("--carrier local"), std::string::npos) << foreign.transcript(); + EXPECT_FALSE(fs::exists(home.dir() / "subos/far")); +} + +XTEST(Carrier, AnEndpointIsReachedThroughItsLauncherWithTheNdjsonInterface, + .area = "subos", .covers = {"CARRIER-LOCAL"}, .requires_ = {"linux", "xlings-bin"}) { + // What wsl.exe -d --exec does for the wsl2 carrier, /usr/bin/env + // does here: a prefix that runs the command "there", with that home. + auto home = tk::Home::isolated("carrier-launcher"); + ASSERT_EQ(home.xlings({"subos", "new", "guest"}).exit_code, 0); + const cr::Endpoint at{.carrier = "test", .instance = "guest-machine", + .launcher = {"/usr/bin/env", "XLINGS_HOME=" + home.dir().string(), + "HOME=" + home.root().string()}, + .xlings = tk::xlings_binary().string()}; + auto reply = cr::control(at, "list_subos", nlohmann::json::object()); + ASSERT_TRUE(reply) << reply.error(); + EXPECT_EQ(reply->exit_code, 0) << reply->diagnostics; + bool guest = false; + for (const auto& e : reply->events) + if (e.value("dataKind", "") == "subos_list") + for (const auto& entry : e["payload"]["entries"]) guest |= entry["name"] == "guest"; + EXPECT_TRUE(guest); + const std::vector version{"--version"}; + EXPECT_EQ(cr::terminal(at, version), 0); + const std::vector unknown{"subos", "info", "no-such-subos"}; + EXPECT_NE(cr::terminal(at, unknown), 0) << "the exit code is the command's, through the launcher"; +} + +XTEST(Carrier, AnUnreadableRecordOfWhereASubosRunsIsRefusedNotTakenAsLocal, + .area = "subos", .covers = {"CARRIER-LOCAL"}, .requires_ = {"xlings-bin"}) { + auto home = tk::Home::isolated("carrier-unreadable"); + ASSERT_EQ(home.xlings({"subos", "new", "box", "--carrier", "local"}).exit_code, 0); + tk::write_file(home.dir() / "config/subos/box/instance.json", "{ not json"); + auto r = home.xlings({"subos", "remove", "box", "-y"}); + EXPECT_EQ(r.exit_code, 1) << r.transcript(); + EXPECT_NE(r.transcript().find("where this SubOS runs is not known"), std::string::npos) << r.transcript(); + EXPECT_TRUE(fs::exists(home.dir() / "subos/box")) << "nothing removed on a guess"; +} diff --git a/tests/e2e/test_carrier_vz.cpp b/tests/e2e/test_carrier_vz.cpp new file mode 100644 index 000000000..c8fac43a5 --- /dev/null +++ b/tests/e2e/test_carrier_vz.cpp @@ -0,0 +1,74 @@ +// The vz carrier's lifecycle (SubOS design part 3 §5.4) against a stand-in for +// its helper (tests/fixtures/carrier/fake-vz-helper.sh): the same contract the +// real xlings-vm answers on a Mac, run here on any POSIX CI host. +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.libs.json; +import xlings.carrier; +import xlings.carrier.vz; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; + +namespace { +fs::path fake_helper() { return fs::path(__FILE__).parent_path().parent_path() / "fixtures/carrier/fake-vz-helper.sh"; } +std::string read_or_empty(const fs::path& p) { + std::ifstream in(p, std::ios::binary); + return {std::istreambuf_iterator(in), {}}; +} +} // namespace + +XTEST(CarrierVz, WithoutItsHelperTheCarrierIsRefusedWithTheRoute, + .area = "subos", .covers = {"CARRIER-UNAVAILABLE"}) { + const auto* vz = xlings::carrier::find("vz"); + ASSERT_NE(vz, nullptr); + auto home = tk::Home::isolated("carrier-vz-none"); + const auto p = vz->probe({home.dir()}); + EXPECT_FALSE(p.supported); + EXPECT_FALSE(p.route.empty()); +} + +XTEST(CarrierVz, AVmPerHomeIsCreatedOnceStartedWhenStoppedAndReachedThroughTheHelper, + .area = "subos", .covers = {"CARRIER-VZ-LIFECYCLE"}, .requires_ = {"linux", "xlings-bin"}) { + auto home = tk::Home::isolated("carrier-vz"); + const auto vms = home.root() / "vz"; + fs::create_directories(vms); + const std::map env{ + {"XLINGS_VZ_HELPER", fake_helper().string()}, {"FAKE_VZ_ROOT", vms.string()}, + {"XLINGS_CARRIER_GUEST_XLINGS", tk::xlings_binary().string()}}; + auto made = home.xlings({"subos", "new", "box", "--carrier", "vz"}, env); + ASSERT_EQ(made.exit_code, 0) << made.transcript(); + const auto vm = vms / "vms" / xlings::carrier::vz::vm_name(home.dir()); + EXPECT_TRUE(fs::is_directory(vm / "xlings/subos/box")) << made.transcript(); + EXPECT_FALSE(fs::exists(vm / "etc/wsl.conf")) << "a VM is not a WSL distribution"; + + auto info = home.xlings({"subos", "info", "box"}, env); + EXPECT_EQ(info.exit_code, 0) << info.transcript(); + // Stopped (an idle VM), then used again: started, not created again. + fs::remove(vm / "running"); + auto again = home.xlings({"subos", "info", "box"}, env); + EXPECT_EQ(again.exit_code, 0) << again.transcript(); + const auto calls = read_or_empty(vms / "calls.log"); + std::size_t creates = 0, starts = 0; + for (std::size_t at = 0; (at = calls.find("create ", at)) != std::string::npos; ++at) ++creates; + for (std::size_t at = 0; (at = calls.find("start ", at)) != std::string::npos; ++at) ++starts; + EXPECT_EQ(creates, 1u) << calls; + EXPECT_EQ(starts, 2u) << calls; + + // A grant through the helper: the path inside is what it answers. + const auto* vz = xlings::carrier::find("vz"); + for (const auto& [k, v] : env) xlings::testkit::set_env(k, v); + auto at = vz->ensure({home.dir()}); + ASSERT_TRUE(at) << at.error(); + auto granted = vz->grant(*at, home.root() / "project", true, "project"); + ASSERT_TRUE(granted) << granted.error(); + EXPECT_EQ(*granted, "/grant/project"); + EXPECT_NE(read_or_empty(vm / "shares").find("project rw"), std::string::npos); + + auto removed = home.xlings({"subos", "remove", "box", "-y"}, env); + EXPECT_EQ(removed.exit_code, 0) << removed.transcript(); + EXPECT_FALSE(fs::exists(vm / "xlings/subos/box")); +} diff --git a/tests/e2e/test_carrier_wsl2.cpp b/tests/e2e/test_carrier_wsl2.cpp new file mode 100644 index 000000000..aa94e2865 --- /dev/null +++ b/tests/e2e/test_carrier_wsl2.cpp @@ -0,0 +1,112 @@ +// The wsl2 carrier's lifecycle (SubOS design part 3 §5.3), on any POSIX CI +// host: a stand-in wsl.exe (tests/fixtures/carrier/fake-wsl.sh) keeps each +// "distribution" as a directory. The image is the real one -- written by this +// xlings, extracted by tar -- and the guest is a real xlings, self-contained +// at /xlings. What the stand-in cannot be is a WSL2 kernel: interop, +// drvfs grants and namespaces inside need a Windows host with WSL2. +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.libs.json; +import xlings.carrier; +import xlings.carrier.wsl2; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; +namespace w2 = xlings::carrier::wsl2; + +namespace { + +fs::path fake_wsl() { return fs::path(__FILE__).parent_path().parent_path() / "fixtures/carrier/fake-wsl.sh"; } + +std::string read_or_empty(const fs::path& p) { + std::ifstream in(p, std::ios::binary); + return {std::istreambuf_iterator(in), {}}; +} + +} // namespace + +XTEST(CarrierWsl2, ItsListingIsReadInEitherEncodingAndItsNameIsTheHomes, + .area = "subos", .covers = {"CARRIER-WSL-LIFECYCLE"}) { + using namespace std::string_literals; + EXPECT_EQ(w2::parse_list("Ubuntu\r\nxlings-abc\r\n"), (std::vector{"Ubuntu", "xlings-abc"})); + // UTF-16LE with a BOM, as wsl.exe answers without WSL_UTF8. + const auto utf16 = "\xFF\xFE" "U\0b\0u\0n\0t\0u\0\r\0\n\0x\0l\0\r\0\n\0"s; + EXPECT_EQ(w2::parse_list(utf16), (std::vector{"Ubuntu", "xl"})); + EXPECT_EQ(w2::distro_name("/h/.xlings"), w2::distro_name("/h/./.xlings")); + EXPECT_NE(w2::distro_name("/h/.xlings"), w2::distro_name("/g/.xlings")); + EXPECT_TRUE(w2::distro_name("/h").starts_with("xlings-")); + EXPECT_EQ(w2::launcher("C:\\Windows\\System32\\wsl.exe", "xlings-1"), + (std::vector{"C:\\Windows\\System32\\wsl.exe", "-d", "xlings-1", "-u", "root", "--exec"})); +} + +XTEST(CarrierWsl2, TheImageTurnsInteropAndAutomountOffAndAGrantIsOnlyUnderGrant, + .area = "subos", .covers = {"CARRIER-WSL-INTEROP-OFF", "CARRIER-WSL-GRANT"}) { + std::string conf; + bool init_link = false; + for (const auto& f : w2::image_files()) { + if (f.path == "etc/wsl.conf") conf = f.content; + if (f.path == "sbin/mount.drvfs") init_link = f.link == "/init"; + } + EXPECT_NE(conf.find("[interop]\nenabled = false"), std::string::npos) << conf; + EXPECT_NE(conf.find("appendWindowsPath = false"), std::string::npos) << conf; + EXPECT_NE(conf.find("[automount]\nenabled = false"), std::string::npos) << conf; + EXPECT_TRUE(init_link); + // The guest side refuses a mount point outside /grant/. + const std::vector outside{"C:\\work", "/etc", "rw"}; + EXPECT_EQ(w2::guest_grant(outside), 2); + const std::vector mode{"C:\\work", "/grant/w", "rwx"}; + EXPECT_EQ(w2::guest_grant(mode), 2); +} + +XTEST(CarrierWsl2, ASubosOnTheCarrierIsCreatedThereOnceUsedThereAndRemovedThere, + .area = "subos", .covers = {"CARRIER-WSL-LIFECYCLE"}, .requires_ = {"linux", "xlings-bin"}) { + auto home = tk::Home::isolated("carrier-wsl2"); + const auto wsl = home.root() / "wsl"; + fs::create_directories(wsl); + const std::map env{ + {"XLINGS_WSL_EXE", fake_wsl().string()}, + {"FAKE_WSL_ROOT", wsl.string()}, + {"XLINGS_CARRIER_GUEST_XLINGS", tk::xlings_binary().string()}, + }; + auto made = home.xlings({"subos", "new", "box", "--carrier", "wsl2"}, env); + ASSERT_EQ(made.exit_code, 0) << made.transcript(); + const auto name = w2::distro_name(home.dir()); + const auto distro = wsl / "distros" / name; + ASSERT_TRUE(fs::is_directory(distro / "xlings/subos/box")) << made.transcript(); + EXPECT_NE(read_or_empty(distro / "etc/wsl.conf").find("[interop]\nenabled = false"), std::string::npos); + // Here: only the name and where it runs. + auto instance = nlohmann::json::parse(tk::read_file(home.dir() / "config/subos/box/instance.json")); + EXPECT_EQ(instance["carrier"], "wsl2"); + EXPECT_TRUE(fs::is_empty(home.dir() / "subos/box")); + + // Used there, through the same distribution: imported once. + auto info = home.xlings({"subos", "info", "box"}, env); + EXPECT_EQ(info.exit_code, 0) << info.transcript(); + auto calls = read_or_empty(wsl / "calls.log"); + std::size_t imports = 0; + for (std::size_t at = 0; (at = calls.find("--import", at)) != std::string::npos; ++at) ++imports; + EXPECT_EQ(imports, 1u) << calls; + const auto run_there = calls.find("-d " + name + " -u root --exec /xlings/bin/xlings __carrier-env"); + ASSERT_NE(run_there, std::string::npos) << calls; + EXPECT_NE(calls.find("-- subos info box", run_there), std::string::npos) << calls; + + // Listed here with where it runs. + auto listed = home.xlings({"interface", "list_subos"}, env); + bool seen = false; + for (const auto& j : listed.json_lines()) + if (j.value("dataKind", "") == "subos_list") + for (const auto& e : j["payload"]["entries"]) + if (e["name"] == "box") { seen = true; EXPECT_EQ(e["carrier"], "wsl2"); } + EXPECT_TRUE(seen) << listed.transcript(); + + // Removed there; then here. + auto removed = home.xlings({"subos", "remove", "box", "-y"}, env); + EXPECT_EQ(removed.exit_code, 0) << removed.transcript(); + EXPECT_FALSE(fs::exists(distro / "xlings/subos/box")); + EXPECT_FALSE(fs::exists(home.dir() / "subos/box")); + EXPECT_FALSE(fs::exists(home.dir() / "config/subos/box/instance.json")); + EXPECT_TRUE(fs::is_directory(distro)) << "the carrier is the home's; removing a SubOS keeps it"; +} diff --git a/tests/e2e/test_domain_producer.cpp b/tests/e2e/test_domain_producer.cpp new file mode 100644 index 000000000..d601a3ee5 --- /dev/null +++ b/tests/e2e/test_domain_producer.cpp @@ -0,0 +1,349 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" +import std; +import xlings.libs.json; +import xlings.core.home.domain_producer; +import xlings.core.elfread; +import xlings.subos.home_view; +import xlings.subos.manifest; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; +using Json = nlohmann::json; + +XTEST(DomainProducer, InstallsAtLogicalPrefixInsideANamespaceAndPreservesHostData, + .area = "subos", .cost = tk::Cost::Medium, + .covers = {"DOM-BUILD-INSIDE", "DOM-LAYOUTS"}, .requires_ = {"linux", "bwrap", "xlings-bin"}, + .resources = {"sandbox"}, .proves = "isolation") { + if constexpr (!tk::is_linux) GTEST_SKIP() << "Linux namespaces"; + if (const auto why = tk::probe("bwrap")) GTEST_SKIP() << *why; + const auto executable = xlings::elfread::read(tk::xlings_binary()); + ASSERT_TRUE(executable) << "cannot read the candidate ELF executable"; + if (!executable->interpreter.empty()) + GTEST_SKIP() << "domain root runtime requires the static release candidate; the development client has an ELF interpreter"; + auto home = tk::Home::isolated("domain-producer"); + const auto hostSentinel = home.root() / "host-sentinel"; + tk::write_file(hostSentinel, "host must remain intact"); + const auto repo = home.root() / "index"; + const auto primary = home.root() / "primary"; + tk::write_file(primary / "xim-indexrepos.lua", "xim_indexrepos = {}\n"); + fs::create_directories(primary / "pkgs"); + tk::write_file(repo / "xim-indexrepos.lua", "xim_indexrepos = {}\n"); + std::string recipe = R"LUA( + package = { spec='1', name='domain-base', type='subos', archs={'x86_64','aarch64'}, + xpm={linux={['1.0.0']={}}} } + import('xim.libxpkg.pkginfo') + function install() + local dir = pkginfo.install_dir() + assert(dir:sub(1, 8) == '/xlings/', 'producer ran outside its logical prefix: '..dir) + local r = assert(io.open(HOST_SENTINEL, 'r')) + assert(r:read('*a') == 'host must remain intact'); r:close() + local writer = io.open(HOST_SENTINEL, 'w') + if writer then writer:close(); error('host root is writable') end + os.mkdir(path.join(dir, 'bin')) + io.writefile(path.join(dir, '.xlings.json'), '{"workspace":{},"subos_kind":"rootfs"}') + io.writefile(path.join(dir, 'produced-prefix'), dir) + return true + end + )LUA"; + recipe.replace(recipe.find("HOST_SENTINEL"), std::string("HOST_SENTINEL").size(), Json(hostSentinel.generic_string()).dump()); + recipe.replace(recipe.find("HOST_SENTINEL"), std::string("HOST_SENTINEL").size(), Json(hostSentinel.generic_string()).dump()); + tk::write_file(repo / "pkgs/d/domain-base.lua", recipe); + Json config{{"mirror", "GLOBAL"}, {"xim", {{"index-repo", primary.generic_string()}}}, + {"index_repos", Json::array({{{"name", "xim"}, {"url", primary.generic_string()}, {"source", "git"}}, + {{"name", "fixture"}, {"url", repo.generic_string()}, {"source", "git"}}})}}; + tk::write_file(home.dir() / ".xlings.json", config.dump()); + fs::create_directories(home.dir() / "bin"); + fs::copy_file(tk::xlings_binary(), home.dir() / "bin/xlings"); + auto initialized = home.xlings({"self", "init"}); + ASSERT_EQ(initialized.exit_code, 0) << initialized.transcript(); + const auto before = tk::read_file(home.dir() / ".xlings.json"); + auto created = home.xlings({"subos", "new", "domain-root", "--rootfs", "--domain", "/xlings", + "--from", "fixture:domain-base@1.0.0"}); + ASSERT_EQ(created.exit_code, 0) << created.transcript(); + const auto privateHome = home.dir() / "domains/xlings/private"; + EXPECT_EQ(tk::read_file(privateHome / "data/xpkgs/fixture-x-domain-base/1.0.0/produced-prefix"), + "/xlings/data/xpkgs/fixture-x-domain-base/1.0.0"); + EXPECT_EQ(tk::read_file(hostSentinel), "host must remain intact"); + const auto outerBefore = Json::parse(before); + const auto outerAfter = Json::parse(tk::read_file(home.dir() / ".xlings.json")); + for (const auto* key : {"versions", "workspace", "configured", "activeSubos"}) + EXPECT_EQ(outerAfter.value(key, Json{}), outerBefore.value(key, Json{})); + EXPECT_TRUE(outerAfter["subos"].contains("domain-root")); + auto status = home.xlings({"subos", "status", "domain-root", "--json"}); + ASSERT_EQ(status.exit_code, 0) << status.transcript(); + const auto state = Json::parse(status.out); + EXPECT_EQ(state["root"]["kind"], "rootfs"); + EXPECT_TRUE(state["root"]["tree"].get().starts_with("/xlings/subos/domain-root/")); + EXPECT_NE(home.xlings({"subos", "use", "domain-root", "--shell=sh"}).exit_code, 0); + const auto descriptor = Json::parse(tk::read_file(xlings::subos::HomeView{home.dir()}.instance_file("domain-root"))); + EXPECT_EQ(descriptor["prefix_domain"]["physical_home"], fs::canonical(privateHome).generic_string()); + EXPECT_EQ(descriptor["prefix_domain"]["logical_home"], "/xlings"); + auto scope = xlings::home::domain_producer::read_scope(home.dir(), "domain-root"); + ASSERT_TRUE(scope) << scope.error(); ASSERT_TRUE(*scope); + EXPECT_EQ((**scope).producerInstance, privateHome / "subos/domain-root"); + // The actual owned /xlings/bin/xlings entry must still discover and probe + // the host backend. A caller's home is not proof that bwrap is its shim. + std::optional backendSource; + if ((**scope).domain.systemSource) { + const auto stage = home.root() / "backend-source-view"; + ASSERT_TRUE(fs::create_directory(stage)); + auto prepared = xlings::home::domain_producer_source::prepare((**scope).domain, stage); + ASSERT_TRUE(prepared) << prepared.error(); + backendSource.emplace(std::move(*prepared)); + } + const std::vector backendArguments{"self", "doctor", "--isolation", "--json"}; + auto backendCommand = xlings::home::domain_producer::command( + (**scope).domain, backendArguments, std::nullopt, + backendSource ? &*backendSource : nullptr, true); + ASSERT_TRUE(backendCommand) << backendCommand.error(); + auto backendDiagnosis = tk::run({.argv = *backendCommand, .env = home.env(), .cwd = home.root()}); + ASSERT_EQ(backendDiagnosis.exit_code, 0) << backendDiagnosis.transcript(); + const auto backendReport = Json::parse(backendDiagnosis.out); + ASSERT_TRUE(backendReport["ok"].get()) << backendDiagnosis.transcript(); + ASSERT_EQ(backendReport["backend"]["name"].get(), "bwrap"); + EXPECT_FALSE(backendReport["bwrap"].empty()); + EXPECT_EQ(tk::read_file(hostSentinel), "host must remain intact"); + const auto beforeSwitch = tk::read_file(home.dir() / ".xlings.json"); + auto switched = home.xlings({"interface", "switch_subos", "--args", Json{{"name", "domain-root"}}.dump()}); + EXPECT_NE(switched.exit_code, 0) << switched.transcript(); + EXPECT_EQ(tk::read_file(home.dir() / ".xlings.json"), beforeSwitch); + const xlings::subos::HomeView producer{privateHome}; + const xlings::subos::HomeView outer{home.dir()}; + auto info = home.xlings({"subos", "info", "domain-root"}); + ASSERT_EQ(info.exit_code, 0) << info.transcript(); + EXPECT_NE(info.transcript().find("/xlings/subos/domain-root"), std::string::npos); + + auto configured = home.xlings({"subos", "config", "domain-root", "--observe", "basic", "--json"}); + ASSERT_EQ(configured.exit_code, 0) << configured.transcript(); + EXPECT_TRUE(fs::is_regular_file(producer.policy_file("domain-root"))); + EXPECT_FALSE(fs::exists(outer.policy_file("domain-root"))); + auto policy = home.xlings({"subos", "config", "domain-root", "--json"}); + ASSERT_EQ(policy.exit_code, 0) << policy.transcript(); + EXPECT_EQ(Json::parse(policy.out)["observe"]["level"], "basic"); + EXPECT_EQ(Json::parse(policy.out)["source"], "file"); + auto invalidObserve = home.xlings({"subos", "exec", "domain-root", "--observe", "unknown", "--", "/bin/true"}); + EXPECT_EQ(invalidObserve.exit_code, 125) << invalidObserve.transcript(); + EXPECT_NE(invalidObserve.transcript().find("--observe expects"), std::string::npos); + + // Explicit grants provide a shell and its exact loader dependencies; the + // root still obtains no host directories implicitly. + const auto shell = xlings::elfread::read("/bin/sh"); + ASSERT_TRUE(shell); + std::set shellFiles{"/bin/sh"}; + if (!shell->interpreter.empty()) { + shellFiles.insert(shell->interpreter); + auto libraries = tk::run({.argv = {shell->interpreter, "--list", "/bin/sh"}, .env = home.env()}); + ASSERT_EQ(libraries.exit_code, 0) << libraries.transcript(); + std::istringstream tokens(libraries.out); + std::string token; + while (tokens >> token) if (token.starts_with('/')) shellFiles.insert(token); + } + // Empty templates have no shell in the readonly /usr projection. Reserve + // this fixture's machine mountpoints without changing that projection. + const auto machineRoot = privateHome / "subos/domain-root/rootfs"; + for (const auto* directory : {"bin", "lib", "lib64"}) { + const auto at = machineRoot / directory; + ASSERT_TRUE(fs::is_symlink(fs::symlink_status(at))) << at; + ASSERT_EQ(fs::read_symlink(at), fs::path("usr") / directory) << at; + ASSERT_TRUE(fs::remove(at)); + ASSERT_TRUE(fs::create_directory(at)); + } + std::vector shellGrants; + for (const auto& file : shellFiles) { + ASSERT_TRUE(fs::is_regular_file(file)) << file; + const auto relative = file.relative_path(); + ASSERT_FALSE(relative.empty()); + const auto directory = relative.begin()->generic_string(); + ASSERT_TRUE(directory == "bin" || directory == "lib" || directory == "lib64") + << "fixture runtime grant needs a machine mountpoint: " << file; + const auto destination = machineRoot / relative; + fs::create_directories(destination.parent_path()); + ASSERT_FALSE(fs::exists(fs::symlink_status(destination))) << destination; + tk::write_file(destination, ""); + shellGrants.insert(shellGrants.end(), {"--mount", file.string() + ":" + file.string() + ":ro"}); + } + const auto mountSource = home.root() / "domain-mount-source"; + tk::write_file(mountSource / "probe", "outer mount source"); + tk::write_file(privateHome / "domain-mount-source/probe", "wrong private mount source"); + for (const auto* spelling : {"./domain-mount-source", "~/domain-mount-source"}) { + std::vector args{"subos", "exec", "domain-root", "--mount", + std::string(spelling) + ":/mnt/domain-probe:ro"}; + args.insert(args.end(), shellGrants.begin(), shellGrants.end()); + args.insert(args.end(), {"--", "/bin/sh", "-c", + "uidSeen=0; capsSeen=0; while read -r key a b rest; do " + "case \"$key\" in Uid:) [ \"$a:$b\" = 0:0 ] && uidSeen=1;; " + "CapEff:) [ \"$a\" = 0000000000000000 ] && capsSeen=1;; esac; " + "done < /proc/self/status; [ \"$uidSeen:$capsSeen\" = 1:1 ] || exit 73; " + "IFS= read -r value < /mnt/domain-probe/probe || :; printf '%s|%s|%s' \"$value\" \"$1\" \"$2\"", + "probe", "--mount", "~/command-literal:/unused:ro"}); + auto mounted = home.xlings(args); + ASSERT_EQ(mounted.exit_code, 0) << mounted.transcript(); + EXPECT_EQ(mounted.out, "outer mount source|--mount|~/command-literal:/unused:ro"); + + args = {"subos", "use", "domain-root", "--no-keep", + "--mount=" + std::string(spelling) + ":/mnt/domain-probe:ro"}; + args.insert(args.end(), shellGrants.begin(), shellGrants.end()); + args.insert(args.end(), {"--cmd", + "IFS= read -r value < /mnt/domain-probe/probe || :; printf 'use-mount:%s' \"$value\""}); + auto used = home.xlings(args); + ASSERT_EQ(used.exit_code, 0) << used.transcript(); + EXPECT_NE(used.out.find("use-mount:outer mount source"), std::string::npos) << used.transcript(); + EXPECT_EQ(used.out.find("wrong private mount source"), std::string::npos); + } + + auto doctor = home.xlings({"subos", "doctor", "domain-root", "--json"}); + const auto diagnosed = Json::parse(doctor.out); + ASSERT_EQ(diagnosed["instances"].size(), 1u) << doctor.transcript(); + EXPECT_EQ(diagnosed["instances"][0]["instance"], "domain-root"); + EXPECT_NE(diagnosed.dump().find("/xlings/config/subos/domain-root/policy.json"), std::string::npos); + { + std::ofstream events(producer.logs_dir("domain-root") / "events.ndjson", std::ios::app); + ASSERT_TRUE(events); + events << Json{{"kind", "lifecycle"}, {"event", "domain-route-probe"}, {"session", "domain-session-probe"}}.dump() << '\n'; + } + tk::write_file(outer.logs_dir("domain-root") / "events.ndjson", + Json{{"kind", "lifecycle"}, {"event", "outer-control-probe"}, {"session", "domain-session-probe"}}.dump() + "\n"); + auto logged = home.xlings({"subos", "log", "domain-root", "--json", "--session", "domain-session-probe"}); + ASSERT_EQ(logged.exit_code, 0) << logged.transcript(); + EXPECT_EQ(Json::parse(logged.out)["event"], "domain-route-probe"); + auto missingSession = home.xlings({"subos", "log", "domain-root", "--session", "--global"}); + EXPECT_NE(missingSession.exit_code, 0); + EXPECT_NE(missingSession.transcript().find("missing value"), std::string::npos); + + + const auto eventArguments = Json{{"name", "domain-root"}, {"session", "domain-session-probe"}}.dump(); + auto interfaceEvents = home.xlings({"interface", "subos_events", "--args", eventArguments}); + ASSERT_EQ(interfaceEvents.exit_code, 0) << interfaceEvents.transcript(); + std::istringstream eventLines(interfaceEvents.out); + std::string eventLine; + int dataCount = 0, resultCount = 0; + while (std::getline(eventLines, eventLine)) { + const auto event = Json::parse(eventLine); + if (event["kind"] == "data") { + ++dataCount; + EXPECT_EQ(event["dataKind"], "subos_event"); + EXPECT_EQ(event["payload"]["event"], "domain-route-probe"); + } else if (event["kind"] == "result") { + ++resultCount; + EXPECT_EQ(event["exitCode"], 0); + EXPECT_EQ(event["data"]["count"], 1); + } + } + EXPECT_EQ(dataCount, 1); + EXPECT_EQ(resultCount, 1); + EXPECT_EQ(interfaceEvents.out.find("outer-control-probe"), std::string::npos); + + auto generations = home.xlings({"subos", "rollback", "domain-root", "--list"}); + EXPECT_EQ(generations.exit_code, 0) << generations.transcript(); + EXPECT_NE(generations.transcript().find("subos new --rootfs"), std::string::npos); + const auto instanceBefore = tk::read_file(privateHome / "subos/domain-root/.xlings.json"); + const auto runtime = xlings::subos::manifest::parse(Json::parse(instanceBefore)).runtime; + ASSERT_FALSE(runtime.empty()); + auto rebound = home.xlings({"subos", "runtime", runtime, "domain-root"}); + EXPECT_EQ(rebound.exit_code, 0) << rebound.transcript(); + EXPECT_EQ(tk::read_file(privateHome / "subos/domain-root/.xlings.json"), instanceBefore); + + struct StopOnExit { + tk::Home& home; + ~StopOnExit() { (void)home.xlings({"subos", "stop", "domain-root"}); } + } stopOnExit{home}; + auto started = home.xlings({"subos", "start", "domain-root", "--ttl", "30s"}); + ASSERT_EQ(started.exit_code, 0) << started.transcript(); + EXPECT_TRUE(fs::is_regular_file(producer.run_dir("domain-root") / "session.json")); + EXPECT_FALSE(fs::exists(outer.run_dir("domain-root") / "session.json")); + const auto sessionBefore = tk::read_file(producer.run_dir("domain-root") / "session.json"); + for (int call = 0; call < 2; ++call) { + const auto joined = home.xlings({"subos", "exec", "domain-root", "--", "/xlings/bin/xlings", "--version"}); + EXPECT_EQ(joined.exit_code, 0) << joined.transcript(); + EXPECT_NE(joined.out.find("xlings "), std::string::npos); + EXPECT_EQ(tk::read_file(producer.run_dir("domain-root") / "session.json"), sessionBefore); + } + auto running = home.xlings({"subos", "ps", "domain-root", "--json"}); + ASSERT_EQ(running.exit_code, 0) << running.transcript(); + EXPECT_EQ(Json::parse(running.out)["instance"], "domain-root"); + auto stopped = home.xlings({"subos", "stop", "domain-root"}); + ASSERT_EQ(stopped.exit_code, 0) << stopped.transcript(); + EXPECT_FALSE(fs::exists(producer.run_dir("domain-root") / "session.json")); + auto ended = home.xlings({"subos", "ps", "domain-root", "--json"}); + EXPECT_EQ(ended.exit_code, 0) << ended.transcript(); + EXPECT_TRUE(ended.out.empty()) << ended.transcript(); + + const auto controlBefore = tk::read_file(outer.instance_file("domain-root")); + auto unconfirmed = home.xlings({"subos", "remove", "domain-root"}); + EXPECT_EQ(unconfirmed.exit_code, 2) << unconfirmed.transcript(); + EXPECT_EQ(tk::read_file(outer.instance_file("domain-root")), controlBefore); + auto copied = home.xlings({"subos", "cp", hostSentinel.string(), "domain-root:/tmp/copied"}); + ASSERT_EQ(copied.exit_code, 0) << copied.transcript(); + EXPECT_EQ(tk::read_file(privateHome / "subos/domain-root/rootfs/tmp/copied"), "host must remain intact"); + const auto copiedOut = home.root() / "copied-out"; + auto retrieved = home.xlings({"subos", "cp", "domain-root:/tmp/copied", copiedOut.string()}); + ASSERT_EQ(retrieved.exit_code, 0) << retrieved.transcript(); + EXPECT_EQ(tk::read_file(copiedOut), "host must remain intact"); + EXPECT_NE(home.xlings({"subos", "cp", hostSentinel.string(), "domain-root:/xlings/forbidden"}).exit_code, 0); + fs::create_directory_symlink(hostSentinel.parent_path(), privateHome / "subos/domain-root/rootfs/tmp/escape"); + EXPECT_NE(home.xlings({"subos", "cp", hostSentinel.string(), "domain-root:/tmp/escape/host-sentinel"}).exit_code, 0); + EXPECT_EQ(tk::read_file(hostSentinel), "host must remain intact"); + auto forked = home.xlings({"subos", "new", "domain-sibling", "--from", "domain-root"}); + ASSERT_EQ(forked.exit_code, 0) << forked.transcript(); + EXPECT_EQ(tk::read_file(privateHome / "subos/domain-sibling/rootfs/tmp/copied"), "host must remain intact"); + auto sibling = xlings::home::domain_producer::read_scope(home.dir(), "domain-sibling"); + ASSERT_TRUE(sibling) << sibling.error(); ASSERT_TRUE(*sibling); + EXPECT_EQ((**sibling).domain.physicalHome, (**scope).domain.physicalHome); + EXPECT_NE(home.xlings({"subos", "new", "cross-domain-fork", "--from", "domain-root", "--rootfs", "--domain", "/other-prefix"}).exit_code, 0); + EXPECT_FALSE(fs::exists(home.dir() / "subos/cross-domain-fork")); + const auto output = home.root() / "export.tar.gz"; + tk::write_file(output, "user-owned output"); + auto exported = home.xlings({"subos", "export", "domain-root", "--tar", output.string()}); + EXPECT_NE(exported.exit_code, 0); + EXPECT_EQ(tk::read_file(output), "user-owned output"); + auto collision = home.xlings({"subos", "new", "domain-root", "--rootfs", "--domain", "/xlings", "-y"}); + EXPECT_NE(collision.exit_code, 0) << collision.transcript(); + EXPECT_EQ(tk::read_file(hostSentinel), "host must remain intact"); + auto failed = home.xlings({"subos", "new", "failed-domain-root", "--rootfs", "--domain", "/xlings", + "--from", "fixture:missing-base@1.0.0"}); + EXPECT_NE(failed.exit_code, 0) << failed.transcript(); + EXPECT_FALSE(fs::exists(home.dir() / "subos/failed-domain-root")); + EXPECT_EQ(tk::read_file(hostSentinel), "host must remain intact"); + tk::write_file(outer.instance("domain-root") / "user-owned", "preserve foreign control data"); + EXPECT_NE(home.xlings({"subos", "remove", "domain-root", "-y"}).exit_code, 0); + EXPECT_TRUE(fs::is_directory(producer.instance("domain-root"))); + EXPECT_EQ(tk::read_file(outer.instance("domain-root") / "user-owned"), "preserve foreign control data"); + fs::remove(outer.instance("domain-root") / "user-owned"); + tk::write_file(outer.instance_file("domain-root"), "{bad JSON"); + const auto corruptEvents = home.xlings({"interface", "subos_events", "--args", eventArguments}); + EXPECT_NE(corruptEvents.exit_code, 0) << corruptEvents.transcript(); + EXPECT_NE(corruptEvents.out.find("E_INVALID_INPUT"), std::string::npos); + EXPECT_EQ(corruptEvents.out.find("outer-control-probe"), std::string::npos); + EXPECT_NE(home.xlings({"subos", "remove", "domain-root", "-y"}).exit_code, 0); + EXPECT_TRUE(fs::is_directory(producer.instance("domain-root"))); + tk::write_file(outer.instance_file("domain-root"), controlBefore); + const auto payload = privateHome / "data/xpkgs/fixture-x-domain-base/1.0.0"; + const auto payloadBefore = tk::read_file(payload / "produced-prefix"); + auto removed = home.xlings({"interface", "remove_subos", "--args", Json{{"name", "domain-root"}, {"yes", true}}.dump()}); + ASSERT_EQ(removed.exit_code, 0) << removed.transcript(); + int removedData = 0, removedResults = 0; + std::istringstream removalLines(removed.out); + while (std::getline(removalLines, eventLine)) { + const auto event = Json::parse(eventLine); + if (event["kind"] == "data" && event["dataKind"] == "subos_removed") { + ++removedData; + EXPECT_EQ(event["payload"]["name"], "domain-root"); + } + if (event["kind"] == "result") ++removedResults; + } + EXPECT_EQ(removedData, 1); + EXPECT_EQ(removedResults, 1); + EXPECT_FALSE(fs::exists(producer.instance("domain-root"))); + EXPECT_FALSE(fs::exists(outer.instance("domain-root"))); + EXPECT_FALSE(fs::exists(outer.instance_file("domain-root"))); + EXPECT_FALSE(Json::parse(tk::read_file(home.dir() / ".xlings.json"))["subos"].contains("domain-root")); + EXPECT_TRUE(fs::is_directory(producer.instance("domain-sibling"))); + EXPECT_TRUE(fs::is_regular_file(outer.instance_file("domain-sibling"))); + EXPECT_EQ(tk::read_file(payload / "produced-prefix"), payloadBefore); + EXPECT_EQ(tk::read_file(outer.logs_dir("domain-root") / "events.ndjson"), + Json({{"kind", "lifecycle"}, {"event", "outer-control-probe"}, {"session", "domain-session-probe"}}).dump() + "\n"); + EXPECT_EQ(tk::read_file(hostSentinel), "host must remain intact"); + +} diff --git a/tests/e2e/test_domain_source.cpp b/tests/e2e/test_domain_source.cpp new file mode 100644 index 000000000..c92493de1 --- /dev/null +++ b/tests/e2e/test_domain_source.cpp @@ -0,0 +1,197 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" +import std; +import xlings.core.home.prefix_domain; +import xlings.core.home.domain_producer; +import xlings.core.elfread; +import xlings.libs.json; +import xlings.subos.home_view; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; +namespace domain = xlings::home::prefix_domain; +namespace producer = xlings::home::domain_producer; +using Json = nlohmann::json; + +XTEST(DomainSourceProducer, ReusesOnlyCheckedReadonlyBytesAndExportsAnOwnedImage, + .area = "subos", .cost = tk::Cost::Medium, + .covers = {"DOM-BUILD-INSIDE", "DOM-PREFIX", "DOM-LAYOUTS", "DOM-SOURCE", "HOME-LAYER-RESOLVE"}, + .requires_ = {"linux", "bwrap", "xlings-bin"}, .resources = {"sandbox"}, .proves = "isolation") { + if constexpr (!tk::is_linux) GTEST_SKIP() << "Linux namespaces"; + if (const auto why = tk::probe("bwrap")) GTEST_SKIP() << *why; + const auto executable = xlings::elfread::read(tk::xlings_binary()); + if (!executable || !executable->interpreter.empty()) GTEST_SKIP() << "image export requires the static release binary"; + auto owner = tk::Home::isolated("domain-source-producer"); + const auto source = owner.root() / "source"; + const auto payload = source / "data/xpkgs/fixture-x-domain-source/1.0.0"; + const auto coordinate = "fixture:1.0.0"; + tk::write_file(payload / "bin/source-member", "#!/bin/sh\nexit 0\n"); + tk::write_file(payload / ".xlings-resolution.json", Json{{"package", "fixture:domain-source@1.0.0"}, + {"deps", Json::array()}, {"future", "unknown record remains"}}.dump()); + Json workspace{{"source-member", {{"active", coordinate}, {"installed", Json::array({coordinate})}}}}; + tk::write_file(payload / ".xlings.json", Json{{"workspace", workspace}, {"subos_kind", "rootfs"}}.dump()); + tk::write_file(source / ".xlings-home", R"({"schema":1,"mode":"root","layout":"multi","future":42})"); + const auto physicalSource = fs::canonical(source); + Json version{{"path", (physicalSource / "data/xpkgs/fixture-x-domain-source/1.0.0/bin").generic_string()}, + {"kind", "program"}, {"alias", Json::array({"/xlings/data/xpkgs/fixture-x-domain-source/1.0.0/bin/source-member"})}}; + tk::write_file(source / ".xlings.json", Json{{"versions", { + {"source-member", {{"type", "program"}, {"versions", {{coordinate, version}}}}}}}, {"future", 77}}.dump()); + tk::write_file(source / "subos/default/.xlings.json", Json{{"workspace", workspace}}.dump()); + const auto sourcePrimary = tk::read_file(source / ".xlings.json"); + const auto sourceScope = tk::read_file(source / "subos/default/.xlings.json"); + const auto sourceEvidence = tk::read_file(payload / ".xlings-resolution.json"); + const auto repo = owner.root() / "index"; + const auto primary = owner.root() / "primary"; + tk::write_file(primary / "xim-indexrepos.lua", "xim_indexrepos = {}\n"); + fs::create_directories(primary / "pkgs"); + tk::write_file(repo / "xim-indexrepos.lua", "xim_indexrepos = {}\n"); + tk::write_file(repo / "pkgs/d/domain-source.lua", R"LUA( + local proc = assert(io.open('/proc/self/status', 'r')) + local identity = proc:read('*a'); proc:close() + assert(identity:match('Uid:%s+(%d+)') == '1000', 'source worker lost the mapped owner identity') + assert(identity:match('CapEff:%s+([%x]+)'):match('^0+$'), 'source worker gained capabilities') + package = {spec='1', name='domain-source', type='subos', archs={'x86_64','aarch64'}, + xpm={linux={['1.0.0']={}}}} + import('xim.libxpkg.pkginfo') + import('xim.libxpkg.xvm') + function install() error('a checked system payload must not be installed again') end + function config() + local dir = pkginfo.install_dir() + assert(dir == '/run/xlings-system-source/data/xpkgs/fixture-x-domain-source/1.0.0') + local f = io.open(path.join(dir, 'hook-write'), 'w') + assert(not f, 'system payload is writable') + xvm.add('source-member', {bindir=path.join(dir, 'bin'), + alias='/xlings/data/xpkgs/fixture-x-domain-source/1.0.0/bin/source-member'}) + return true + end + )LUA"); + tk::write_file(owner.dir() / ".xlings.json", Json{{"mirror", "GLOBAL"}, {"xim", {{"index-repo", primary.generic_string()}}}, + {"index_repos", Json::array({{{"name", "xim"}, {"url", primary.generic_string()}, {"source", "git"}}, + {{"name", "fixture"}, {"url", repo.generic_string()}, {"source", "git"}}})}}.dump()); + auto selected = domain::resolve(owner.dir(), "/xlings", physicalSource); + ASSERT_TRUE(selected) << selected.error(); + auto prepared = producer::prepare(*selected, tk::xlings_binary()); + ASSERT_TRUE(prepared) << prepared.error(); + // run() refreshes from the calling CLI image; this caller is a GTest + // program. Select the candidate explicitly and execute the same command. + int operation { 0 }; + auto runProducer = [&](std::span arguments, + std::optional output = std::nullopt) + -> std::expected { + auto refreshed = producer::refresh_entry(*selected, tk::xlings_binary()); + if (!refreshed) return std::unexpected(refreshed.error()); + const auto stage = owner.root() / ("source-view-" + std::to_string(operation++)); + std::error_code ec; + if (!fs::create_directory(stage, ec) || ec) + return std::unexpected("cannot reserve fixture source facade " + stage.string() + ": " + ec.message()); + auto facade = xlings::home::domain_producer_source::prepare(*selected, stage); + if (!facade) return std::unexpected(facade.error()); + auto command = producer::command(*selected, arguments, output, &*facade); + if (!command) return std::unexpected(command.error()); + return tk::run({.argv = std::move(*command), .env = owner.env(), .cwd = owner.root(), + .timeout = std::chrono::seconds(120)}); + }; + const std::vector initialize{"self", "init"}; + auto initialized = runProducer(initialize); + ASSERT_TRUE(initialized) << initialized.error(); + ASSERT_FALSE(initialized->timed_out) << initialized->transcript(); + ASSERT_EQ(initialized->exit_code, 0) << "signal=" << initialized->signal << '\n' << initialized->transcript(); + const std::vector create{"subos", "new", "source-root", "--rootfs", "--from", "fixture:domain-source@1.0.0"}; + auto created = runProducer(create); + ASSERT_TRUE(created) << created.error(); + ASSERT_FALSE(created->timed_out) << created->transcript(); + ASSERT_EQ(created->exit_code, 0) << "signal=" << created->signal << '\n' << created->transcript(); + ASSERT_TRUE(producer::publish_scope(*selected, "source-root")); + const auto copiedSlot = selected->physicalHome / "data/xpkgs/fixture-x-domain-source/1.0.0"; + EXPECT_TRUE(fs::is_empty(copiedSlot)) << "logical mountpoints hold no copied system payload"; + const auto privateConfig = Json::parse(tk::read_file(selected->physicalHome / ".xlings.json")); + const Json::json_pointer registration("/versions/source-member/versions/fixture:1.0.0"); + ASSERT_TRUE(privateConfig.contains(registration)) << privateConfig.dump(2); + const auto& borrowed = privateConfig.at(registration); + ASSERT_TRUE(borrowed.contains("layer") && borrowed.at("layer").contains("home")) << borrowed.dump(2); + ASSERT_TRUE(borrowed.contains("path")) << borrowed.dump(2); + EXPECT_EQ(borrowed.at("layer").at("home"), physicalSource.generic_string()); + EXPECT_EQ(borrowed.at("path"), "/run/xlings-system-source/data/xpkgs/fixture-x-domain-source/1.0.0/bin"); + auto status = owner.xlings({"subos", "status", "source-root", "--json"}); + ASSERT_EQ(status.exit_code, 0) << status.transcript(); + EXPECT_EQ(Json::parse(status.out)["root"]["kind"], "rootfs"); + const auto output = owner.root() / "export"; + fs::create_directory(output); + const std::vector exportArgs{"subos", "export", "source-root", "--rootfs", "/run/xlings-domain-output/rootfs"}; + auto exported = runProducer(exportArgs, producer::OutputBinding{output, "/run/xlings-domain-output"}); + ASSERT_TRUE(exported) << exported.error(); + ASSERT_FALSE(exported->timed_out) << exported->transcript(); + ASSERT_EQ(exported->exit_code, 0) << "signal=" << exported->signal << '\n' << exported->transcript(); + const auto imageHome = output / "rootfs/xlings"; + EXPECT_EQ(tk::read_file(imageHome / "data/xpkgs/fixture-x-domain-source/1.0.0/bin/source-member"), "#!/bin/sh\nexit 0\n"); + const auto imageConfig = Json::parse(tk::read_file(imageHome / ".xlings.json")); + ASSERT_TRUE(imageConfig.contains(registration)) << imageConfig.dump(2); + const auto& imageVersion = imageConfig.at(registration); + ASSERT_TRUE(imageVersion.contains("path")) << imageVersion.dump(2); + EXPECT_EQ(imageVersion.at("path"), "/xlings/data/xpkgs/fixture-x-domain-source/1.0.0/bin"); + EXPECT_FALSE(imageVersion.contains("layer")); + EXPECT_EQ(Json::parse(tk::read_file(imageHome / "data/xpkgs/fixture-x-domain-source/1.0.0/.xlings-resolution.json"))["future"], + "unknown record remains"); + EXPECT_FALSE(fs::exists(payload / "hook-write")); + EXPECT_EQ(tk::read_file(source / ".xlings.json"), sourcePrimary); + EXPECT_EQ(tk::read_file(source / "subos/default/.xlings.json"), sourceScope); + EXPECT_EQ(tk::read_file(payload / ".xlings-resolution.json"), sourceEvidence); +} + +XTEST(RootExport, OwnsBorrowedClosureAndOmitsOtherScopeVersions, + .area = "subos", .cost = tk::Cost::Medium, + .covers = {"HOME-LAYER-RESOLVE"}, + .requires_ = {"linux", "xlings-bin"}) { + if constexpr (!tk::is_linux) GTEST_SKIP() << "Linux root export"; + const auto executable = xlings::elfread::read(tk::xlings_binary()); + if (!executable || !executable->interpreter.empty()) GTEST_SKIP() << "image export requires the static release binary"; + auto owner = tk::Home::isolated("mixed-source-export"); + fs::create_directories(owner.dir() / "bin"); + fs::copy_file(tk::xlings_binary(), owner.dir() / "bin/xlings"); + auto initialized = owner.xlings({"self", "init"}); + ASSERT_EQ(initialized.exit_code, 0) << initialized.transcript(); + auto created = owner.xlings({"subos", "new", "mixed-root", "--rootfs"}); + ASSERT_EQ(created.exit_code, 0) << created.transcript(); + const auto source = owner.root() / "system"; + const auto payload = source / "data/xpkgs/fixture-x-visible/1.0.0"; + const auto excluded = source / "data/xpkgs/fixture-x-visible/2.0.0"; + tk::write_file(payload / "bin/visible", "borrowed program bytes"); + tk::write_file(excluded / "bin/visible", "another scope's version"); + tk::write_file(payload / ".xlings-resolution.json", + R"({"package":"fixture:visible@1.0.0","deps":[],"future":42})"); + tk::write_file(source / ".xlings-home", R"({"mode":"root","layout":"multi"})"); + Json versions{{"visible", {{"type", "program"}, {"versions", { + {"fixture:1.0.0", {{"path", (payload / "bin").generic_string()}}}, + {"fixture:2.0.0", {{"path", (excluded / "bin").generic_string()}}}}}}}}; + Json workspace{{"visible", {{"active", "fixture:1.0.0"}, {"installed", Json::array({"fixture:1.0.0"})}}}}; + tk::write_file(source / ".xlings.json", Json{{"versions", versions}}.dump()); + tk::write_file(source / "subos/default/.xlings.json", Json{{"workspace", workspace}}.dump()); + auto configuration = Json::parse(tk::read_file(owner.dir() / ".xlings.json")); + versions["visible"]["versions"]["fixture:1.0.0"]["layer"] = {{"home", source.generic_string()}, {"scope", "default"}}; + configuration["versions"] = versions; + tk::write_file(owner.dir() / ".xlings.json", configuration.dump()); + const auto scopeFile = owner.dir() / "subos/mixed-root/.xlings.json"; + auto scope = Json::parse(tk::read_file(scopeFile)); + scope["workspace"] = workspace; + tk::write_file(scopeFile, scope.dump()); + const auto sourceBefore = tk::read_file(source / ".xlings.json"); + const auto output = owner.root() / "image"; + auto exported = owner.xlings({"subos", "export", "mixed-root", "--rootfs", output.string()}); + ASSERT_EQ(exported.exit_code, 0) << exported.transcript(); + const auto imageHome = output / owner.dir().relative_path(); + const auto image = Json::parse(tk::read_file(imageHome / ".xlings.json")); + const auto& imageVersions = image["versions"]["visible"]["versions"]; + ASSERT_EQ(imageVersions.size(), 1u); + EXPECT_FALSE(imageVersions.contains("fixture:2.0.0")); + EXPECT_FALSE(imageVersions["fixture:1.0.0"].contains("layer")); + EXPECT_EQ(imageVersions["fixture:1.0.0"]["path"], + (owner.dir() / "data/xpkgs/fixture-x-visible/1.0.0/bin").generic_string()); + EXPECT_EQ(tk::read_file(imageHome / "data/xpkgs/fixture-x-visible/1.0.0/bin/visible"), "borrowed program bytes"); + EXPECT_FALSE(fs::exists(imageHome / "data/xpkgs/fixture-x-visible/2.0.0")); + EXPECT_EQ(fs::read_symlink(output / payload.relative_path()), + owner.dir() / "data/xpkgs/fixture-x-visible/1.0.0"); + EXPECT_EQ(Json::parse(tk::read_file(imageHome / "data/xpkgs/fixture-x-visible/1.0.0/.xlings-resolution.json"))["future"], 42); + EXPECT_EQ(tk::read_file(source / ".xlings.json"), sourceBefore); + EXPECT_EQ(tk::read_file(excluded / "bin/visible"), "another scope's version"); +} diff --git a/tests/e2e/test_isolation_doctor.cpp b/tests/e2e/test_isolation_doctor.cpp new file mode 100644 index 000000000..872ae0e5b --- /dev/null +++ b/tests/e2e/test_isolation_doctor.cpp @@ -0,0 +1,85 @@ +// `self doctor --isolation` (C21) and the backend lookup order (#640 F10). +// The root repair itself runs on a CI runner of its own +// (tests/e2e/isolation_doctor_fix_test.sh): it installs files as root. +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.libs.json; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; + +XTEST(IsolationDoctor, ReportsEveryBackendItFoundAndWhatTheProbeSaid, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"DOC-ISOLATION", "F10", "CARRIER-LOCAL"}, + .requires_ = {"linux", "xlings-bin"}) { + auto home = tk::Home::isolated("doctor-iso"); + home.seed_sandbox_backend(); + auto r = home.xlings({"self", "doctor", "--isolation", "--json"}); + auto j = nlohmann::json::parse(r.out, nullptr, false); + ASSERT_FALSE(j.is_discarded()) << r.transcript(); + EXPECT_EQ(j["gates"].size(), 8u); + EXPECT_TRUE(j["sysctl"].contains("kernel.apparmor_restrict_unprivileged_userns")); + // Where a SubOS can run here: Linux has its own kernel (part 3 §5). + ASSERT_TRUE(j["carriers"].is_array()) << j.dump(); + ASSERT_FALSE(j["carriers"].empty()); + EXPECT_EQ(j["carriers"][0]["name"], "local"); + EXPECT_TRUE(j["carriers"][0]["supported"].get()); + // Root-owned first, then the system's, then the payload -- each probed. + std::vector order; + for (auto& b : j["bwrap"]) order.push_back(b["source"].get()); + auto rank = [](const std::string& s) { return s == "root-owned" ? 0 : s == "system" ? 1 : 2; }; + EXPECT_TRUE(std::ranges::is_sorted(order, {}, rank)) << j["bwrap"].dump(); + if (j["ok"].get()) { + // The backend is the first usable candidate, not "the payload because + // it is ours". + std::string first_usable; + for (auto& b : j["bwrap"]) if (b["usable"].get()) { first_usable = b["path"]; break; } + EXPECT_EQ(j["backend"]["path"], first_usable); + EXPECT_EQ(r.exit_code, 0); + } else { + EXPECT_NE(r.exit_code, 0); + } + // No setuid anywhere in what it chose or offered. + EXPECT_EQ(r.out.find("chmod 4755"), std::string::npos); +} + +XTEST(IsolationDoctor, ProbesAreCachedPerBootAndTraced, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"ISO-CAPS-CACHE", "OBS-TRACE"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}) { + auto home = tk::Home::isolated("caps-cache"); + home.seed_sandbox_backend(); + ASSERT_EQ(home.xlings({"subos", "new", "box"}).exit_code, 0); + auto first = home.xlings({"subos", "exec", "box", "--sandbox", "--", "true"}, {{"XLINGS_TRACE", "caps"}}); + auto second = home.xlings({"subos", "exec", "box", "--sandbox", "--", "true"}, {{"XLINGS_TRACE", "caps,spec"}}); + EXPECT_NE(first.err.find("[trace:caps]"), std::string::npos) << first.transcript(); + EXPECT_NE(first.err.find("probed"), std::string::npos) << first.transcript(); + EXPECT_NE(second.err.find("cached"), std::string::npos) << second.transcript(); + EXPECT_EQ(second.err.find("probed"), std::string::npos) << second.transcript(); + EXPECT_NE(second.err.find("[trace:spec]"), std::string::npos); + EXPECT_TRUE(fs::exists(home.dir() / "state" / "isolation-caps.json")); +} + +XTEST(IsolationDoctor, APlatformWithoutTheIsolationSaysSoInOneFormat, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"PLAT-HINT"}, + .requires_ = {"xlings-bin"}) { + if (tk::probe("linux")) { // a reason it is not Linux: macOS or Windows + auto home = tk::Home::isolated("plat-hint"); + ASSERT_EQ(home.xlings({"self", "init"}, {}, std::chrono::minutes(3)).exit_code, 0); + ASSERT_EQ(home.xlings({"subos", "new", "box"}).exit_code, 0); + auto r = home.xlings({"subos", "exec", "box", "--sandbox=private", "--", "true"}); + EXPECT_EQ(r.exit_code, 125) << r.transcript(); + EXPECT_NE(r.transcript().find("not implemented on this platform yet"), std::string::npos) + << r.transcript(); + return; + } + // Linux: the same shared format, for a requirement this host cannot meet. + auto home = tk::Home::isolated("plat-hint"); + ASSERT_EQ(home.xlings({"subos", "new", "box"}).exit_code, 0); + tk::write_file(home.dir() / "data" / "xpkgs" / "xim-x-proot" / "0" / "bin" / "proot", "#!/bin/sh\nexit 0\n"); + fs::permissions(home.dir() / "data" / "xpkgs" / "xim-x-proot" / "0" / "bin" / "proot", fs::perms::owner_all); + auto r = home.xlings({"subos", "exec", "box", "--sandbox", "proot", "--sandbox=locked", "--", "true"}); + EXPECT_EQ(r.exit_code, 125) << r.transcript(); + EXPECT_NE(r.transcript().find("cannot enter"), std::string::npos) << r.transcript(); +} diff --git a/tests/e2e/test_lua_worker.cpp b/tests/e2e/test_lua_worker.cpp new file mode 100644 index 000000000..90a8a3c4e --- /dev/null +++ b/tests/e2e/test_lua_worker.cpp @@ -0,0 +1,452 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import mcpplibs.xpkg.executor; +import xlings.platform.worker; +import xlings.core.xim.lua_protocol; +import xlings.core.elfread; +import xlings.libs.json; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; +namespace xp = mcpplibs::xpkg; +namespace protocol = xlings::xim::lua_protocol; +namespace transport = xlings::platform::worker; + +XTEST(LuaWorker, DedicatedControlPipesPreserveOneLuaStateAndCumulativeEffects, .area = "xim", + .requires_ = {"xlings-bin"}) { + auto home = tk::Home::isolated("lua-worker-protocol"); + const auto recipe = home.root() / "worker.lua"; + tk::write_file(recipe, R"LUA( + io.stdout:write('raw top-level output is never a control message\n') + import('xim.libxpkg.xvm') + import('xim.libxpkg.pkgmanager') + local configured = false + function install() + configured = true + xvm.add('first', {version='1.0.0', args={'space value'}}) + pkgmanager.install('fixture:extra@1.0.0') + return true + end + function config() + assert(configured, 'Lua state was reloaded') + xvm.add('second', {type='files', src='share/data', dst='usr/share/data'}) + pkgmanager.remove('fixture:old') + return true + end + )LUA"); + auto process = transport::Process::launch({tk::xlings_binary().string(), "__xpkg-worker", + std::string(transport::kReadToken), + std::string(transport::kWriteToken)}, + home.env()); + ASSERT_TRUE(process) << process.error(); + auto invoke = [&](xp::HookInvocation request) { + nlohmann::json input = {{"protocol", xp::kHookBoundaryProtocol}, + {"operation", "executor"}, + {"invocation", protocol::encode(request)}, + {"environment", nlohmann::json::object()}, + {"cwd", home.root().generic_string()}, + {"capture_log", (home.root() / "recipe.log").generic_string()}}; + auto wire = process->exchange(input.dump()); + if (!wire) + throw std::runtime_error(wire.error()); + auto answer = nlohmann::json::parse(*wire); + if (!answer.at("ok").get()) + throw std::runtime_error(answer.at("error").get()); + return protocol::response(answer.at("value")); + }; + auto executor = xp::create_executor(recipe, invoke); + ASSERT_TRUE(executor) << executor.error(); + EXPECT_NE(tk::read_file(home.root() / "recipe.log").find("raw top-level output"), + std::string::npos); + xp::ExecutionContext context; + context.install_dir = home.root() / "payload"; + context.pkg_name = "worker"; + context.version = "1.0.0"; + ASSERT_TRUE(executor->run_hook(xp::HookType::Install, context).success); + ASSERT_EQ(executor->xvm_operations().size(), 1u); + ASSERT_TRUE(executor->run_hook(xp::HookType::Config, context).success); + ASSERT_EQ(executor->xvm_operations().size(), 2u); + EXPECT_EQ(executor->xvm_operations()[0].args, (std::vector{"space value"})); + ASSERT_EQ(executor->install_requests().size(), 2u); + EXPECT_EQ(executor->install_requests()[1].target, "fixture:old"); +} + +XTEST(LuaWorker, MalformedPolicyStopsBeforeAnyRecipeTopLevelCode, .area = "xim", + .requires_ = {"xlings-bin"}) { + auto home = tk::Home::isolated("lua-worker-no-fallback"); + ASSERT_EQ(home.xlings({"subos", "new", "box"}).exit_code, 0); + const auto marker = home.root() / "host-top-level"; + const auto script = home.root() / "script.lua"; + tk::write_file(script, "local f=assert(io.open([[" + marker.generic_string() + + "]],'w')); f:write('host');f:close()\nfunction xpkg_main() end\n"); + tk::write_file(home.dir() / "config" / "subos" / "box" / "policy.json", "{invalid policy"); + const auto result = home.xlings({"script", script.string()}, {{"XLINGS_ACTIVE_SUBOS", "box"}}); + EXPECT_NE(result.exit_code, 0) << result.transcript(); + EXPECT_FALSE(fs::exists(marker)); + EXPECT_NE(result.transcript().find("policy"), std::string::npos); +} + +XTEST(LuaWorker, DeclaredPolicyIsolatesWholeLuaIncludingIoExecuteAndPopen, .area = "xim", + .requires_ = {"linux", "sandbox", "xlings-bin"}, .resources = {"sandbox"}, + .proves = "isolation") { + auto home = tk::Home::isolated("lua-worker-isolation"); + ASSERT_TRUE(home.seed_sandbox_backend()); + ASSERT_EQ(home.xlings({"subos", "new", "box"}).exit_code, 0); + ASSERT_EQ(home.xlings({"subos", "config", "box", "--sandbox=dev"}).exit_code, 0); + const auto recipe_logs = home.dir() / "logs" / "recipes"; + ASSERT_FALSE(fs::exists(recipe_logs)); + const auto forbidden = home.root() / "host-secret"; + const auto external = home.root() / "external-recipes"; + const auto script = home.dir() / "data/external-recipes/script.lua"; + fs::create_directories(external); + fs::create_directories(script.parent_path().parent_path()); + fs::create_directory_symlink(external, script.parent_path()); + tk::write_file(forbidden, "host original"); + const auto quoted = "'" + forbidden.generic_string() + "'"; + const std::string load_output = "RECIPE_LOAD_OUTPUT_BELONGS_IN_ITS_LOG"; + tk::write_file(script, "print('" + load_output + "')\nlocal r=io.open([[" + + forbidden.generic_string() + + "]],'r'); assert(r==nil, 'unrelated host file became visible')\n" + "local f=io.open([[" + + forbidden.generic_string() + + "]],'w'); if f then f:write('virtual top-level'); f:close() end\n" + "function xpkg_main()\n" + " os.execute([[printf escaped > " + + quoted + + "]])\n" + " local p=assert(io.popen([[printf escaped > " + + quoted + + "; printf alive]])); assert(p:read('*a')=='alive'); p:close()\n" + "end\n"); + const auto result = home.xlings({"script", script.string()}, {{"XLINGS_ACTIVE_SUBOS", "box"}}); + EXPECT_EQ(result.exit_code, 0) << result.transcript(); + EXPECT_EQ(result.out.find(load_output), std::string::npos); + ASSERT_TRUE(fs::is_directory(recipe_logs)); + bool output_published = false; + for (const auto& file : fs::directory_iterator(recipe_logs)) { + if (file.path().filename().string().ends_with(".executor.load.log") && + tk::read_file(file.path()).find(load_output) != std::string::npos) + output_published = true; + } + EXPECT_TRUE(output_published); + EXPECT_EQ(tk::read_file(forbidden), "host original"); + EXPECT_TRUE(fs::is_symlink(script.parent_path())); +} + +XTEST(LuaWorker, RecipeLogDirectorySymlinkRefusesBeforeRecipeLoad, .area = "xim", + .requires_ = {"linux", "sandbox", "xlings-bin"}, .resources = {"sandbox"}, + .proves = "isolation") { + auto home = tk::Home::isolated("lua-log-symlink"); + ASSERT_TRUE(home.seed_sandbox_backend()); + ASSERT_EQ(home.xlings({"subos", "new", "box"}).exit_code, 0); + ASSERT_EQ(home.xlings({"subos", "config", "box", "--sandbox=dev"}).exit_code, 0); + const auto outside = home.root() / "user-log-directory"; + const auto sentinel = outside / "user-file"; + tk::write_file(sentinel, "user-owned bytes"); + const auto before = fs::last_write_time(sentinel); + const auto recipe_logs = home.dir() / "logs" / "recipes"; + fs::create_directories(recipe_logs.parent_path()); + fs::create_directory_symlink(outside, recipe_logs); + const auto script = home.root() / "script.lua"; + tk::write_file(script, "error('RECIPE_TOP_LEVEL_MUST_NOT_RUN')\nfunction xpkg_main() end\n"); + const auto result = home.xlings({"script", script.string()}, {{"XLINGS_ACTIVE_SUBOS", "box"}}); + EXPECT_NE(result.exit_code, 0) << result.transcript(); + EXPECT_NE(result.transcript().find("recipe log directory"), std::string::npos); + EXPECT_EQ(result.transcript().find("RECIPE_TOP_LEVEL_MUST_NOT_RUN"), std::string::npos); + EXPECT_TRUE(fs::is_symlink(recipe_logs)); + EXPECT_EQ(tk::read_file(sentinel), "user-owned bytes"); + EXPECT_EQ(fs::last_write_time(sentinel), before); + EXPECT_EQ(std::distance(fs::directory_iterator(outside), fs::directory_iterator{}), 1); +} + +namespace { +void expect_payloadless_config_removal(bool sandbox) { + auto home = tk::Home::isolated(sandbox ? "lua-config-remove" : "config-remove-legacy"); + if (sandbox) + ASSERT_TRUE(home.seed_sandbox_backend()); + const auto repo = home.root() / "index"; + tk::write_file(repo / "xim-indexrepos.lua", "xim_indexrepos = {}\n"); + for (const auto name : {"config-explicit", "config-bare"}) { + tk::write_file(repo / "pkgs/c" / (std::string(name) + ".lua"), + "package={spec='1',name='" + std::string(name) + + "',type='config',archs={'x86_64','aarch64'}," + "xpm={linux={['latest']={ref='1.0.0'},['1.0.0']={}}}}\n" + "function uninstall() print('PAYLOADLESS_UNINSTALL_" + + name + "'); return true end\n"); + } + tk::write_file(repo / "pkgs/c/config-failed.lua", + "package={spec='1',name='config-failed',type='config'," + "archs={'x86_64','aarch64'},xpm={linux={['1.0.0']={}}}}\n" + "function uninstall() error('PAYLOADLESS_UNINSTALL_ACTUAL_FAILURE') end\n"); + const auto primary = home.root() / "empty-primary"; + tk::write_file(primary / "xim-indexrepos.lua", "xim_indexrepos = {}\n"); + fs::create_directories(primary / "pkgs"); + nlohmann::json config = { + {"mirror", "GLOBAL"}, + {"xim", {{"index-repo", primary.generic_string()}}}, + {"index_repos", + nlohmann::json::array( + {{{"name", "xim"}, {"url", primary.generic_string()}, {"source", "git"}}, + {{"name", "fixture"}, {"url", repo.generic_string()}, {"source", "git"}}})}}; + tk::write_file(home.dir() / ".xlings.json", config.dump()); + fs::create_directories(home.dir() / "bin"); + fs::copy_file(tk::xlings_binary(), home.dir() / "bin/xlings"); + const auto initialized = home.xlings({"self", "init"}); + ASSERT_EQ(initialized.exit_code, 0) << initialized.transcript(); + ASSERT_EQ(home.xlings({"subos", "new", "box"}).exit_code, 0); + if (sandbox) + ASSERT_EQ(home.xlings({"subos", "config", "box", "--sandbox=dev"}).exit_code, 0); + for (const auto name : {"config-explicit", "config-bare"}) { + const auto package_parent = home.dir() / "data/xpkgs" / ("fixture-x-" + std::string(name)); + ASSERT_FALSE(fs::exists(package_parent)); + const auto coordinate = "fixture:" + std::string(name) + + (std::string_view(name) == "config-explicit" ? "@1.0.0" : ""); + const auto result = + home.xlings({"remove", coordinate, "-y"}, {{"XLINGS_ACTIVE_SUBOS", "box"}}); + ASSERT_EQ(result.exit_code, 0) << result.transcript(); + const auto log = + home.dir() / "logs/hooks" / ("fixture-" + std::string(name) + "@1.0.0.uninstall.log"); + const auto output = tk::read_file(log); + const auto marker = "PAYLOADLESS_UNINSTALL_" + std::string(name); + const auto first = output.find(marker); + ASSERT_NE(first, std::string::npos) << result.transcript() << '\n' << output; + EXPECT_EQ(output.find(marker, first + marker.size()), std::string::npos); + EXPECT_FALSE(fs::exists(package_parent / "1.0.0")); + } + const auto explicit_log = home.dir() / "logs/hooks/fixture-config-explicit@1.0.0.uninstall.log"; + const auto previous_log = tk::read_file(explicit_log); + const auto invalid = home.xlings({"remove", "fixture:config-explicit@9.9.9", "-y"}, + {{"XLINGS_ACTIVE_SUBOS", "box"}}); + EXPECT_NE(invalid.exit_code, 0) << invalid.transcript(); + EXPECT_EQ(tk::read_file(explicit_log), previous_log); + const auto failed = home.xlings({"remove", "fixture:config-failed@1.0.0", "-y"}, + {{"XLINGS_ACTIVE_SUBOS", "box"}}); + EXPECT_NE(failed.exit_code, 0) << failed.transcript(); + EXPECT_NE(failed.transcript().find("PAYLOADLESS_UNINSTALL_ACTUAL_FAILURE"), std::string::npos) + << failed.transcript(); +} +} // namespace + +XTEST(LuaWorker, PayloadlessConfigRemovalInspectsReadonlyAndRunsEachHookOnce, .area = "xim", + .requires_ = {"linux", "sandbox", "xlings-bin"}, .resources = {"sandbox"}, + .proves = "isolation") { + expect_payloadless_config_removal(true); +} + +XTEST(LuaWorker, UndeclaredPayloadlessConfigRemovalRunsWithoutASandbox, .area = "xim", + .requires_ = {"linux", "xlings-bin"}) { + expect_payloadless_config_removal(false); +} + +XTEST(LuaWorker, RuntimeInterpreterAliasUnderPrivateTmpRemainsExecutableAndReadonly, .area = "xim", + .requires_ = {"linux", "sandbox", "xlings-bin"}, .resources = {"sandbox"}, + .proves = "isolation") { + const auto runtime = xlings::elfread::read(tk::xlings_binary()); + ASSERT_TRUE(runtime); + if (runtime->interpreter.empty()) + GTEST_SKIP() << "static entry has no PT_INTERP alias to exercise"; + auto home = tk::Home::isolated("lua-loader"); + ASSERT_TRUE(home.seed_sandbox_backend()); + ASSERT_EQ(home.xlings({"subos", "new", "box"}).exit_code, 0); + + struct LoaderStage { + fs::path root; + ~LoaderStage() { + if (!root.empty()) { + std::error_code error; + fs::remove_all(root, error); // exclusively reserved fixture directory + } + } + } stage; + std::random_device random; + for (int attempt = 0; attempt != 32; ++attempt) { + const auto candidate = fs::path("/tmp") / std::format("lw{:08x}", random()); + if (fs::create_directory(candidate)) { + stage.root = candidate; + break; + } + } + ASSERT_FALSE(stage.root.empty()); + const auto loader = stage.root / "l" / "ld"; + fs::create_directory(stage.root / "s"); + fs::create_directory(stage.root / "f"); + fs::copy_file(fs::canonical(runtime->interpreter), stage.root / "f" / "ld"); + fs::create_symlink(stage.root / "f" / "ld", stage.root / "s" / "ld"); + fs::create_directory_symlink("s", stage.root / "l"); + ASSERT_LE(loader.generic_string().size(), runtime->interpreter.size()); + const auto original_loader = tk::read_file(loader); + auto entry_bytes = tk::read_file(tk::xlings_binary()); + const auto position = entry_bytes.find(runtime->interpreter + '\0'); + ASSERT_NE(position, std::string::npos); + const auto replacement = loader.generic_string(); + entry_bytes.replace(position, runtime->interpreter.size(), + replacement + + std::string(runtime->interpreter.size() - replacement.size(), '\0')); + const auto entry = home.root() / "entry" / "xlings"; + fs::create_directories(entry.parent_path()); + fs::copy_file(tk::xlings_binary(), entry); + tk::write_file(entry, entry_bytes); + const auto patched = xlings::elfread::read(entry); + ASSERT_TRUE(patched); + ASSERT_EQ(patched->interpreter, replacement); + auto environment = home.env(); + environment["XLINGS_ACTIVE_SUBOS"] = "box"; + const auto host = + tk::run({.argv = {entry.string(), "--version"}, .env = environment, .cwd = home.root()}); + ASSERT_EQ(host.exit_code, 0) << host.transcript(); + const auto secret = stage.root / "unrelated-host-data"; + tk::write_file(secret, "host secret"); + const auto script = home.root() / "script.lua"; + tk::write_file(script, "assert(io.open([[" + secret.generic_string() + + "]], 'r') == nil, 'runtime mount exposed its parent')\n" + "assert(io.open([[" + + replacement + + "]], 'w') == nil, 'runtime loader is writable')\n" + "function xpkg_main() os.execute('uname >/dev/null') end\n"); + for (const auto preset : {"dev", "locked"}) { + ASSERT_EQ( + home.xlings({"subos", "config", "box", "--sandbox=" + std::string(preset)}).exit_code, + 0); + const auto result = tk::run({.argv = {entry.string(), "script", script.string()}, + .env = environment, + .cwd = home.root()}); + EXPECT_EQ(result.exit_code, 0) << preset << '\n' << result.transcript(); + } + EXPECT_EQ(tk::read_file(loader), original_loader); + EXPECT_EQ(tk::read_file(secret), "host secret"); + EXPECT_TRUE(fs::is_symlink(stage.root / "l")); + const auto journal = tk::read_file(home.dir() / "logs" / "subos" / "box" / "events.ndjson"); + EXPECT_NE(journal.find("uname"), std::string::npos); +} + +XTEST(LuaWorker, FullObservationAuditsHookSubprocessesWithoutArgumentValues, .area = "xim", + .requires_ = {"linux", "sandbox", "xlings-bin"}, .resources = {"sandbox"}, + .proves = "isolation") { + auto home = tk::Home::isolated("lua-worker-full-observe"); + ASSERT_TRUE(home.seed_sandbox_backend()); + ASSERT_EQ(home.xlings({"subos", "new", "box"}).exit_code, 0); + ASSERT_EQ(home.xlings({"subos", "config", "box", "--sandbox=locked"}).exit_code, 0); + const auto script = home.root() / "script.lua"; + tk::write_file(script, + "os.execute('true')\nfunction xpkg_main(argument)\n" + " os.execute('uname >/dev/null')\n" + " os.execute([[bash -c 'echo probe >/dev/tcp/127.0.0.1/9' >/dev/null 2>&1]])\n" + "end\n"); + const std::string secret = "ARGUMENT_VALUE_MUST_NOT_BE_AUDITED"; + const auto result = + home.xlings({"script", script.string(), secret}, {{"XLINGS_ACTIVE_SUBOS", "box"}}); + ASSERT_EQ(result.exit_code, 0) << result.transcript(); + const auto journal = tk::read_file(home.dir() / "logs" / "subos" / "box" / "events.ndjson"); + EXPECT_NE(journal.find("uname"), std::string::npos); + EXPECT_NE(journal.find("net-attempt"), std::string::npos); + EXPECT_NE(journal.find("127.0.0.1"), std::string::npos); + EXPECT_NE(journal.find("\"result\":\"unknown\""), std::string::npos); + EXPECT_NE(journal.find("\"worker\":\"lua\""), std::string::npos); + EXPECT_EQ(journal.find(secret), std::string::npos); +} + +XTEST(LuaWorker, LockedAuditFailureRefusesLuaBeforeItsTopLevelRuns, .area = "xim", + .requires_ = {"linux", "sandbox", "xlings-bin"}, .resources = {"sandbox"}, + .proves = "isolation") { + auto home = tk::Home::isolated("lua-worker-locked-audit"); + ASSERT_TRUE(home.seed_sandbox_backend()); + ASSERT_EQ(home.xlings({"subos", "new", "box"}).exit_code, 0); + ASSERT_EQ(home.xlings({"subos", "config", "box", "--sandbox=locked"}).exit_code, 0); + const auto journal = home.dir() / "logs" / "subos" / "box" / "events.ndjson"; + std::error_code ec; + fs::remove(journal, ec); + fs::create_directories(journal); + const auto script = home.root() / "script.lua"; + tk::write_file(script, "error('RECIPE_TOP_LEVEL_MUST_NOT_RUN')\nfunction xpkg_main() end\n"); + const auto result = home.xlings({"script", script.string()}, {{"XLINGS_ACTIVE_SUBOS", "box"}}); + EXPECT_NE(result.exit_code, 0) << result.transcript(); + EXPECT_NE(result.transcript().find("audit"), std::string::npos); + EXPECT_EQ(result.transcript().find("RECIPE_TOP_LEVEL_MUST_NOT_RUN"), std::string::npos); +} + +XTEST(LuaWorker, HostEffectsRejectHostSourcesTraversalAndSymlinksButAcceptOwnedPayloadAssets, + .area = "xim", .cost = tk::Cost::Medium, .requires_ = {"linux", "sandbox", "xlings-bin"}, + .resources = {"sandbox"}, .proves = "isolation") { + auto home = tk::Home::isolated("lua-worker-host-effects"); + ASSERT_TRUE(home.seed_sandbox_backend()); + const auto secret = home.root() / "private-user-file"; + tk::write_file(secret, "never expose this host data through a recipe declaration"); + const auto sibling = home.dir() / "data/xpkgs/unrelated-x-payload/1.0.0/user-file"; + tk::write_file(sibling, "unrelated payload bytes"); + const auto repo = home.root() / "index"; + tk::write_file(repo / "xim-indexrepos.lua", "xim_indexrepos = {}\n"); + tk::write_file( + repo / "pkgs" / "f" / "fixture.lua", + R"LUA( + package = { + spec = '1', name = 'fixture', type = 'package', archs = {'x86_64','aarch64'}, + xpm = {linux = {['1.0.0']={}, ['2.0.0']={}, ['3.0.0']={}, ['4.0.0']={}}} + } + import('xim.libxpkg.pkginfo') + import('xim.libxpkg.xvm') + function install() + )LUA" + std::string("local sibling = io.open([[") + sibling.generic_string() + + "]], 'w'); assert(not sibling, 'unrelated payload became writable')\n" + R"LUA( + os.mkdir(pkginfo.install_dir()) + io.writefile(path.join(pkginfo.install_dir(), 'asset'), 'owned payload') + if pkginfo.version() == '2.0.0' then + )LUA" + std::string("os.execute('ln -s ' .. string.format('%q', [[") + + secret.generic_string() + + "]]) .. ' ' .. string.format('%q', path.join(pkginfo.install_dir(), 'escape')))\n" + + R"LUA( + end + return true + end + function config() + local dir = pkginfo.install_dir() + xvm.add('fixture', {type='group'}) + if pkginfo.version() == '1.0.0' then + )LUA" + std::string("xvm.add('escape', {type='files', bindir=[[") + + secret.parent_path().generic_string() + + "]], src='private-user-file', dst='usr/share/forbidden'})\n" + R"LUA( + elseif pkginfo.version() == '2.0.0' then + xvm.add('escape', {type='files', src='escape', dst='usr/share/forbidden'}) + elseif pkginfo.version() == '3.0.0' then + xvm.add('escape', {type='files', src='../private-user-file', dst='usr/share/forbidden'}) + else + xvm.add('owned', {type='files', src='asset', dst='usr/share/owned'}) + end + return true + end + )LUA"); + const auto primary = home.root() / "empty-primary"; + tk::write_file(primary / "xim-indexrepos.lua", "xim_indexrepos = {}\n"); + fs::create_directories(primary / "pkgs"); + nlohmann::json config = { + {"mirror", "GLOBAL"}, {"xim", {{"index-repo", primary.generic_string()}}}, + {"index_repos", nlohmann::json::array({ + {{"name", "xim"}, {"url", primary.generic_string()}, {"source", "git"}}, + {{"name", "fixture"}, {"url", repo.generic_string()}, {"source", "git"}}})}}; + tk::write_file(home.dir() / ".xlings.json", config.dump()); + fs::create_directories(home.dir() / "bin"); + fs::copy_file(tk::xlings_binary(), home.dir() / "bin" / "xlings"); + const auto initialized = home.xlings({"self", "init"}); + ASSERT_EQ(initialized.exit_code, 0) << initialized.transcript(); + ASSERT_EQ(home.xlings({"subos", "new", "box"}).exit_code, 0); + ASSERT_EQ(home.xlings({"subos", "config", "box", "--sandbox=locked"}).exit_code, 0); + ASSERT_FALSE(fs::exists(home.dir() / "data/xpkgs/fixture-x-fixture")); + for (const auto version : {"1.0.0", "2.0.0", "3.0.0"}) { + const auto result = + home.xlings({"install", "fixture:fixture@" + std::string(version), "-y"}, + {{"XLINGS_ACTIVE_SUBOS", "box"}}); + EXPECT_NE(result.exit_code, 0) << result.transcript(); + EXPECT_NE(result.transcript().find("unsafe Lua host effect"), std::string::npos) + << result.transcript(); + EXPECT_FALSE(fs::exists(home.dir() / "subos" / "box" / "usr" / "share" / "forbidden")); + } + const auto allowed = + home.xlings({"install", "fixture:fixture@4.0.0", "-y"}, {{"XLINGS_ACTIVE_SUBOS", "box"}}); + ASSERT_EQ(allowed.exit_code, 0) << allowed.transcript(); + EXPECT_EQ(tk::read_file(home.dir() / "subos" / "box" / "usr" / "share" / "owned"), + "owned payload"); + EXPECT_EQ(tk::read_file(secret), "never expose this host data through a recipe declaration"); + EXPECT_EQ(tk::read_file(sibling), "unrelated payload bytes"); +} diff --git a/tests/e2e/test_policy_compatibility.cpp b/tests/e2e/test_policy_compatibility.cpp new file mode 100644 index 000000000..38fa7906e --- /dev/null +++ b/tests/e2e/test_policy_compatibility.cpp @@ -0,0 +1,97 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.libs.json; +import xlings.subos.policy; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; + +XTEST(PolicyCompatibility, NewPoliciesDeclareTheirMinimumClient, + .area = "subos", .covers = {"POLICY-MIN-CLIENT"}, .requires_ = {"xlings-bin"}) { + auto home = tk::Home::isolated("policy-client-new"); + ASSERT_EQ(home.xlings({"subos", "new", "box"}).exit_code, 0); + auto set = home.xlings({"subos", "config", "box", "--sandbox", "dev"}); + ASSERT_EQ(set.exit_code, 0) << set.transcript(); + auto doc = nlohmann::json::parse(tk::read_file(home.dir() / "config" / "subos" / "box" / "policy.json")); + ASSERT_TRUE(doc["min_client"].is_string()); + EXPECT_EQ(doc["min_client"].get(), xlings::subos::policy::kPolicyMinClient); + auto show = home.xlings({"subos", "config", "box", "--json"}); + ASSERT_EQ(show.exit_code, 0) << show.transcript(); + EXPECT_EQ(nlohmann::json::parse(show.out)["min_client"], doc["min_client"]); +} + +XTEST(PolicyCompatibility, OldPolicyFilesRemainReadable, + .area = "subos", .covers = {"POLICY-MIN-CLIENT"}, .requires_ = {"xlings-bin"}) { + auto home = tk::Home::isolated("policy-client-old"); + ASSERT_EQ(home.xlings({"subos", "new", "box"}).exit_code, 0); + tk::write_file(home.dir() / "config" / "subos" / "box" / "policy.json", R"({"extends":"dev"})"); + auto show = home.xlings({"subos", "config", "box", "--json"}); + ASSERT_EQ(show.exit_code, 0) << show.transcript(); + EXPECT_EQ(nlohmann::json::parse(show.out)["source"], "file"); +} + +XTEST(PolicyCompatibility, ANewerPolicyRefusesEntryAndDoctorExplainsTheUpgrade, + .area = "subos", .covers = {"POLICY-MIN-CLIENT"}, .requires_ = {"xlings-bin"}) { + auto home = tk::Home::isolated("policy-client-future"); + ASSERT_EQ(home.xlings({"subos", "new", "box"}).exit_code, 0); + const auto policy = home.dir() / "config" / "subos" / "box" / "policy.json"; + const std::string original = R"({"extends":"dev","min_client":"9999.1.1.1"})"; + tk::write_file(policy, original); + auto exec = home.xlings({"subos", "exec", "box", "--", "xlings", "--version"}); + EXPECT_EQ(exec.exit_code, 125) << exec.transcript(); + EXPECT_NE(exec.transcript().find("E_POLICY_CLIENT"), std::string::npos); + EXPECT_TRUE(exec.out.empty()) << exec.transcript(); + auto status = home.xlings({"subos", "status", "box", "--json"}); + auto snapshot = nlohmann::json::parse(status.out, nullptr, false); + ASSERT_FALSE(snapshot.is_discarded()) << status.transcript(); + EXPECT_EQ(snapshot["effective"]["enters"], false); + const auto entry = home.dir() / "bin" / (tk::is_windows ? "xlings.exe" : "xlings"); + fs::create_directories(entry.parent_path()); + std::error_code ec; + fs::remove(entry, ec); + ec.clear(); + fs::copy_file(tk::xlings_binary(), entry, fs::copy_options::none, ec); + ASSERT_FALSE(ec) << ec.message(); + auto doctor = home.xlings({"self", "doctor"}); + EXPECT_NE(doctor.exit_code, 0) << doctor.transcript(); + EXPECT_NE(doctor.transcript().find("policy client"), std::string::npos); + EXPECT_NE(doctor.transcript().find("9999.1.1.1"), std::string::npos); + EXPECT_NE(doctor.transcript().find("self update"), std::string::npos); + EXPECT_EQ(tk::read_file(policy), original); +} + +XTEST(PolicyCompatibility, DanglingPolicyDoesNotFallBackToAnUnsandboxedCommand, + .area = "subos", .covers = {"POL-UNKNOWN-REFUSED"}, .requires_ = {"posix", "xlings-bin"}) { + auto home = tk::Home::isolated("policy-dangling"); + ASSERT_EQ(home.xlings({"subos", "new", "box"}).exit_code, 0); + const auto policy = home.dir() / "config" / "subos" / "box" / "policy.json"; + fs::create_directories(policy.parent_path()); + fs::create_symlink("missing-policy-target", policy); + const auto exec = home.xlings({"subos", "exec", "box", "--", "/bin/sh", "-c", "echo POLICY_BYPASS_RAN"}); + EXPECT_EQ(exec.exit_code, 125) << exec.transcript(); + EXPECT_NE(exec.transcript().find("policy.json"), std::string::npos); + EXPECT_EQ(exec.out.find("POLICY_BYPASS_RAN"), std::string::npos); + auto edit = home.xlings({"subos", "config", "box", "--sandbox=dev"}); + EXPECT_NE(edit.exit_code, 0) << edit.transcript(); + EXPECT_EQ(fs::read_symlink(policy), "missing-policy-target"); +} + +XTEST(PolicyCompatibility, APolicyEditLeavesUnrelatedTemporaryFilesAndRejectsMalformedPolicies, + .area = "subos", .covers = {"POL-UNKNOWN-REFUSED"}, .requires_ = {"xlings-bin"}) { + auto home = tk::Home::isolated("policy-write-safety"); + ASSERT_EQ(home.xlings({"subos", "new", "box"}).exit_code, 0); + const auto policy = home.dir() / "config" / "subos" / "box" / "policy.json"; + const auto user_file = fs::path(policy.string() + ".tmp"); + tk::write_file(user_file, "user file; never truncate"); + auto edit = home.xlings({"subos", "config", "box", "--sandbox=dev"}); + ASSERT_EQ(edit.exit_code, 0) << edit.transcript(); + EXPECT_EQ(tk::read_file(user_file), "user file; never truncate"); + tk::write_file(policy, "{malformed policy"); + edit = home.xlings({"subos", "config", "box", "--sandbox=locked"}); + EXPECT_NE(edit.exit_code, 0) << edit.transcript(); + EXPECT_EQ(tk::read_file(policy), "{malformed policy"); + EXPECT_EQ(tk::read_file(user_file), "user file; never truncate"); +} diff --git a/tests/e2e/test_resolution_evidence.cpp b/tests/e2e/test_resolution_evidence.cpp new file mode 100644 index 000000000..7873621f0 --- /dev/null +++ b/tests/e2e/test_resolution_evidence.cpp @@ -0,0 +1,240 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.libs.json; +import xlings.core.config; +import xlings.libs.sha256; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; + +namespace { +nlohmann::json local_indexes(const fs::path& fixture) { + const auto primary = fixture.parent_path() / "empty-primary-index"; + fs::create_directories(primary / "pkgs"); + tk::write_file(primary / "xim-indexrepos.lua", "xim_indexrepos = {}\n"); + return {{"mirror", "GLOBAL"}, + {"xim", {{"index-repo", primary.generic_string()}}}, + {"index_repos", + nlohmann::json::array( + {{{"name", std::string(xlings::Config::DEFAULT_INDEX_REPO_NAME)}, + {"url", primary.generic_string()}, + {"source", "git"}}, + {{"name", "fixture"}, {"url", fixture.generic_string()}, {"source", "git"}}})}}; +} +} // namespace + +XTEST(ResolutionEvidence, FreshLeafAndExplicitMigrationAreCheckedBeforeSuccess, .area = "xim", + .covers = {"HOME-LAYER-RESOLVE"}, .requires_ = {"xlings-bin"}) { + auto home = tk::Home::isolated("resolution-evidence"); + const auto repo = home.root() / "index"; + tk::write_file(repo / "xim-indexrepos.lua", "xim_indexrepos = {}\n"); + tk::write_file(repo / "pkgs" / "r" / "resolution-leaf.lua", R"LUA( + package = { + spec = '1', name = 'resolution-leaf', type = 'script', + archs = {'x86_64', 'aarch64'}, + xpm = { + linux = {['1.0.0'] = {}}, + macosx = {['1.0.0'] = {}}, + windows = {['1.0.0'] = {}} + } + } + function xpkg_main() return true end + )LUA"); + tk::write_file(home.dir() / ".xlings.json", local_indexes(repo).dump()); + fs::create_directories(home.dir() / "bin"); + fs::copy_file(tk::xlings_binary(), + home.dir() / "bin" / (tk::is_windows ? "xlings.exe" : "xlings")); + const auto initialized = home.xlings({"self", "init"}); + ASSERT_EQ(initialized.exit_code, 0) << initialized.transcript(); + const auto installed = home.xlings({"install", "fixture:resolution-leaf@1.0.0", "-y"}); + ASSERT_EQ(installed.exit_code, 0) << installed.transcript(); + const auto evidence = home.dir() / "data" / "xpkgs" / "fixture-x-resolution-leaf" / "1.0.0" / + ".xlings-resolution.json"; + ASSERT_TRUE(fs::is_regular_file(evidence)); + const auto record = nlohmann::json::parse(tk::read_file(evidence)); + EXPECT_EQ(record.at("package"), "fixture:resolution-leaf@1.0.0"); + EXPECT_EQ(record.at("deps"), nlohmann::json::array()); + + ASSERT_TRUE(fs::remove(evidence)); + const auto reused = home.xlings({"install", "fixture:resolution-leaf@1.0.0", "-y"}); + ASSERT_EQ(reused.exit_code, 0) << reused.transcript(); + EXPECT_FALSE(fs::exists(evidence)) << "legacy reuse cannot invent an empty dependency closure"; + const auto migrated = + home.xlings({"install", "fixture:resolution-leaf@1.0.0", "--reconfig", "-y"}); + ASSERT_EQ(migrated.exit_code, 0) << migrated.transcript(); + ASSERT_TRUE(fs::is_regular_file(evidence)); + EXPECT_EQ(nlohmann::json::parse(tk::read_file(evidence)).at("deps"), nlohmann::json::array()); + + ASSERT_TRUE(fs::remove(evidence)); + ASSERT_TRUE(fs::create_directory(evidence)); + tk::write_file(evidence / "sentinel", "must survive failed recording"); + const auto refused = + home.xlings({"install", "fixture:resolution-leaf@1.0.0", "--reconfig", "-y"}); + EXPECT_NE(refused.exit_code, 0) << refused.transcript(); + EXPECT_NE(refused.transcript().find("resolution record is not a regular file"), + std::string::npos); + EXPECT_EQ(tk::read_file(evidence / "sentinel"), "must survive failed recording"); +} + +XTEST(EntryActivationE2E, ARecipeCannotClaimTheSharedEntryInAFreshHome, .area = "xim", + .requires_ = {"xlings-bin"}) { + auto home = tk::Home::isolated("entry-activation-forgery"); + const auto repo = home.root() / "index"; + tk::write_file(repo / "xim-indexrepos.lua", "xim_indexrepos = {}\n"); + tk::write_file(repo / "pkgs" / "f" / "forged.lua", R"LUA( + package = { + spec = '1', name = 'forged', type = 'package', archs = {'x86_64','aarch64'}, + xpm = {linux={['1.0.0']={}}, macosx={['1.0.0']={}}, windows={['1.0.0']={}}} + } + import('xim.libxpkg.pkginfo') + import('xim.libxpkg.xvm') + function install() + os.mkdir(pkginfo.install_dir()) + io.writefile(path.join(pkginfo.install_dir(), 'xlings'), 'fake shared client') + return true + end + function config() + xvm.add('xlings', {bindir=pkginfo.install_dir()}) + return true + end + )LUA"); + tk::write_file(home.dir() / ".xlings.json", local_indexes(repo).dump()); + const auto entry = home.dir() / "bin" / (tk::is_windows ? "xlings.exe" : "xlings"); + fs::create_directories(entry.parent_path()); + fs::copy_file(tk::xlings_binary(), entry); + const auto initialized = home.xlings({"self", "init"}); + ASSERT_EQ(initialized.exit_code, 0) << initialized.transcript(); + const auto before = xlings::sha256::hex_file(entry); + ASSERT_TRUE(before); + const auto result = home.xlings({"install", "fixture:forged@1.0.0", "-y"}); + EXPECT_NE(result.exit_code, 0) << result.transcript(); + EXPECT_NE(result.transcript().find("only the xlings package"), std::string::npos) + << result.transcript(); + EXPECT_EQ(xlings::sha256::hex_file(entry), before); + EXPECT_EQ(home.xlings({"--version"}).exit_code, 0); +} + +XTEST(HomeLayerInstall, BorrowedPayloadUsesTheCurrentScopeAndAReadonlyWorker, .area = "xim", + .covers = {"HOME-LAYER-RESOLVE"}, .requires_ = {"linux", "xlings-bin", "sandbox"}, + .resources = {"sandbox"}, .proves = "isolation") { + using Json = nlohmann::json; + auto user = tk::Home::isolated("borrowed-install"); + ASSERT_TRUE(user.seed_sandbox_backend()); + const auto system = user.root() / "system"; + const auto payload = system / "data/xpkgs/fixture-x-layer-recipe/1.0.0"; + tk::write_file(payload / "bin/layer-command", "system payload sentinel"); + tk::write_file(payload / ".xlings-resolution.json", + Json{{"package", "fixture:layer-recipe@1.0.0"}, {"deps", Json::array()}}.dump()); + tk::write_file(system / ".xlings-home", + R"({"schema":1,"id":"system","mode":"multi","layout":1})"); + Json sourceVersions = {{"layer-command", + {{"type", "program"}, + {"versions", + {{"fixture:1.0.0", + {{"path", (payload / "bin").generic_string()}, + {"alias", {"echo source-config"}}}}}}}}}; + tk::write_file(system / ".xlings.json", Json{{"versions", sourceVersions}}.dump()); + tk::write_file( + system / "subos/default/.xlings.json", + Json{{"workspace", + {{"layer-command", {{"active", "fixture:1.0.0"}, {"installed", {"fixture:1.0.0"}}}}}}, + {"configured", {{"fixture:layer-recipe@1.0.0", 99}}}} + .dump()); + const auto sourceState = tk::read_file(system / ".xlings.json"); + const auto sourceScope = tk::read_file(system / "subos/default/.xlings.json"); + const auto repo = user.root() / "index"; + tk::write_file(repo / "xim-indexrepos.lua", "xim_indexrepos = {}\n"); + const auto forbidden = payload / "host-write"; + tk::write_file(repo / "pkgs/l/layer-recipe.lua", + std::format(R"LUA( + local top = io.open({0}, 'w') + if top then top:write('host top-level escaped'); top:close() end + package = {{spec='1',name='layer-recipe',type='package',archs={{'x86_64','aarch64'}}, + xpm={{linux={{['1.0.0']={{}}}}}}}} + import('xim.libxpkg.pkginfo') + import('xim.libxpkg.xvm') + function install() error('a borrowed payload must never install') end + function config() + local f = io.open(path.join(pkginfo.install_dir(), 'hook-write'), 'w') + assert(not f, 'system payload was writable') + xvm.add('layer-command', {{bindir=path.join(pkginfo.install_dir(),'bin'), + alias='echo current-scope-config'}}) + return true + end + )LUA", + Json(forbidden.generic_string()).dump())); + tk::write_file( + user.dir() / ".xlings.json", + local_indexes(repo).dump()); + const auto entry = user.dir() / "bin/xlings"; + fs::create_directories(entry.parent_path()); + fs::copy_file(tk::xlings_binary(), entry); + const std::map layer_environment = { + {"XLINGS_SYSTEM_LAYER", system.generic_string()}}; + ASSERT_FALSE(fs::exists(forbidden)); + const auto initialized = user.xlings({"self", "init"}, layer_environment); + ASSERT_EQ(initialized.exit_code, 0) << initialized.transcript(); + ASSERT_FALSE(fs::exists(forbidden)) << "initial index metadata read escaped its layer boundary"; + const auto result = + user.xlings({"install", "fixture:layer-recipe@1.0.0", "-y"}, layer_environment); + ASSERT_EQ(result.exit_code, 0) << result.transcript(); + ASSERT_TRUE(fs::is_symlink(user.dir() / "data/fixture")); + EXPECT_EQ(fs::canonical(user.dir() / "data/fixture"), fs::canonical(repo)); + EXPECT_FALSE(fs::exists(user.dir() / "data/xpkgs/fixture-x-layer-recipe/1.0.0")); + EXPECT_FALSE(fs::exists(forbidden)); + EXPECT_FALSE(fs::exists(payload / "hook-write")); + EXPECT_EQ(tk::read_file(payload / "bin/layer-command"), "system payload sentinel"); + EXPECT_EQ(tk::read_file(system / ".xlings.json"), sourceState); + EXPECT_EQ(tk::read_file(system / "subos/default/.xlings.json"), sourceScope); + const auto state = Json::parse(tk::read_file(user.dir() / ".xlings.json")); + const auto& data = state.at("versions").at("layer-command").at("versions").at("fixture:1.0.0"); + EXPECT_EQ(data.at("layer").at("home").get(), fs::canonical(system).string()); + EXPECT_NE(data.at("alias").dump().find("current-scope-config"), std::string::npos); + const auto scope = Json::parse(tk::read_file(user.dir() / "subos/default/.xlings.json")); + EXPECT_EQ(scope.at("configured").at("fixture:layer-recipe@1.0.0").get(), 0); +} + +XTEST(InstallerMaterialization, AUserFileRefusesTheWholeRegistrationBeforeMetadataChanges, + .area = "xim", .requires_ = {"xlings-bin"}) { + using Json = nlohmann::json; + auto home = tk::Home::isolated("installer-user-file"); + const auto repo = home.root() / "index"; + tk::write_file(repo / "xim-indexrepos.lua", "xim_indexrepos = {}\n"); + tk::write_file(repo / "pkgs/c/collision.lua", R"LUA( + package = {spec='1',name='collision',type='package',archs={'x86_64','aarch64'}, + xpm={linux={['1.0.0']={}},macosx={['1.0.0']={}},windows={['1.0.0']={}}} + import('xim.libxpkg.pkginfo') + import('xim.libxpkg.xvm') + function install() + os.mkdir(pkginfo.install_dir()) + io.writefile(path.join(pkginfo.install_dir(),'asset'), 'derived content') + return true + end + function config() + xvm.add('collision-asset', {type='files',src='asset',dst='usr/share/collision'}) + return true + end + )LUA"); + tk::write_file( + home.dir() / ".xlings.json", + local_indexes(repo).dump()); + const auto entry = home.dir() / "bin" / (tk::is_windows ? "xlings.exe" : "xlings"); + fs::create_directories(entry.parent_path()); + fs::copy_file(tk::xlings_binary(), entry); + ASSERT_EQ(home.xlings({"self", "init"}).exit_code, 0); + const auto sentinel = home.dir() / "subos/default/usr/share/collision"; + tk::write_file(sentinel, "user-owned content"); + const auto versionsBefore = + Json::parse(tk::read_file(home.dir() / ".xlings.json")).value("versions", Json::object()); + const auto workspaceBefore = tk::read_file(home.dir() / "subos/default/.xlings.json"); + const auto result = home.xlings({"install", "fixture:collision@1.0.0", "-y"}); + EXPECT_NE(result.exit_code, 0) << result.transcript(); + EXPECT_EQ(tk::read_file(sentinel), "user-owned content"); + EXPECT_EQ( + Json::parse(tk::read_file(home.dir() / ".xlings.json")).value("versions", Json::object()), + versionsBefore); + EXPECT_EQ(tk::read_file(home.dir() / "subos/default/.xlings.json"), workspaceBefore); +} diff --git a/tests/e2e/test_root_mount.cpp b/tests/e2e/test_root_mount.cpp new file mode 100644 index 000000000..879e30e2d --- /dev/null +++ b/tests/e2e/test_root_mount.cpp @@ -0,0 +1,218 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.libs.json; +import xlings.platform; +import xlings.platform.root_mount; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; +namespace p = xlings::platform; +namespace rm = xlings::platform::root_mount; +using nlohmann::json; + +namespace { +std::optional receive(int fd) { + p::PollFd descriptor{fd}; + if (p::poll_fds(std::span(&descriptor, 1), 5000) <= 0) + return std::nullopt; + return p::receive_message(fd); +} +struct CaptureChild { + CaptureChild() { + const auto* value = std::getenv("XLINGS_TEST_ROOT_MOUNT_FD"); + if (!value || !*value) + return; + const int fd = std::atoi(value); + const auto target = rm::capture(); + if (!target || !p::send_message(fd, "ready", *target)) + std::_Exit(125); + for (const int captured : *target) + p::close_fd(captured); + for (;;) { + const auto packet = receive(fd); + if (!packet) + std::_Exit(125); + if (packet->data == "stop") + std::_Exit(0); + const auto request = json::parse(packet->data); + const fs::path path = request.at("path").get(); + const auto content = tk::read_file(path); + std::ofstream writer(path, std::ios::app); + const bool writable = writer.good(); + writer.close(); + if (!p::send_message( + fd, + json{{"content", content}, {"writable", writable}, {"exists", fs::exists(path)}} + .dump())) + std::_Exit(125); + } + } +} captureChild; + +struct Session { + int child{-1}, control{-1}; + std::array target{-1, -1, -1}; + ~Session() { + if (control >= 0) { + (void)p::send_message(control, "stop"); + p::close_fd(control); + } + if (child > 0) { + p::send_signal(child, p::sig::kill); + (void)p::wait_process(child, true); + } + for (const int fd : target) + if (fd >= 0) + p::close_fd(fd); + } + json read(const fs::path& path) { + if (!p::send_message(control, json{{"path", path.string()}}.dump())) + throw std::runtime_error("send mount query"); + const auto reply = receive(control); + if (!reply) + throw std::runtime_error("mount query timed out"); + return json::parse(reply->data); + } +}; +} // namespace + +namespace { +void check_namespace_updates(bool locked) { + if constexpr (!tk::is_linux) + GTEST_SKIP() << "Linux mount namespaces"; + if (const auto why = tk::probe("bwrap")) + GTEST_SKIP() << *why; + std::string backend; + for (const auto& candidate : + {std::string(std::getenv("XDEV_BWRAP") ? std::getenv("XDEV_BWRAP") : ""), + std::string("/usr/lib/xlings/bwrap"), std::string("/usr/bin/bwrap"), + std::string("/usr/local/bin/bwrap")}) { + if (candidate.empty() || !fs::exists(candidate)) + continue; + if (tk::run({.argv = {candidate, "--ro-bind", "/", "/", "--", "/bin/true"}}).exit_code == + 0) { + backend = candidate; + break; + } + } + ASSERT_FALSE(backend.empty()); + auto home = tk::Home::isolated("root-mount"); + const auto source = home.root() / "payload"; + const auto point = home.root() / "private-mountpoint"; + fs::create_directories(point / "hidden-point"); + tk::write_file(source / "data", "checked payload"); + fs::create_directory(source / "old-child"); + const auto pair = p::unix_pair(); + ASSERT_TRUE(pair); + Session session; + auto env = tk::inherited_env(); + env["XLINGS_TEST_ROOT_MOUNT_FD"] = std::to_string((*pair)[1]); + std::vector argv{ + backend, "--unshare-user", "--ro-bind", "/", "/", + "--proc", "/proc", "--die-with-parent", "--", p::get_executable_path().string()}; + if (locked) + argv.insert(argv.begin() + 2, "--disable-userns"); + session.child = p::fork_process(); + if (session.child == 0) { + p::close_fd((*pair)[0]); + p::set_inheritable((*pair)[1], true); + (void)p::exec_program(argv, env); + p::exit_now(125); + } + p::close_fd((*pair)[1]); + ASSERT_GT(session.child, 0); + session.control = (*pair)[0]; + auto ready = receive(session.control); + ASSERT_TRUE(ready); + ASSERT_EQ(ready->data, "ready"); + ASSERT_EQ(ready->fds.size(), 3); + std::copy(ready->fds.begin(), ready->fds.end(), session.target.begin()); + if (locked) + EXPECT_FALSE(rm::update(session.target, {}, {})) + << "the current child USER is not the mount owner"; + const auto authority = rm::normalize(session.target); + ASSERT_TRUE(authority) << authority.error(); + + // Both points existed during preflight. Publishing the first payload + // hides the second point; the second mount fails and the first rolls back. + const std::vector partial{{source, point}, {source, point / "hidden-point"}}; + const auto failed = rm::update(session.target, partial, {}); + ASSERT_FALSE(failed); + EXPECT_NE(failed.error().find("move_mount[1]"), std::string::npos) << failed.error(); + EXPECT_FALSE(session.read(point / "data").at("exists").get()); + EXPECT_TRUE(fs::is_directory(point / "hidden-point")); + + const std::vector full{{source, point}}; + const auto applied = rm::update(session.target, full, {}); + ASSERT_TRUE(applied) << applied.error(); + const auto observed = session.read(point / "data"); + EXPECT_EQ(observed.at("content"), "checked payload"); + EXPECT_FALSE(observed.at("writable").get()); + EXPECT_FALSE(fs::exists(point / "data")) << "the host namespace never received the mount"; + const auto metadata = home.root() / "metadata"; + const auto metadataPoint = home.root() / "metadata-point"; + tk::write_file(metadata, "new metadata"); + tk::write_file(metadataPoint, "old metadata"); + const std::vector files{{metadata, metadataPoint}}; + ASSERT_TRUE(rm::update(session.target, files, {})); + EXPECT_EQ(session.read(metadataPoint).at("content"), "new metadata"); + EXPECT_EQ(tk::read_file(metadataPoint), "old metadata"); + fs::rename(metadata, home.root() / "retired-metadata"); + tk::write_file(metadata, "replacement metadata at the same source path"); + EXPECT_EQ(session.read(metadataPoint).at("content"), "new metadata"); + + // The first removal succeeds; the second path is a directory without its + // own mount. Rollback must restore the exact old mounted metadata inode. + const auto unmounted = home.root() / "idle"; + fs::create_directory(unmounted); + const std::array partialRemoval{metadataPoint, unmounted}; + const auto removalFailure = rm::update(session.target, {}, partialRemoval); + ASSERT_FALSE(removalFailure); + EXPECT_NE(removalFailure.error().find("unmount[1]"), std::string::npos) + << removalFailure.error(); + EXPECT_EQ(removalFailure.error().find("rollback"), std::string::npos) << removalFailure.error(); + const auto restored = session.read(metadataPoint); + EXPECT_EQ(restored.at("content"), "new metadata"); + EXPECT_FALSE(restored.at("writable").get()); + EXPECT_EQ(tk::read_file(metadataPoint), "old metadata"); + EXPECT_EQ(session.read(point / "data").at("content"), "checked payload"); + const auto replacement = home.root() / "replacement-payload"; + tk::write_file(replacement / "data", "replacement payload"); + const std::vector failedReplacement{{replacement, point, true}, + {replacement, point / "old-child"}}; + const auto publicationFailure = rm::update(session.target, failedReplacement, {}); + ASSERT_FALSE(publicationFailure); + EXPECT_NE(publicationFailure.error().find("move_mount[1]"), std::string::npos) + << publicationFailure.error(); + EXPECT_EQ(publicationFailure.error().find("rollback"), std::string::npos) + << publicationFailure.error(); + EXPECT_EQ(session.read(point / "data").at("content"), "checked payload"); + const std::vector replacements{{replacement, point, true}, + {metadata, metadataPoint, true}}; + const auto replaced = rm::update(session.target, replacements, {}); + ASSERT_TRUE(replaced) << replaced.error(); + EXPECT_EQ(session.read(point / "data").at("content"), "replacement payload"); + EXPECT_EQ(session.read(metadataPoint).at("content"), + "replacement metadata at the same source path"); + const std::array removed{point, metadataPoint}; + ASSERT_TRUE(rm::update(session.target, {}, removed)); + EXPECT_FALSE(session.read(point / "data").at("exists").get()); + EXPECT_EQ(session.read(metadataPoint).at("content"), "old metadata"); +} + +} // namespace + +XTEST(RootMount, UpdatesOnlyTheCapturedSessionAndRollsBackAPartialMountBatch, .area = "subos", + .cost = tk::Cost::Medium, .covers = {"ROOT-NO-HOST"}, .requires_ = {"linux", "bwrap"}, + .resources = {"sandbox"}, .proves = "isolation") { + check_namespace_updates(false); +} + +XTEST(RootMount, LockedUserNamespaceAllowsOnlyTheTrustedOwnerToRefresh, .area = "subos", + .cost = tk::Cost::Medium, .covers = {"ROOT-NO-HOST", "ROOT-STORE-CLOSURE"}, + .requires_ = {"linux", "bwrap"}, .resources = {"sandbox"}, .proves = "isolation") { + check_namespace_updates(true); +} diff --git a/tests/e2e/test_root_retention.cpp b/tests/e2e/test_root_retention.cpp new file mode 100644 index 000000000..ecba9f153 --- /dev/null +++ b/tests/e2e/test_root_retention.cpp @@ -0,0 +1,177 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.libs.json; +import xlings.store; +import xlings.subos.rootfs; + +// What keeps a payload alive besides a workspace: a retained root generation +// that links into it (SubOS design part 3 §7.1). + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; +namespace rf = xlings::subos::rootfs; + +namespace { + +const std::map kBox{{"XLINGS_ACTIVE_SUBOS", "box"}}; + +fs::path tool_payload(const tk::Home& home) { + return home.dir() / "data/xpkgs/xim-x-fixture-tool/1.0.0"; +} + +} // namespace + +XTEST(RootRetention, RemoveKeepsWhatAnOlderGenerationLinksIntoAndRollbackFindsIt, + .area = "subos", .covers = {"ROOT-GC-ROOT"}, .requires_ = {"linux", "xlings-bin"}) { + auto home = tk::Home::isolated("root-retention"); + auto created = home.xlings({"subos", "new", "box", "--rootfs"}); + ASSERT_EQ(created.exit_code, 0) << created.transcript(); + const auto box = home.dir() / "subos/box"; + + auto installed = home.xlings({"install", "fixture-tool", "-y"}, kBox); + ASSERT_EQ(installed.exit_code, 0) << installed.transcript(); + const auto with_tool = rf::current(box); + ASSERT_TRUE(with_tool); + ASSERT_TRUE(fs::exists(box / "root/usr/bin/fixture-tool")) << installed.transcript(); + + auto removed = home.xlings({"remove", "fixture-tool", "-y"}, kBox); + ASSERT_EQ(removed.exit_code, 0) << removed.transcript(); + EXPECT_NE(rf::current(box), with_tool); + EXPECT_FALSE(fs::exists(box / "root/usr/bin/fixture-tool")); + // Removed from every workspace, and still on disk: generation `with_tool` + // links into it. The ledger says so. + EXPECT_TRUE(fs::exists(tool_payload(home) / "bin/fixture-tool")) << removed.transcript(); + EXPECT_NE(removed.transcript().find("stay for rollback"), std::string::npos) << removed.transcript(); + auto ledger = xlings::store::read_ledger(xlings::store::ledger_path(home.dir() / "data")); + ASSERT_TRUE(ledger) << ledger.error(); + ASSERT_EQ(ledger->size(), 1u); + EXPECT_EQ(ledger->front().target, "fixture-tool"); + + // The defect this replaces: rollback succeeded and /usr/bin pointed at a + // payload `remove` had deleted. + auto rolled = home.xlings({"subos", "rollback", "box", "--to", std::to_string(*with_tool)}); + ASSERT_EQ(rolled.exit_code, 0) << rolled.transcript(); + EXPECT_TRUE(fs::exists(box / "root/usr/bin/fixture-tool")) << rolled.transcript(); +} + +XTEST(RootRetention, PruningTheLastHolderReleasesTheRetainedPayload, + .area = "subos", .covers = {"ROOT-GC-ROOT", "ROOT-GEN-RETAIN"}, .requires_ = {"linux", "xlings-bin"}) { + auto home = tk::Home::isolated("root-release"); + ASSERT_EQ(home.xlings({"subos", "new", "box", "--rootfs"}).exit_code, 0); + const auto box = home.dir() / "subos/box"; + ASSERT_EQ(home.xlings({"install", "fixture-tool", "-y"}, kBox).exit_code, 0); + const auto holder = rf::current(box); + ASSERT_TRUE(holder); + ASSERT_EQ(home.xlings({"remove", "fixture-tool", "-y"}, kBox).exit_code, 0); + ASSERT_TRUE(fs::exists(tool_payload(home))); + + // Push the holder out of the kept window with generations of other + // content, then let one more workspace change prune it. + for (std::size_t i = 0; i <= rf::kKeepGenerations; ++i) { + rf::Plan plan; + plan.links.push_back({"usr/share/marker-" + std::to_string(i), "/nonexistent", "test"}); + ASSERT_TRUE(rf::commit(box, plan, "filler")); + } + auto installed = home.xlings({"install", "fixture-data", "-y"}, kBox); + ASSERT_EQ(installed.exit_code, 0) << installed.transcript(); + EXPECT_FALSE(fs::exists(box / rf::kGenerations / std::to_string(*holder))); + EXPECT_FALSE(fs::exists(tool_payload(home))) << installed.transcript(); + auto ledger = xlings::store::read_ledger(xlings::store::ledger_path(home.dir() / "data")); + ASSERT_TRUE(ledger) << ledger.error(); + EXPECT_TRUE(ledger->empty()); +} + +XTEST(RootRetention, ARootPointerThatNamesNoGenerationPrunesNothing, + .area = "subos", .covers = {"ROOT-GEN-RETAIN"}, .requires_ = {"posix"}) { + auto home = tk::Home::isolated("root-prune-unknown"); + const auto box = home.dir() / "subos/box"; + for (int i = 0; i != 8; ++i) { + rf::Plan plan; + plan.links.push_back({"usr/share/m" + std::to_string(i), "/nonexistent", "test"}); + ASSERT_TRUE(rf::commit(box, plan, "filler")); + } + const auto before = rf::generations(box); + fs::remove(box / rf::kPointer); + fs::create_directory_symlink("somewhere-else", box / rf::kPointer); + EXPECT_TRUE(rf::prune(box, 1).empty()); + EXPECT_EQ(rf::generations(box), before); +} + +XTEST(RootGeneration, ACommitFlushesItsRecordsAndDirectoriesBeforePublishingThem, + .area = "subos", .covers = {"ROOT-GEN-DURABLE"}, .requires_ = {"linux", "xlings-bin"}) { + auto home = tk::Home::isolated("root-durable"); + ASSERT_EQ(home.xlings({"subos", "new", "box", "--rootfs"}).exit_code, 0); + auto env = kBox; + env["XLINGS_TRACE"] = "durability"; + auto installed = home.xlings({"install", "fixture-tool", "-y"}, env); + ASSERT_EQ(installed.exit_code, 0) << installed.transcript(); + const auto box = home.dir() / "subos/box"; + const auto generation = rf::current(box); + ASSERT_TRUE(generation); + + // The order of the flushes is the guarantee: everything a rename + // publishes is on disk before the directory that holds the rename is. + std::vector trace; + std::istringstream lines(installed.transcript()); + for (std::string line; std::getline(lines, line);) + if (line.starts_with("[trace:durability] ")) trace.push_back(line.substr(19)); + auto at = [&](std::string_view needle) { + const auto it = std::ranges::find_if(trace, [&](const std::string& t) { return t.find(needle) != std::string::npos; }); + return it == trace.end() ? std::ptrdiff_t{-1} : it - trace.begin(); + }; + const auto records = at("links.tsv"); + const auto bin = at("/usr/bin"); + const auto placedLine = std::format("dir {}", (box / rf::kGenerations).string()); + const auto placedIt = std::ranges::find(trace, placedLine); // exact: staging lives under it + const auto placed = placedIt == trace.end() ? std::ptrdiff_t{-1} : placedIt - trace.begin(); + const auto pointer = static_cast(trace.size()) - 1; + ASSERT_GE(records, 0) << installed.transcript(); + ASSERT_GE(bin, 0) << installed.transcript(); + ASSERT_GE(placed, 0) << installed.transcript(); + EXPECT_LT(records, placed); + EXPECT_LT(bin, placed); + EXPECT_EQ(trace.back(), std::format("dir {}", box.string())) << "the pointer's directory is flushed last"; + EXPECT_LT(placed, pointer); + EXPECT_TRUE(fs::exists(box / rf::kGenerations / std::format(".{}.inventory", *generation))); +} + +XTEST(RootGeneration, ACommitPublishesLinksToLogicalPathsThatExistOnlyWhereTheRootIsUsed, + .area = "subos", .covers = {"ROOT-GC-ROOT", "ROOT-GEN-ATOMIC"}, .requires_ = {"posix"}) { + // An exported image's generation names payloads at the image home's + // path; at export time they live in the staging tree, not there. + auto home = tk::Home::isolated("root-logical"); + const auto image = home.dir() / "stage/subos/default"; + const fs::path logical = "/nonexistent-logical-home/data/xpkgs/fixture-x-tool/1/bin/tool"; + const auto placed = rf::commit(image, {{{"usr/bin/tool", logical, "fixture"}}, {}}, "export"); + ASSERT_TRUE(placed) << placed.error(); + EXPECT_EQ(rf::current(image), placed); + // Choosing it again later, here, is a rollback: that does check. + ASSERT_TRUE(rf::commit(image, {{{"usr/bin/other", logical, "fixture"}}, {}}, "second")); + EXPECT_FALSE(rf::switch_to(image, *placed)); + EXPECT_TRUE(rf::switch_to(image, *placed, rf::Flush::Durable, rf::Verify::Tree)); +} + +XTEST(RootGeneration, ASwitchRefusesAGenerationWhosePayloadIsGone, + .area = "subos", .covers = {"ROOT-GC-ROOT", "ROOT-SWITCH-SCALE"}, .requires_ = {"posix"}) { + auto home = tk::Home::isolated("root-switch-gone"); + const auto box = home.dir() / "subos/box"; + const auto payload = home.dir() / "data/xpkgs/fixture-x-tool/1"; + tk::write_file(payload / "bin/tool", "fixture"); + const auto first = rf::commit(box, {{{"usr/bin/tool", payload / "bin/tool", "fixture"}}, {}}, "first"); + ASSERT_TRUE(first) << first.error(); + const auto second = rf::commit(box, {{{"usr/bin/other", payload / "bin/tool", "fixture"}}, {}}, "second"); + ASSERT_TRUE(second) << second.error(); + fs::remove_all(payload); + const auto refused = rf::switch_to(box, *first); + ASSERT_FALSE(refused); + EXPECT_NE(refused.error().find("which is gone"), std::string::npos) << refused.error(); + EXPECT_EQ(rf::current(box), second); + // Without the inventory the full walk decides, and says the same. + fs::remove(box / rf::kGenerations / std::format(".{}.inventory", *first)); + const auto again = rf::switch_to(box, *first); + ASSERT_FALSE(again); + EXPECT_NE(again.error().find("which is gone"), std::string::npos) << again.error(); +} diff --git a/tests/e2e/test_root_store_closure.cpp b/tests/e2e/test_root_store_closure.cpp new file mode 100644 index 000000000..c82abb915 --- /dev/null +++ b/tests/e2e/test_root_store_closure.cpp @@ -0,0 +1,317 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.platform; +import xlings.libs.json; +import xlings.core.config; +import xlings.core.subos.root; +import xlings.core.subos.store_closure; +import xlings.core.subos.root_view; +import xlings.subos.rootfs; +import xlings.subos.roles; +import xlings.subos.session; +import xlings.subos.home_view; +import xlings.subos.policy; +import xlings.subos.broker; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; +namespace p = xlings::platform; +namespace session = xlings::subos::session; +namespace closure = xlings::subos_root::store_closure; +namespace view = xlings::subos_root::root_view; +namespace rf = xlings::subos::rootfs; +using Json = nlohmann::json; + +namespace { +constexpr auto roleVariable = "XLINGS_TEST_ROOT_VIEW_ROLE"; + +struct NamespaceProbe { + NamespaceProbe() { + const auto* role = std::getenv(roleVariable); + if (!role || !*role) + return; + const auto value = std::string(role); + p::unset_env_variable(roleVariable); + if (value == "init") { + const std::vector arguments; + std::_Exit(session::session_init(arguments)); + } + if (value != "probe") + std::_Exit(125); + const fs::path home(std::getenv("XLINGS_HOME")); + const auto visible = home / "data/xpkgs/fixture-x-old-visible/1.0.0/bin/old-visible"; + const auto hidden = home / "data/xpkgs/fixture-x-other-scope/9.0.0/private.txt"; + const auto added = home / "data/xpkgs/fixture-x-root-new/1.0.0/.xlings-resolution.json"; + std::ofstream write(visible, std::ios::app); + const bool writable = write.good(); + write.close(); + const Json result{ + {"visible", fs::exists(visible)}, + {"visible_content", tk::read_file(visible)}, + {"hidden", fs::exists(hidden)}, + {"writable", writable}, + {"added", fs::exists(added)}, + {"projected", fs::exists(home / "subos/box/root/usr/bin/root-new")}, + {"root_pointer", fs::read_symlink(home / "subos/box/root").generic_string()}}; + tk::write_file("/run/root-view-result/result.json", result.dump()); + std::_Exit(0); + } +} namespaceProbe; + +std::string backend() { + for (const auto& candidate : + {std::string(std::getenv("XDEV_BWRAP") ? std::getenv("XDEV_BWRAP") : ""), + std::string("/usr/lib/xlings/bwrap"), std::string("/usr/bin/bwrap"), + std::string("/usr/local/bin/bwrap")}) { + if (!candidate.empty() && fs::exists(candidate) && + tk::run({.argv = {candidate, "--ro-bind", "/", "/", "--", "/bin/true"}}).exit_code == 0) + return candidate; + } + return {}; +} + +struct RunningView { + tk::Home home = tk::Home::isolated("root-view-broker"); + std::shared_ptr prepared; + int generation{}; + ~RunningView() { + (void)session::stop({home.dir()}, "box"); + if (prepared) + prepared->close(); + } + + void setup() { + const auto primary = home.root() / "empty-index"; + const auto fixture = home.root() / "fixture-index"; + tk::write_file(primary / "xim-indexrepos.lua", "xim_indexrepos = {}\n"); + fs::create_directories(primary / "pkgs"); + tk::write_file(fixture / "xim-indexrepos.lua", "xim_indexrepos = {}\n"); + tk::write_file(fixture / "pkgs/r/root-new.lua", R"LUA( + package = { + spec = '1', name = 'root-new', type = 'script', + archs = {'x86_64', 'aarch64'}, + xpm = {linux = {['1.0.0'] = {}}} + } + function xpkg_main() return true end + )LUA"); + Json config{ + {"mirror", "GLOBAL"}, + {"xim", {{"index-repo", primary.string()}}}, + {"index_repos", + Json::array({{{"name", std::string(xlings::Config::DEFAULT_INDEX_REPO_NAME)}, + {"url", primary.string()}, + {"source", "git"}}, + {{"name", "fixture"}, {"url", fixture.string()}, {"source", "git"}}})}}; + tk::write_file(home.dir() / ".xlings.json", config.dump()); + fs::create_directories(home.dir() / "bin"); + fs::copy_file(tk::xlings_binary(), home.dir() / "bin/xlings", + fs::copy_options::overwrite_existing); + const auto initialized = home.xlings({"self", "init"}); + if (initialized.exit_code != 0) + throw std::runtime_error(initialized.transcript()); + const auto payload = home.dir() / "data/xpkgs/fixture-x-old-visible/1.0.0"; + tk::write_file(payload / "bin/old-visible", "old visible sentinel"); + tk::write_file(payload / ".xlings-resolution.json", + R"({"package":"fixture:old-visible@1.0.0","deps":[]})"); + tk::write_file(home.dir() / "data/xpkgs/fixture-x-other-scope/9.0.0/private.txt", + "other scope must stay hidden"); + config = Json::parse(tk::read_file(home.dir() / ".xlings.json")); + config["versions"] = { + {"old-visible", + {{"type", "program"}, + {"versions", {{"fixture:1.0.0", {{"path", (payload / "bin").string()}}}}}}}}; + tk::write_file(home.dir() / ".xlings.json", config.dump()); + const auto scope = home.dir() / "subos/box"; + tk::write_file(scope / ".xlings.json", + Json{{"workspace", + {{"old-visible", + {{"active", "fixture:1.0.0"}, {"installed", {"fixture:1.0.0"}}}}}}} + .dump()); + const auto declared = + xlings::subos_root::declare_kind(home.dir(), "box", xlings::subos::roles::Kind::Rootfs); + if (!declared) + throw std::runtime_error(declared.error()); + rf::Inputs initial; + initial.payloads = {payload}; + initial.programs = {{"old-visible", payload / "bin/old-visible"}}; + const auto committed = rf::commit(scope, rf::plan(initial), "initial fixture"); + if (!committed) + throw std::runtime_error(committed.error()); + generation = *committed; + const auto laid = rf::lay_out(scope / "rootfs", rf::usr_of(scope), home.dir()); + if (!laid) + throw std::runtime_error(laid.error()); + auto inputs = closure::read_scope(home.dir(), "box", scope / "rootfs"); + if (!inputs) + throw std::runtime_error(inputs.error()); + auto result = view::prepare(*inputs); + if (!result) + throw std::runtime_error(result.error()); + prepared = std::move(*result); + fs::create_directories(home.root() / "results"); + } + + int start(const std::string& bwrap, bool failRefresh) { + std::vector argv{bwrap, "--unshare-user", "--ro-bind", "/", + "/", "--proc", "/proc", "--tmpfs", + "/run", "--dir", "/run/xlings", "--die-with-parent"}; + for (const auto& binding : prepared->bindings()) + argv.insert(argv.end(), + {"--ro-bind", binding.source.string(), binding.destination.string()}); + argv.insert(argv.end(), + {"--bind", (home.root() / "results").string(), "/run/root-view-result", "--bind", + xlings::subos::HomeView{home.dir()}.broker_socket("box").string(), + std::string(xlings::subos::broker::kSocketInside), "--", + p::get_executable_path().string()}); + auto environment = tk::inherited_env(); + environment[roleVariable] = "init"; + environment["XLINGS_HOME"] = home.dir().string(); + auto owner = tk::inherited_env(); + owner["XLINGS_HOME"] = home.dir().string(); + owner["XLINGS_ACTIVE_SUBOS"] = "box"; + owner["XLINGS_NON_INTERACTIVE"] = "1"; + owner.erase("XLINGS_SUBOS_MODE"); + owner.erase("XLINGS_PROJECT_DIR"); + return session::host( + {home.dir()}, + session::Launch{ + .instance = "box", + .argv = std::move(argv), + .env = environment, + .backend = "bwrap", + .digest = "root-view-fixture", + .exec_env = {{roleVariable, "probe"}, {"XLINGS_HOME", home.dir().string()}}, + .default_cwd = "/", + .ttl = 30, + .detached = true, + .broker_policy = xlings::subos::policy::preset(xlings::subos::policy::Preset::Dev), + .broker_exe = {tk::xlings_binary().string()}, + .broker_env = std::move(owner), + .refresh_root = + [current = prepared, failRefresh](std::span target) { + if (failRefresh) { + const std::array broken{-1, target[1], target[2]}; + return current->refresh(broken); + } + return current->refresh(target); + }, + .finalize_root = [current = prepared] { current->close(); }}); + } + + Json command(const std::vector& arguments) { + const auto fd = p::unix_connect(xlings::subos::HomeView{home.dir()}.broker_socket("box")); + if (fd < 0) + throw std::runtime_error("fixture broker unavailable"); + const int input = p::open_null(); + const int output = p::open_for_append(home.root() / "broker.log"); + const std::array stdio{input, output, output}; + const Json request{{"op", "run"}, {"argv", arguments}}; + if (!p::send_message(fd, request.dump(), stdio)) + throw std::runtime_error("cannot send fixture install"); + p::close_fd(input); + p::close_fd(output); + for (;;) { + p::PollFd descriptor{fd}; + if (p::poll_fds(std::span(&descriptor, 1), 30000) <= 0) { + p::close_fd(fd); + throw std::runtime_error("fixture broker timeout"); + } + auto packet = p::receive_message(fd); + if (!packet) { + p::close_fd(fd); + throw std::runtime_error("fixture broker closed before reply"); + } + p::close_fds(packet->fds); + auto reply = Json::parse(packet->data); + if (reply.contains("started")) + continue; + p::close_fd(fd); + return reply; + } + } + Json install() { + return command({"install", "fixture:root-new@1.0.0"}); + } +}; +} // namespace + +XTEST(RootStoreClosureIsolation, BrokerSuccessPublishesNewPayloadAndProjectionBeforeReply, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"ROOT-STORE-CLOSURE"}, + .requires_ = {"linux", "bwrap", "xlings-bin"}, .resources = {"sandbox"}, + .proves = "isolation") { + if constexpr (!tk::is_linux) + GTEST_SKIP() << "Linux mount namespaces"; + if (const auto why = tk::probe("bwrap")) + GTEST_SKIP() << *why; + const auto bwrap = backend(); + ASSERT_FALSE(bwrap.empty()); + RunningView running; + ASSERT_NO_THROW(running.setup()); + ASSERT_EQ(running.start(bwrap, false), 0) + << tk::read_file(running.home.dir() / "logs/subos/box/session.log") + << tk::read_file(running.home.dir() / "logs/subos/box/events.ndjson"); + const auto visible = running.home.dir() / "data/xpkgs/fixture-x-old-visible/1.0.0"; + fs::rename(visible, running.home.root() / "parked-old-payload"); + tk::write_file(visible / "bin/old-visible", "replacement at the same payload path"); + tk::write_file(visible / ".xlings-resolution.json", + R"({"package":"fixture:old-visible@1.0.0","deps":[]})"); + const auto reply = running.install(); + ASSERT_EQ(reply.value("exit", -1), 0) + << reply << tk::read_file(running.home.root() / "broker.log"); + const auto inspected = session::join({running.home.dir()}, "box", + {.argv = {p::get_executable_path().string()}, .cwd = "/"}); + ASSERT_EQ(inspected.exit_code, 0) << inspected.error; + const auto result = Json::parse(tk::read_file(running.home.root() / "results/result.json")); + EXPECT_TRUE(result["visible"].get()); + EXPECT_EQ(result["visible_content"].get(), "replacement at the same payload path"); + EXPECT_FALSE(result["hidden"].get()); + EXPECT_FALSE(result["writable"].get()); + EXPECT_TRUE(result["added"].get()); + EXPECT_TRUE(result["projected"].get()); + EXPECT_NE(result["root_pointer"].get(), + "root.gen/" + std::to_string(running.generation)); + EXPECT_EQ( + tk::read_file(running.home.dir() / "data/xpkgs/fixture-x-other-scope/9.0.0/private.txt"), + "other scope must stay hidden"); + const auto removed = running.command({"remove", "old-visible@fixture:1.0.0", "-y", "--force"}); + ASSERT_EQ(removed.value("exit", -1), 0) + << removed << tk::read_file(running.home.root() / "broker.log"); + const auto afterRemoval = session::join( + {running.home.dir()}, "box", {.argv = {p::get_executable_path().string()}, .cwd = "/"}); + ASSERT_EQ(afterRemoval.exit_code, 0) << afterRemoval.error; + const auto retired = Json::parse(tk::read_file(running.home.root() / "results/result.json")); + EXPECT_FALSE(retired["visible"].get()) + << "withdrawing a replacement must not reveal its previous mount"; + EXPECT_FALSE(retired["hidden"].get()); + EXPECT_TRUE(retired["added"].get()); + EXPECT_EQ(tk::read_file(running.home.root() / "parked-old-payload/bin/old-visible"), + "old visible sentinel"); +} + +XTEST(RootStoreClosureIsolation, FailedNamespaceRefreshReturns125AndEndsTheSession, .area = "subos", + .cost = tk::Cost::Medium, .covers = {"ROOT-STORE-CLOSURE", "ROOT-REFRESH-FAIL"}, + .requires_ = {"linux", "bwrap", "xlings-bin"}, .resources = {"sandbox"}, + .proves = "isolation") { + if constexpr (!tk::is_linux) + GTEST_SKIP() << "Linux mount namespaces"; + if (const auto why = tk::probe("bwrap")) + GTEST_SKIP() << *why; + RunningView running; + ASSERT_NO_THROW(running.setup()); + ASSERT_EQ(running.start(backend(), true), 0) + << tk::read_file(running.home.dir() / "logs/subos/box/session.log") + << tk::read_file(running.home.dir() / "logs/subos/box/events.ndjson"); + const auto reply = running.install(); + EXPECT_EQ(reply.value("exit", -1), 125) << reply; + EXPECT_FALSE(reply.value("error", "").empty()); + for (int attempt = 0; attempt < 100 && session::find({running.home.dir()}, "box"); ++attempt) + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + EXPECT_FALSE(session::find({running.home.dir()}, "box")); + EXPECT_FALSE(fs::exists(running.home.dir() / "run/subos/box/session.json")); + EXPECT_NE(tk::read_file(running.home.dir() / "logs/subos/box/events.ndjson") + .find("root-refresh-failed"), + std::string::npos); +} diff --git a/tests/e2e/test_subos_cmd_contract.cpp b/tests/e2e/test_subos_cmd_contract.cpp new file mode 100644 index 000000000..87c2a0bfd --- /dev/null +++ b/tests/e2e/test_subos_cmd_contract.cpp @@ -0,0 +1,97 @@ +// `subos use --sandbox --cmd `: the command's exit code is the +// exit code, and the command saw the sandbox's home (T1's proof case). +// +// Replaces subos_cmd_contract_test.sh (macOS) and .ps1 (Windows), which were +// one contract written twice. The macOS copy never asserted anything: macOS +// ships bash 3.2, where a failing `[[ ]]` does not trip `set -e`, so the run +// on 2026-10-04 printed `cat: .../sandbox-home/marker: No such file` and then +// `ok`. The path it checked was never the one xlings uses. One C++ test, one +// set of assertions, three platforms. +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; + +namespace { + +fs::path sandbox_home(const tk::Home& home, std::string_view subos) { + return home.dir() / "subos" / subos / "home" / tk::current_user(); +} + +} // namespace + +XTEST(SubosCmdContract, ExitCodeAndHomeOnLinux, + .area = "subos", .cost = tk::Cost::Medium, + .covers = {"SUBOS-CMD-EXIT", "SUBOS-CMD-HOME"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}) { + auto home = tk::Home::isolated("cmd-contract"); + ASSERT_TRUE(home.seed_sandbox_backend()); + auto created = home.xlings({"subos", "new", "probe"}); + ASSERT_EQ(created.exit_code, 0) << created.transcript(); + + auto r = home.xlings({"subos", "use", "probe", "--sandbox", "--cmd", + R"(printf "%s" "$HOME" > "$HOME/marker"; exit 37)"}); + EXPECT_EQ(r.exit_code, 37) << r.transcript(); + // Inside, HOME is /home/; outside, that is /home/. + EXPECT_EQ(tk::read_file(sandbox_home(home, "probe") / "marker"), + "/home/" + tk::current_user()); +} + +XTEST(SubosCmdContract, ExitCodeAndHomeOnMacos, + .area = "subos", .cost = tk::Cost::Medium, + .covers = {"SUBOS-CMD-EXIT", "SUBOS-CMD-HOME"}, + .requires_ = {"macos", "xlings-bin"}) { + auto home = tk::Home::isolated("cmd-contract"); + ASSERT_EQ(home.xlings({"self", "init"}).exit_code, 0); + ASSERT_EQ(home.xlings({"subos", "new", "probe"}).exit_code, 0); + + auto r = home.xlings({"subos", "use", "probe", "--sandbox", "--cmd", + R"(printf "%s" "$HOME" > "$HOME/marker"; exit 37)"}); + EXPECT_EQ(r.exit_code, 37) << r.transcript(); + // home-redirect: HOME is the sandbox home's real path. + auto expected = sandbox_home(home, "probe"); + EXPECT_EQ(fs::path(tk::read_file(expected / "marker")).lexically_normal(), + expected.lexically_normal()); + + // `exec -- argv` runs the argv with the same home, not a shell. + auto e = home.xlings({"subos", "exec", "probe", "--sandbox", "--", "/bin/sh", "-c", + R"(printf "%s" "$HOME" > "$HOME/exec-marker"; exit 38)"}); + EXPECT_EQ(e.exit_code, 38) << e.transcript(); + EXPECT_EQ(fs::path(tk::read_file(expected / "exec-marker")).lexically_normal(), + expected.lexically_normal()); +} + +XTEST(SubosCmdContract, ExitCodeHomeAndCmdQuotingOnWindows, + .area = "subos", .cost = tk::Cost::Medium, + .covers = {"SUBOS-CMD-EXIT", "SUBOS-CMD-HOME", "SUBOS-CMD-WIN-QUOTING"}, + .requires_ = {"windows", "xlings-bin"}) { + auto home = tk::Home::isolated("cmd-contract"); + ASSERT_EQ(home.xlings({"self", "init"}).exit_code, 0); + ASSERT_EQ(home.xlings({"subos", "new", "probe"}).exit_code, 0); + + auto r = home.xlings({"subos", "use", "probe", "--sandbox", "--cmd", + R"($env:USERPROFILE | Set-Content -NoNewline "$env:USERPROFILE\marker"; exit 37)"}); + EXPECT_EQ(r.exit_code, 37) << r.transcript(); + auto expected = sandbox_home(home, "probe"); + EXPECT_EQ(fs::path(tk::read_file(expected / "marker")), expected); + + // `exec -- argv` runs the argv -- not an interactive shell -- and returns + // its exit code. + auto e = home.xlings({"subos", "exec", "probe", "--sandbox", "--", "cmd", "/c", "exit", "38"}); + EXPECT_EQ(e.exit_code, 38) << e.transcript(); + + // cmd.exe has its own quote / metacharacter grammar; the CRT quoting used + // for PowerShell must not leak into what follows `cmd /s /c`. + auto c = home.xlings({"subos", "use", "probe", "--sandbox", "--cmd", + R"(echo quoted^&pipe>"%USERPROFILE%\cmd-marker" & exit /b 37)"}, + {{"XLINGS_SHELL", "cmd.exe"}}); + EXPECT_EQ(c.exit_code, 37) << c.transcript(); + auto text = tk::read_file(expected / "cmd-marker"); + while (!text.empty() && (text.back() == '\n' || text.back() == '\r' || text.back() == ' ')) + text.pop_back(); + EXPECT_EQ(text, "quoted&pipe"); +} diff --git a/tests/e2e/test_subos_exec.cpp b/tests/e2e/test_subos_exec.cpp new file mode 100644 index 000000000..f388237c6 --- /dev/null +++ b/tests/e2e/test_subos_exec.cpp @@ -0,0 +1,169 @@ +// Running commands in an instance from outside it (C12, design §12): the +// exec surface, its exit-code table, hot sessions, throwaway instances, file +// copies, and the interface capability agents use. +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.libs.json; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; + +namespace { + +struct Box { + tk::Home home = tk::Home::isolated("exec"); + Box() { + home.seed_sandbox_backend(); + auto r = home.xlings({"subos", "new", "box"}); + if (r.exit_code != 0) ADD_FAILURE() << r.transcript(); + } + tk::RunResult exec(std::vector args) const { + std::vector a{"subos", "exec"}; + a.insert(a.end(), args.begin(), args.end()); + return home.xlings(a); + } +}; + +} // namespace + +XTEST(SubosExec, ArgvIsPassedVerbatimAndTheExitCodeIsTheCommands, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"F16", "EXIT-CMD"}, + .requires_ = {"posix", "xlings-bin"}) { + Box box; + // No shell between us and the command: the space and the quote arrive as + // one argument each, untouched. + auto r = box.exec({"box", "--", "/bin/sh", "-c", "printf '[%s]' \"$1\"; exit 5", "sh", + "a b 'c'"}); + EXPECT_EQ(r.exit_code, 5) << r.transcript(); + EXPECT_EQ(r.out, "[a b 'c']"); + EXPECT_EQ(r.err.find("entering"), std::string::npos) << "exec announces nothing"; +} + +XTEST(SubosExec, TheExitCodeTable, + .area = "subos", .cost = tk::Cost::Medium, + .covers = {"EXIT-125", "EXIT-126", "EXIT-127", "EXIT-124", "EXIT-SIGNAL"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}) { + Box box; + EXPECT_EQ(box.exec({"nosuch", "--", "true"}).exit_code, 125) << "no such instance"; + EXPECT_EQ(box.exec({"box", "--sandbox", "--", "no-such-program"}).exit_code, 127); + EXPECT_EQ(box.exec({"box", "--sandbox", "--", "/etc/passwd"}).exit_code, 126); + EXPECT_EQ(box.exec({"box", "--sandbox", "--timeout", "1", "--", "sleep", "30"}).exit_code, 124); + EXPECT_EQ(box.exec({"box", "--sandbox", "--", "/bin/sh", "-c", "kill -TERM $$"}).exit_code, + 128 + 15); + auto j = box.exec({"box", "--sandbox", "--json", "--", "/bin/sh", "-c", "exit 3"}); + EXPECT_EQ(j.exit_code, 3); + auto last = j.err.substr(j.err.rfind('{')); + auto result = nlohmann::json::parse(last, nullptr, false); + ASSERT_FALSE(result.is_discarded()) << j.transcript(); + EXPECT_EQ(result["exit"], 3); + EXPECT_EQ(result["mode"], "sandbox"); +} + +XTEST(SubosExec, StartRunsAHotSessionThatExecJoinsAndStopEnds, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"SES-START-STOP", "SES-JOIN"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}) { + Box box; + auto s = box.home.xlings({"subos", "start", "box", "--ttl", "60"}); + ASSERT_EQ(s.exit_code, 0) << s.transcript(); + // Two commands, one session: what the first leaves in /tmp the second sees. + EXPECT_EQ(box.exec({"box", "--", "/bin/sh", "-c", "echo hot > /tmp/state"}).exit_code, 0); + auto r = box.exec({"box", "--", "cat", "/tmp/state"}); + EXPECT_EQ(r.exit_code, 0) << r.transcript(); + EXPECT_EQ(r.out, "hot\n"); + EXPECT_EQ(box.home.xlings({"subos", "stop", "box"}).exit_code, 0); + EXPECT_FALSE(fs::exists(box.home.dir() / "run" / "subos" / "box" / "session.json")); +} + +XTEST(SubosExec, ATemporaryInstanceGoesAndItsAuditStays, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"SES-TEMP"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}) { + Box box; + auto r = box.exec({"--temp", "--sandbox", "--json", "--", "/bin/sh", "-c", "echo t; exit 2"}); + EXPECT_EQ(r.exit_code, 2) << r.transcript(); + auto result = nlohmann::json::parse(r.err.substr(r.err.rfind('{')), nullptr, false); + ASSERT_FALSE(result.is_discarded()) << r.transcript(); + const auto name = result.value("instance", ""); + ASSERT_TRUE(name.starts_with("tmp-")) << name; + EXPECT_FALSE(fs::exists(box.home.dir() / "subos" / name)); + EXPECT_TRUE(fs::exists(box.home.dir() / "logs" / "subos" / name / "events.ndjson")); + auto list = box.home.xlings({"subos", "list"}); + EXPECT_EQ(list.out.find(name), std::string::npos) << "unregistered too"; +} + +XTEST(SubosExec, CpCopiesIntoAndOutOfTheInstancesOwnTreesOnly, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"SES-CP"}, + .requires_ = {"xlings-bin"}) { + Box box; + auto host_file = box.home.root() / "in.txt"; + tk::write_file(host_file, "payload"); + auto in = box.home.xlings({"subos", "cp", host_file.string(), "box:/tmp/"}); + ASSERT_EQ(in.exit_code, 0) << in.transcript(); + EXPECT_EQ(tk::read_file(box.home.dir() / "subos" / "box" / "tmp" / "in.txt"), "payload"); + + auto out_file = box.home.root() / "out.txt"; + auto out = box.home.xlings({"subos", "cp", "box:/tmp/in.txt", out_file.string()}); + ASSERT_EQ(out.exit_code, 0) << out.transcript(); + EXPECT_EQ(tk::read_file(out_file), "payload"); + + auto refused = box.home.xlings({"subos", "cp", host_file.string(), "box:/etc/passwd"}); + EXPECT_NE(refused.exit_code, 0); +} + +// What the instance planted in its own tree must not steer the owner's copy +// (docker's CVE-2018-15664 shape): a link is replaced or copied as a link, +// never written or read through. +XTEST(SubosExec, CpNeverFollowsALinkTheInstancePlanted, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"SES-CP"}, + .requires_ = {"linux", "xlings-bin"}, .proves = "isolation") { + Box box; + const auto tmp = box.home.dir() / "subos" / "box" / "tmp"; + const auto victim = box.home.root() / "owner-bashrc"; + const auto victim_dir = box.home.root() / "owner-dir"; + fs::create_directories(tmp); + fs::create_directories(victim_dir); + tk::write_file(victim, "owner's own\n"); + fs::create_symlink(victim, tmp / "link"); // as a process inside would + fs::create_symlink(victim_dir, tmp / "dirlink"); + auto payload = box.home.root() / "payload.txt"; + tk::write_file(payload, "from the owner\n"); + + // In, onto the link: the link is replaced, the owner's file untouched. + auto in = box.home.xlings({"subos", "cp", payload.string(), "box:/tmp/link"}); + EXPECT_EQ(in.exit_code, 0) << in.transcript(); + EXPECT_EQ(tk::read_file(victim), "owner's own\n"); + EXPECT_FALSE(fs::is_symlink(tmp / "link")); + EXPECT_EQ(tk::read_file(tmp / "link"), "from the owner\n"); + + // In, through a linked directory: refused, nothing lands outside. + auto through = box.home.xlings({"subos", "cp", payload.string(), "box:/tmp/dirlink/x"}); + EXPECT_NE(through.exit_code, 0) << through.transcript(); + EXPECT_FALSE(fs::exists(victim_dir / "x")); + + // Out: a link is copied as a link, not as what it points at. + fs::create_symlink(victim, tmp / "secret"); + const auto got = box.home.root() / "got"; + auto out = box.home.xlings({"subos", "cp", "box:/tmp/secret", got.string()}); + EXPECT_EQ(out.exit_code, 0) << out.transcript(); + EXPECT_TRUE(fs::is_symlink(got)) << "the link's target was read on the owner's behalf"; +} + +XTEST(SubosExec, TheInterfaceRunsArgvAndReturnsTheExitCode, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"F16"}, + .requires_ = {"posix", "xlings-bin"}) { + Box box; + nlohmann::json args{{"name", "box"}, {"argv", {"/bin/sh", "-c", "echo from-iface; exit 3"}}}; + auto r = box.home.xlings({"interface", "subos_exec", "--args", args.dump()}); + std::string output; + std::optional exitCode; + for (auto& line : r.json_lines()) { + if (line.value("kind", "") == "data" && line.value("dataKind", "") == "subos_exec_output") + output += line["payload"].value("data", ""); + if (line.value("kind", "") == "result") exitCode = line.value("exitCode", -1); + } + EXPECT_NE(output.find("from-iface"), std::string::npos) << r.transcript(); + ASSERT_TRUE(exitCode.has_value()) << r.transcript(); + EXPECT_EQ(*exitCode, 3); +} diff --git a/tests/e2e/test_subos_home_ro.cpp b/tests/e2e/test_subos_home_ro.cpp new file mode 100644 index 000000000..31ca5d66b --- /dev/null +++ b/tests/e2e/test_subos_home_ro.cpp @@ -0,0 +1,171 @@ +// The xlings home as a sandbox sees it (C10) and the broker that changes it +// on the sandbox's behalf (C15). +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.libs.json; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; + +namespace { + +struct Box { + tk::Home home = tk::Home::isolated("home-ro"); + Box() { + home.seed_sandbox_backend(); + for (auto n : {"box", "other"}) { + auto r = home.xlings({"subos", "new", n}); + if (r.exit_code != 0) ADD_FAILURE() << r.transcript(); + } + } + tk::RunResult in(const std::string& script) const { + return home.xlings({"subos", "exec", "box", "--sandbox", "--", "/bin/sh", "-c", script}); + } +}; + +} // namespace + +XTEST(SubosHomeReadOnly, TheHomeIsReadOnlyExceptTheInstancesOwnTree, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"F1", "POL-OUTSIDE-INSTANCE"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}, + .proves = "isolation") { + Box box; + ASSERT_EQ(box.home.xlings({"subos", "config", "box", "--sandbox=dev"}).exit_code, 0); + const auto h = box.home.dir().string(); + auto r = box.in( + "for f in bin/evil .xlings.json data/evil config/subos/box/policy.json; do " + " if touch \"$XLINGS_HOME/$f\" 2>/dev/null; then echo \"WROTE $f\"; else echo \"RO $f\"; fi; " + "done; " + "cat $XLINGS_HOME/config/subos/box/policy.json >/dev/null && echo POLICY-READABLE; " + "touch $XLINGS_HOME/subos/box/mine && echo OWN-WRITABLE"); + ASSERT_EQ(r.exit_code, 0) << r.transcript(); + EXPECT_EQ(r.out.find("WROTE"), std::string::npos) << r.out; + EXPECT_NE(r.out.find("RO bin/evil"), std::string::npos) << r.out; + EXPECT_NE(r.out.find("RO config/subos/box/policy.json"), std::string::npos) << r.out; + EXPECT_NE(r.out.find("POLICY-READABLE"), std::string::npos) << r.out; + EXPECT_NE(r.out.find("OWN-WRITABLE"), std::string::npos) << r.out; + EXPECT_FALSE(fs::exists(box.home.dir() / "bin" / "evil")); +} + +XTEST(SubosHomeReadOnly, OtherInstancesAndTheAuditAreOutOfSight, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"F6", "F15"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}, + .proves = "isolation") { + Box box; + tk::write_file(box.home.dir() / "subos" / "other" / "home" / "secret", "x"); + // Some audit exists before this session starts. + ASSERT_EQ(box.in("true").exit_code, 0); + auto r = box.in("echo subos=$(ls $XLINGS_HOME/subos | tr '\\n' ,); " + "echo logs=$(ls -A $XLINGS_HOME/logs | wc -l); " + "echo run=$(ls -A $XLINGS_HOME/run | wc -l)"); + ASSERT_EQ(r.exit_code, 0) << r.transcript(); + EXPECT_NE(r.out.find("subos=box,"), std::string::npos) << r.out; // not `other` + EXPECT_NE(r.out.find("logs=0"), std::string::npos) << r.out; // the audit is not there + EXPECT_NE(r.out.find("run=0"), std::string::npos) << r.out; // nor the sockets + // ...and outside, it is. + EXPECT_TRUE(fs::exists(box.home.dir() / "logs" / "subos" / "box" / "events.ndjson")); +} + +XTEST(SubosHomeReadOnly, ReadingWorksInsideAndWritesGoThroughTheBroker, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"OBS-PERM"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}) { + Box box; + auto r = box.in("xlings --version && xlings subos list >/dev/null && echo READS-OK; " + "xlings remove not-installed-anywhere -y; echo remove=$?"); + EXPECT_NE(r.out.find("READS-OK"), std::string::npos) << r.transcript(); + // The broker ran it outside and brought back its exit code -- not a hang + // on a lock file it could not write, not a refusal. + EXPECT_EQ(r.out.find("remove=13"), std::string::npos) << r.transcript(); + EXPECT_EQ(r.out.find("remove=0"), std::string::npos) << r.transcript(); + auto log = box.home.xlings({"subos", "log", "box", "--json", "--kind", "perm"}); + auto ev = log.json_lines(); + EXPECT_TRUE(std::ranges::any_of(ev, [](auto& e) { + return e.value("event", "") == "decision" && e.value("program", "") == "remove"; })) + << log.transcript(); +} + +XTEST(SubosHomeReadOnly, WhatOnlyTheOwnerDoesIsRefusedInside, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"EXIT-13"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}) { + Box box; + auto r = box.in("xlings self update; echo self=$?; " + "xlings subos remove other -y; echo subos=$?; " + "xlings install x --subos other; echo other=$?"); + EXPECT_NE(r.out.find("self=13"), std::string::npos) << r.transcript(); + EXPECT_NE(r.out.find("subos=13"), std::string::npos) << r.transcript(); + EXPECT_NE(r.out.find("other=13"), std::string::npos) << r.transcript(); + EXPECT_TRUE(fs::exists(box.home.dir() / "subos" / "other")); +} + +XTEST(SubosHomeReadOnly, FetchDenyAndAskAreDecidedOutside, + .area = "subos", .cost = tk::Cost::Medium, + .covers = {"PERM-FETCH-DENY", "PERM-FETCH-ASK", "EXIT-75"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}) { + Box box; + ASSERT_EQ(box.home.xlings({"subos", "config", "box", "--fetch", "deny"}).exit_code, 0); + auto denied = box.in("xlings install -y anything; echo rc=$?"); + EXPECT_NE(denied.out.find("rc=13"), std::string::npos) << denied.transcript(); + EXPECT_NE(denied.transcript().find("xlings install anything --subos box"), std::string::npos) + << denied.transcript(); + + ASSERT_EQ(box.home.xlings({"subos", "config", "box", "--fetch", "ask"}).exit_code, 0); + auto asked = box.in("xlings install -y anything; echo rc=$?"); + EXPECT_NE(asked.out.find("rc=75"), std::string::npos) << asked.transcript(); + auto reqs = box.home.xlings({"subos", "requests", "box", "--json"}); + auto lines = reqs.json_lines(); + ASSERT_EQ(lines.size(), 1u) << reqs.transcript(); + const auto id = lines[0].value("id", ""); + EXPECT_EQ(box.home.xlings({"subos", "deny", "box", id}).exit_code, 0); + EXPECT_TRUE(box.home.xlings({"subos", "requests", "box", "--json"}).json_lines().empty()); + auto log = tk::read_file(box.home.dir() / "logs" / "subos" / "box" / "events.ndjson"); + EXPECT_NE(log.find("request-denied"), std::string::npos); +} + +XTEST(SubosHomeReadOnly, AutoFetchInstallsFromInsideAndInstallSubosFromOutside, + .area = "subos", .cost = tk::Cost::Slow, + .covers = {"PERM-FETCH-AUTO", "PERM-INSTALL-SUBOS"}, + .requires_ = {"linux", "xlings-bin", "sandbox", "network"}, .resources = {"sandbox"}) { + Box box; + ASSERT_EQ(box.home.xlings({"self", "init"}, {}, std::chrono::minutes(5)).exit_code, 0); + auto outside = box.home.xlings({"install", "-y", "ninja", "--subos", "other"}, {}, + std::chrono::minutes(5)); + EXPECT_EQ(outside.exit_code, 0) << outside.transcript(); + auto listed = box.home.xlings({"list"}, {{"XLINGS_ACTIVE_SUBOS", "other"}}); + EXPECT_NE(listed.out.find("ninja"), std::string::npos) << listed.transcript(); + + auto inside = box.home.xlings({"subos", "exec", "box", "--sandbox", "--", "xlings", "install", + "-y", "ninja"}, {}, std::chrono::minutes(5)); + EXPECT_EQ(inside.exit_code, 0) << inside.transcript(); + auto box_list = box.home.xlings({"list"}, {{"XLINGS_ACTIVE_SUBOS", "box"}}); + EXPECT_NE(box_list.out.find("ninja"), std::string::npos) << box_list.transcript(); +} + +namespace { +// Talk to the broker the way a hostile process inside would: directly, with +// any argv, skipping the client's own classification. +const char* kRawBrokerRequest = + "import socket,json,array,sys,os\n" + "s=socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET)\n" + "s.connect(os.environ.get('XLINGS_BROKER_SOCKET') or '/run/xlings/broker.sock')\n" + "s.sendmsg([json.dumps({'op':'run','argv':sys.argv[1:]}).encode()],\n" + " [(socket.SOL_SOCKET, socket.SCM_RIGHTS, array.array('i',[0,1,2]))])\n" + "print('REPLY', s.recv(65536).decode())\n"; +} + +XTEST(SubosHomeReadOnly, TheBrokerRunsOnlyWhatChangesThisInstance, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"OBS-PERM", "EXIT-13"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}, + .proves = "isolation") { + Box box; + // `interface` is what the client runs locally; run OUTSIDE, as the + // owner, it would create an instance. The broker must refuse it. + auto r = box.home.xlings({"subos", "exec", "box", "--sandbox", "--", "python3", "-c", kRawBrokerRequest, + "interface", "create_subos", "--args", R"({"name":"pwned"})"}); + if (r.transcript().find("No such file") != std::string::npos) GTEST_SKIP() << "no python3"; + EXPECT_NE(r.out.find("REPLY"), std::string::npos) << r.transcript(); + EXPECT_NE(r.out.find("\"exit\":13"), std::string::npos) << r.transcript(); + EXPECT_FALSE(fs::exists(box.home.dir() / "subos" / "pwned")) << "the broker ran a command it must not"; +} diff --git a/tests/e2e/test_subos_isolation.cpp b/tests/e2e/test_subos_isolation.cpp new file mode 100644 index 000000000..3ce0a9bfe --- /dev/null +++ b/tests/e2e/test_subos_isolation.cpp @@ -0,0 +1,168 @@ +// Isolation, measured from inside a real sandbox (L4, design §24.4). +// +// Each case runs `xlings subos use --sandbox --cmd ...` with the backend +// this host has and asserts what the process inside can and cannot see. +// These are the only tests that may cover an isolation requirement +// (`proves = "isolation"`): the spec goldens show what was asked for; these +// show the kernel did it. +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; + +namespace { + +// A home with a sandbox backend and one instance. +struct Box { + tk::Home home = tk::Home::isolated("iso"); + std::string name = "box"; + + Box() { + home.seed_sandbox_backend(); + auto r = home.xlings({"subos", "new", name}); + if (r.exit_code != 0) ADD_FAILURE() << r.transcript(); + } + + tk::RunResult run(const std::string& cmd, std::map env = {}) const { + return home.xlings({"subos", "use", name, "--sandbox", "--cmd", cmd}, std::move(env)); + } +}; + +std::string trim(std::string s) { + while (!s.empty() && (s.back() == '\n' || s.back() == '\r' || s.back() == ' ')) s.pop_back(); + return s; +} + +} // namespace + +XTEST(SubosIsolation, OnlyAllowListedEnvironmentEnters, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"F3"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}, + .proves = "isolation") { + Box box; + auto r = box.run("env", {{"GITHUB_TOKEN", "do-not-leak"}, {"SSH_AUTH_SOCK", "/tmp/agent"}, + {"EDITOR", "vi"}, {"LANG", "C.UTF-8"}}); + ASSERT_EQ(r.exit_code, 0) << r.transcript(); + EXPECT_EQ(r.out.find("do-not-leak"), std::string::npos) << r.out; + EXPECT_EQ(r.out.find("SSH_AUTH_SOCK"), std::string::npos) << r.out; + EXPECT_NE(r.out.find("EDITOR=vi"), std::string::npos) << r.out; + EXPECT_NE(r.out.find("XLINGS_SUBOS_MODE=sandbox"), std::string::npos) << r.out; +} + +XTEST(SubosIsolation, HostProcessesAreInvisible, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"F4"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}, + .proves = "isolation") { + Box box; + auto r = box.run("ls /proc | grep -c '^[0-9][0-9]*$'"); + ASSERT_EQ(r.exit_code, 0) << r.transcript(); + // bwrap's init, the shell, ls and grep -- not the host's hundreds. The + // count is the last line; what xlings itself said comes before it. + auto out = trim(r.out); + auto last = out.substr(out.rfind('\n') == std::string::npos ? 0 : out.rfind('\n') + 1); + ASSERT_FALSE(last.empty()) << r.transcript(); + ASSERT_TRUE(std::isdigit(static_cast(last[0]))) << r.transcript(); + EXPECT_LT(std::stoi(last), 10) << r.out; +} + +XTEST(SubosIsolation, ANonInteractiveCommandHasNoControllingTerminal, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"F8"}, + .requires_ = {"linux", "xlings-bin", "sandbox", "pty"}, .resources = {"sandbox"}, + .proves = "isolation") { + Box box; + // The case that matters: run from a terminal with stdin redirected. The + // caller HAS a controlling terminal; the command inside must not, or it + // could open /dev/tty and push input into the user's shell. + // Field 7 of /proc//stat is the controlling terminal (0 = none). + tk::RunOptions o; + o.argv = {"/bin/sh", "-c", "exec \"$0\" \"$@\" < /dev/null", + tk::xlings_binary().string(), "subos", "use", box.name, "--sandbox", "--cmd", + R"(set -- $(cat /proc/$$/stat); echo "TTY_NR=$7")"}; + o.env = box.home.env(); + o.pty = true; + o.timeout = std::chrono::seconds(60); + auto r = tk::run(o); + EXPECT_NE(r.out.find("TTY_NR=0"), std::string::npos) << r.transcript(); +} + +XTEST(SubosIsolation, AnInteractiveSandboxCannotInjectIntoTheTerminal, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"F8"}, + .requires_ = {"linux", "xlings-bin", "sandbox", "pty"}, .resources = {"sandbox"}, + .proves = "isolation") { + Box box; + tk::RunOptions o; + o.argv = {"subos", "use", box.name, "--sandbox", "--cmd", + "python3 -c 'import fcntl,termios\n" + "try:\n fcntl.ioctl(0, termios.TIOCSTI, b\"x\"); print(\"TIOCSTI-ALLOWED\")\n" + "except OSError as e: print(\"TIOCSTI-ERRNO\", e.errno)'"}; + o.pty = true; + o.timeout = std::chrono::seconds(60); + auto r = box.home.xlings(o); + if (r.out.find("python3: not found") != std::string::npos + || r.out.find("No such file") != std::string::npos) + GTEST_SKIP() << "no python3 on this host"; + // EPERM (1) is the filter. A kernel with legacy_tiocsti=0 would answer + // EIO on its own; EPERM proves it was ours. + EXPECT_NE(r.out.find("TIOCSTI-ERRNO 1"), std::string::npos) << r.transcript(); +} + +XTEST(SubosIsolation, AFailedProbeQuotesBwrapAndNeverAdvisesASysctl, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"F12"}, + .requires_ = {"linux", "xlings-bin"}) { + auto home = tk::Home::isolated("probe-text"); + // A root-owned or system bwrap is tried before the payload, so the fake + // below is only reached on a host where none of those works. + for (const char* host : {"/usr/lib/xlings/bwrap", "/usr/bin/bwrap", "/usr/local/bin/bwrap"}) { + if (!fs::exists(host)) continue; + if (tk::run({.argv = {host, "--ro-bind", "/", "/", "--", "/bin/true"}}).exit_code == 0) + GTEST_SKIP() << host << " works here; the isolation-fix lane covers the failing case"; + } + // A bwrap that fails the way Ubuntu 24.04's AppArmor makes it fail. + auto bin = home.dir() / "data" / "xpkgs" / "xim-x-bwrap" / "0.0.0" / "bin" / "bwrap"; + tk::write_file(bin, "#!/bin/sh\necho 'bwrap: setting up uid map: Permission denied' >&2\nexit 1\n"); + fs::permissions(bin, fs::perms::owner_all); + ASSERT_EQ(home.xlings({"subos", "new", "box"}).exit_code, 0); + auto r = home.xlings({"subos", "use", "box", "--sandbox", "bwrap", "--cmd", "true"}); + EXPECT_NE(r.exit_code, 0); + auto all = r.transcript(); + EXPECT_NE(all.find("setting up uid map: Permission denied"), std::string::npos) << all; + EXPECT_NE(all.find("self doctor --isolation --fix"), std::string::npos) << all; + EXPECT_EQ(all.find("sysctl -w"), std::string::npos) << all; +} + +XTEST(SubosIsolation, LandlockFencesWritesWithoutANamespace, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"ISO-LANDLOCK"}, + .requires_ = {"linux", "xlings-bin", "landlock"}, .proves = "isolation") { + auto home = tk::Home::isolated("landlock"); + ASSERT_EQ(home.xlings({"subos", "new", "box"}).exit_code, 0); + const auto outside = home.root() / "outside"; + const auto mounted = home.root() / "work"; + fs::create_directories(outside); + fs::create_directories(mounted); + tk::write_file(outside / "file", "keep\n"); + const auto cfg_before = tk::read_file(home.dir() / ".xlings.json"); + auto r = home.xlings({"subos", "exec", "box", "--sandbox", "landlock", "--mount", mounted.string(), + "--", "/bin/sh", "-c", + "echo x >> \"$XLINGS_HOME/.xlings.json\" 2>/dev/null && echo WROTE-HOME || echo RO-HOME; " + "echo x >> '" + (outside / "file").string() + "' 2>/dev/null && echo WROTE-OUTSIDE || echo RO-OUTSIDE; " + "touch /tmp/xlings-landlock-$$ 2>/dev/null && echo WROTE-TMP || echo RO-TMP; " + "touch \"$HOME/mine\" && echo OWN-HOME; " + "t=$(mktemp) && echo OWN-TMP; " + "echo y > '" + (mounted / "out").string() + "' && echo MOUNTED; " + "cat '" + (outside / "file").string() + "'"}); + ASSERT_EQ(r.exit_code, 0) << r.transcript(); + for (auto want : {"RO-HOME", "RO-OUTSIDE", "RO-TMP", "OWN-HOME", "OWN-TMP", "MOUNTED", "keep"}) + EXPECT_NE(r.out.find(want), std::string::npos) << want << "\n" << r.transcript(); + EXPECT_EQ(tk::read_file(home.dir() / ".xlings.json"), cfg_before); + EXPECT_EQ(tk::read_file(outside / "file"), "keep\n"); + EXPECT_EQ(tk::read_file(mounted / "out"), "y\n"); + + // What only namespaces give is refused, with the reason. + auto locked = home.xlings({"subos", "exec", "box", "--sandbox=locked", "--sandbox", "landlock", "--", "true"}); + EXPECT_EQ(locked.exit_code, 125) << locked.transcript(); + EXPECT_NE(locked.transcript().find("Landlock"), std::string::npos) << locked.transcript(); +} diff --git a/tests/e2e/test_subos_native_session.cpp b/tests/e2e/test_subos_native_session.cpp new file mode 100644 index 000000000..5d564ba14 --- /dev/null +++ b/tests/e2e/test_subos_native_session.cpp @@ -0,0 +1,90 @@ +// A native SubOS on macOS and Windows under the same contract as Linux +// (SubOS design part 3 §6.3): on macOS the home redirect runs under the +// supervisor -- start, join, stop, --timeout, the exit-code table; on Windows +// its command runs in a Job Object -- the whole tree ends at --timeout (124) +// and the exit code is the command's. +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.subos.caps; +import xlings.subos.home_view; +import xlings.subos.policy; +import xlings.subos.spec; +import xlings.confine; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; + +namespace { + +struct Box { + tk::Home home = tk::Home::isolated("native"); + Box() { + auto r = home.xlings({"subos", "new", "box"}); + if (r.exit_code != 0) ADD_FAILURE() << r.transcript(); + } + tk::RunResult exec(std::vector args) const { + std::vector a{"subos", "exec", "box", "--sandbox"}; + a.insert(a.end(), args.begin(), args.end()); + return home.xlings(a); + } +}; + +} // namespace + +XTEST(NativeSession, TheHomeRedirectLaunchesAsTheSupervisorsSessionInit, + .area = "subos", .covers = {"SESSION-NATIVE-SUPERVISED"}) { + // The mechanism the macOS supervisor stands on, on any host: a home + // redirect is started as this binary's __session-init with its command + // after it -- the same entry a Linux sandbox's supervisor starts. + namespace sp = xlings::subos::spec; + xlings::subos::caps::Caps mac; + mac.platform = "macos"; + sp::Request r; + r.instance = "box"; + r.instance_dir = "/h/subos/box"; + r.user = "u"; + r.argv = {"/bin/echo", "hi"}; + auto compiled = xlings::confine::compile(xlings::subos::policy::legacy(), xlings::subos::HomeView{"/h"}, mac, r); + ASSERT_TRUE(compiled); + EXPECT_EQ(compiled->backend, sp::Backend::HomeRedirect); + auto launched = *compiled; + launched.argv = {"/run/xlings/xlings", "__session-init", "--", "/bin/echo", "hi"}; + EXPECT_EQ(xlings::confine::launch_argv(launched, std::nullopt, "/Applications/x/bin/xlings"), + (std::vector{"/Applications/x/bin/xlings", "__session-init", "--", "/bin/echo", "hi"})); +} + +XTEST(NativeSession, MacosRunsTheHomeRedirectUnderTheSupervisor, + .area = "subos", .cost = tk::Cost::Medium, + .covers = {"SESSION-NATIVE-SUPERVISED"}, .requires_ = {"macos", "xlings-bin"}) { + Box box; + EXPECT_EQ(box.exec({"--", "/bin/sh", "-c", "exit 3"}).exit_code, 3); + EXPECT_EQ(box.exec({"--", "no-such-program"}).exit_code, 127); + EXPECT_EQ(box.exec({"--timeout", "1", "--", "/bin/sleep", "30"}).exit_code, 124); + EXPECT_EQ(box.exec({"--", "/bin/sh", "-c", "kill -TERM $$"}).exit_code, 128 + 15); + // A hot session: what the first command leaves, the second -- joining -- + // sees; its home is the instance's (the redirect). + auto started = box.home.xlings({"subos", "start", "box", "--ttl", "60"}); + ASSERT_EQ(started.exit_code, 0) << started.transcript(); + EXPECT_TRUE(fs::exists(box.home.dir() / "run" / "subos" / "box" / "session.json")); + EXPECT_EQ(box.exec({"--", "/bin/sh", "-c", "echo hot > \"$HOME/state\""}).exit_code, 0); + auto r = box.exec({"--", "/bin/sh", "-c", "cat \"$HOME/state\"; echo \"$HOME\""}); + EXPECT_EQ(r.exit_code, 0) << r.transcript(); + EXPECT_NE(r.out.find("hot\n"), std::string::npos) << r.transcript(); + EXPECT_NE(r.out.find((box.home.dir() / "subos" / "box").string()), std::string::npos) << r.transcript(); + EXPECT_EQ(box.home.xlings({"subos", "stop", "box"}).exit_code, 0); + EXPECT_FALSE(fs::exists(box.home.dir() / "run" / "subos" / "box" / "session.json")); +} + +XTEST(NativeSession, WindowsRunsTheCommandInAJobAndEndsTheWholeTreeAtTheTimeout, + .area = "subos", .cost = tk::Cost::Medium, + .covers = {"SESSION-NATIVE-SUPERVISED"}, .requires_ = {"windows", "xlings-bin"}) { + Box box; + EXPECT_EQ(box.exec({"--", "cmd.exe", "/c", "exit 3"}).exit_code, 3); + const auto started = std::chrono::steady_clock::now(); + // cmd starts ping: a tree, not one process -- the job ends both. + EXPECT_EQ(box.exec({"--timeout", "2", "--", "cmd.exe", "/c", "ping -n 30 127.0.0.1 >NUL"}).exit_code, 124); + EXPECT_LT(std::chrono::steady_clock::now() - started, std::chrono::seconds(20)); +} diff --git a/tests/e2e/test_subos_network.cpp b/tests/e2e/test_subos_network.cpp new file mode 100644 index 000000000..436b524a2 --- /dev/null +++ b/tests/e2e/test_subos_network.cpp @@ -0,0 +1,171 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" +import std; +import xlings.libs.json; +import xlings.platform; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; +namespace platform = xlings::platform; + +namespace { + +bool read_exact(platform::TcpStream& connection, std::span output) { + while (!output.empty()) { + const auto read = connection.receive(output, std::chrono::seconds(3)); + if (!read || *read == 0) return false; + output = output.subspan(*read); + } + return true; +} + +struct ProxyFixture { + platform::LoopbackListener listener; + std::jthread worker; + std::mutex mutex; + std::vector domains; + std::atomic connections { 0 }; + std::string error; + ProxyFixture() { + auto opened = platform::LoopbackListener::open(); + if (!opened) throw std::runtime_error(opened.error()); + listener = std::move(*opened); + worker = std::jthread([this](std::stop_token stop) { + while (!stop.stop_requested()) { + auto accepted = listener.accept(std::chrono::milliseconds(50)); + if (!accepted) { error = accepted.error(); return; } + if (!accepted->has_value()) continue; + ++connections; + auto& stream = **accepted; + std::array greeting{}; + if (!read_exact(stream, greeting) || greeting != std::array{5, 1, 0}) { + error = "invalid SOCKS greeting"; return; + } + if (!stream.send_all(std::string{5, 0}, std::chrono::seconds(2))) { error = "send method"; return; } + std::array header{}; + if (!read_exact(stream, header) || header[0] != 5 || header[1] != 1 || header[3] != 3) { + error = "target domain was not passed to SOCKS"; return; + } + std::string name(static_cast(header[4]), '\0'); + std::array port{}; + if (!read_exact(stream, name) || !read_exact(stream, port)) { error = "incomplete target"; return; } + { + std::lock_guard guard{mutex}; + domains.push_back(name); + } + const std::string reply{5, 0, 0, 1, 0, 0, 0, 0, 0, 0}; + if (!stream.send_all(reply, std::chrono::seconds(2))) { error = "send SOCKS reply"; return; } + std::string request; + std::array bytes{}; + while (request.find("\r\n\r\n") == std::string::npos && request.size() < 8192) { + const auto read = stream.receive(bytes, std::chrono::seconds(3)); + if (!read || *read == 0) { error = "incomplete HTTP request"; return; } + request.append(bytes.data(), *read); + } + const std::string body { "through-declared-proxy" }; + if (!stream.send_all(std::format("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.size(), body), std::chrono::seconds(2))) { error = "send HTTP fixture"; return; } + } + }); + } + ~ProxyFixture() { finish(); } + std::string url() const { return "socks5h://127.0.0.1:" + std::to_string(listener.port()); } + void finish() { worker.request_stop(); if (worker.joinable()) worker.join(); } +}; + +struct Box { + tk::Home home = tk::Home::isolated("proxy-network"); + Box() { + home.seed_sandbox_backend(); + const auto created = home.xlings({"subos", "new", "box"}); + if (created.exit_code != 0) ADD_FAILURE() << created.transcript(); + } + std::vector events() const { + return home.xlings({"subos", "log", "box", "--json", "-n", "1000"}).json_lines(); + } +}; + +} // namespace + +XTEST(SubosNetworkE2E, ProxyHasOneExitAndPassesTheUnresolvableDomainToThatExit, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"ISO-NET-PROXY", "OBS-NET", "OBS-EXEC"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}, .proves = "isolation") { + if (!fs::is_regular_file("/usr/bin/curl")) GTEST_SKIP() << "curl HTTP probe unavailable"; + ProxyFixture proxy; + Box box; + const auto configured = box.home.xlings({"subos", "config", "box", "--sandbox", "dev", "--proxy", proxy.url(), + "--observe", "full"}); + ASSERT_EQ(configured.exit_code, 0) << configured.transcript(); + const auto proxied = box.home.xlings({"subos", "exec", "box", "--", "/usr/bin/curl", "--max-time", "10", + "--silent", "--show-error", "http://must-not-resolve-on-host.invalid/payload"}); + ASSERT_EQ(proxied.exit_code, 0) << proxied.transcript(); + EXPECT_EQ(proxied.out, "through-declared-proxy"); + { + std::lock_guard guard{proxy.mutex}; + ASSERT_EQ(proxy.domains.size(), 1); + EXPECT_EQ(proxy.domains.front(), "must-not-resolve-on-host.invalid"); + } + auto host = platform::LoopbackListener::open(); + ASSERT_TRUE(host) << host.error(); + // Clearing ALL_PROXY/NO_PROXY changes client behavior, not the namespace. + const auto refused = box.home.xlings({"subos", "exec", "box", "--", "/usr/bin/curl", "--noproxy", "*", + "--connect-timeout", "1", "--max-time", "2", "--silent", "--show-error", + "http://127.0.0.1:" + std::to_string(host->port()) + "/host-secret"}); + EXPECT_NE(refused.exit_code, 0) << refused.transcript(); + const auto incoming = host->accept(std::chrono::milliseconds(20)); + ASSERT_TRUE(incoming); + EXPECT_FALSE(incoming->has_value()) << "namespace must hide the host's loopback service"; + const auto bypass = box.home.xlings({"subos", "exec", "box", "--", "/bin/bash", "-c", + "if echo leak >/dev/tcp/198.51.100.7/80; then exit 10; fi; " + "if echo dns-leak >/dev/udp/198.51.100.7/53; then exit 11; fi; " + "echo interfaces; tail -n +3 /proc/net/dev; echo routes; cat /proc/net/route"}); + ASSERT_EQ(bypass.exit_code, 0) << bypass.transcript(); + EXPECT_NE(bypass.out.find("lo:"), std::string::npos); + EXPECT_EQ(bypass.out.find("eth"), std::string::npos); + EXPECT_EQ(bypass.out.find("tap"), std::string::npos); + bool attempted { false }, accepted { false }, executed { false }, notified { false }; + for (const auto& event : box.events()) { + if (event.value("kind", "") == "exec" && event.value("path", "").find("curl") != std::string::npos) + executed = true; + if (event.value("kind", "") == "net" && event.value("event", "") == "net-attempt") { + notified = true; + EXPECT_EQ(event.value("mode", ""), "proxy"); + } + if (event.value("event", "") == "connect-attempt") { + attempted = true; + EXPECT_EQ(event["target"], "must-not-resolve-on-host.invalid"); + EXPECT_EQ(event["remote_dns"], true); + } + if (event.value("event", "") == "proxy-result") accepted = event.value("reason", "") == "accepted"; + } + EXPECT_TRUE(attempted); + EXPECT_TRUE(accepted); + EXPECT_TRUE(executed) << "full observation must acknowledge curl's exec syscall"; + EXPECT_TRUE(notified) << "the same listener must report proxy namespace network syscalls"; + proxy.finish(); + EXPECT_TRUE(proxy.error.empty()) << proxy.error; + EXPECT_EQ(proxy.connections.load(), 1) << "direct probes cannot reach the proxy by bypassing the gate"; +} + +XTEST(SubosNetworkE2E, NatAuditsConnectAndDnsPortRequestsBeforeContinuing, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"OBS-NET", "ISO-NET-NAT", "OBS-EXEC"}, + .requires_ = {"linux", "xlings-bin", "sandbox", "pasta"}, .resources = {"sandbox"}, .proves = "isolation") { + Box box; + const auto run = box.home.xlings({"subos", "exec", "box", "--sandbox=private", "--observe", "full", + "--", "/bin/bash", "-c", "echo dns >/dev/udp/127.0.0.1/53 || true"}); + ASSERT_EQ(run.exit_code, 0) << run.transcript(); + bool request { false }, executed { false }; + for (const auto& event : box.events()) { + if (event.value("kind", "") == "exec" && event.value("path", "").find("bash") != std::string::npos) + executed = true; + if (event.value("kind", "") != "net" || event.value("event", "") != "net-attempt" || + event.value("address", "") != "127.0.0.1") continue; + request = true; + EXPECT_EQ(event["mode"], "nat"); + EXPECT_EQ(event["port"], 53); + EXPECT_EQ(event["result"], "unknown") << "notification CONTINUE proves only a request"; + } + EXPECT_TRUE(request); + EXPECT_TRUE(executed) << "full observation must acknowledge bash's exec syscall"; +} diff --git a/tests/e2e/test_subos_observe.cpp b/tests/e2e/test_subos_observe.cpp new file mode 100644 index 000000000..785f849ca --- /dev/null +++ b/tests/e2e/test_subos_observe.cpp @@ -0,0 +1,192 @@ +// Observability (C22, design §22): what happened in an instance, recorded by +// the supervisor outside it, read back through log / report / the interface, +// and never carrying a secret's value. +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.libs.json; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; + +namespace { + +struct Box { + tk::Home home = tk::Home::isolated("observe"); + Box() { + home.seed_sandbox_backend(); + auto r = home.xlings({"subos", "new", "box"}); + if (r.exit_code != 0) ADD_FAILURE() << r.transcript(); + } + std::vector log(std::string kind) const { + return home.xlings({"subos", "log", "box", "--json", "--kind", kind, "-n", "1000"}).json_lines(); + } +}; + +} // namespace + +XTEST(SubosObserve, EveryProgramALockedSandboxRunsIsRecorded, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"OBS-EXEC"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}, + .proves = "isolation") { + Box box; + auto r = box.home.xlings({"subos", "exec", "box", "--sandbox=locked", "--", "/bin/sh", "-c", + "ls / >/dev/null; uname >/dev/null; echo done"}); + ASSERT_EQ(r.exit_code, 0) << r.transcript(); + std::vector paths; + for (auto& e : box.log("exec")) paths.push_back(e.value("path", "")); + auto ran = [&](std::string_view tail) { + return std::ranges::any_of(paths, [&](auto& p) { return p.ends_with(tail); }); + }; + EXPECT_TRUE(ran("/sh")) << nlohmann::json(paths).dump(); + EXPECT_TRUE(ran("/ls")) << nlohmann::json(paths).dump(); + EXPECT_TRUE(ran("/uname")) << nlohmann::json(paths).dump(); + + auto report = box.home.xlings({"subos", "report", "box", "--json"}); + auto j = nlohmann::json::parse(report.out, nullptr, false); + ASSERT_FALSE(j.is_discarded()) << report.transcript(); + ASSERT_FALSE(j["sessions"].empty()); + EXPECT_GE(j["sessions"].back()["executions"].get(), 3); + EXPECT_TRUE(j["sessions"].back()["exec_traced"].get()); +} + +XTEST(SubosObserve, FilesChangedInAReadWriteMountAreListed, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"OBS-FS", "OBS-LOG"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}) { + Box box; + auto work = box.home.root() / "work"; + fs::create_directories(work); + tk::write_file(work / "untouched", "x"); + ASSERT_EQ(box.home.xlings({"subos", "config", "box", "--observe", "standard"}).exit_code, 0); + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + auto r = box.home.xlings({"subos", "exec", "box", "--mount", work.string() + ":/work", "--", + "/bin/sh", "-c", "echo new > /work/made; mkdir -p /work/d; echo x > /work/d/f"}); + ASSERT_EQ(r.exit_code, 0) << r.transcript(); + auto fs_events = box.log("fs"); + ASSERT_FALSE(fs_events.empty()); + auto files = fs_events.back()["files"]; + std::set got; + for (auto& f : files) got.insert(f.get()); + EXPECT_TRUE(got.contains("made")) << files.dump(); + EXPECT_TRUE(got.contains("d/f")) << files.dump(); + EXPECT_FALSE(got.contains("untouched")) << files.dump(); + auto report = box.home.xlings({"subos", "report", "box"}); + EXPECT_NE(report.out.find("files changed in rw mounts"), std::string::npos) << report.out; +} + +XTEST(SubosObserve, TheInterfaceReadsTheAudit, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"OBS-IFACE-EVENTS"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}) { + Box box; + ASSERT_EQ(box.home.xlings({"subos", "exec", "box", "--sandbox", "--", "true"}).exit_code, 0); + auto r = box.home.xlings({"interface", "subos_events", "--args", + R"({"name":"box","kind":"lifecycle"})"}); + int events = 0; + for (auto& line : r.json_lines()) { + if (line.value("dataKind", "") != "subos_event") continue; + ++events; + EXPECT_EQ(line["payload"]["kind"], "lifecycle"); + } + EXPECT_GE(events, 2) << r.transcript(); +} + +XTEST(SubosObserve, TheAuditHasNamesNeverValues, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"OBS-REDACT"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}) { + Box box; + ASSERT_EQ(box.home.xlings({"subos", "config", "box", "--env-pass", "GITHUB_TOKEN"}).exit_code, 0); + const std::map env{{"GITHUB_TOKEN", "ghp-do-not-record-me"}}; + ASSERT_EQ(box.home.xlings({"subos", "start", "box"}).exit_code, 0); + auto r = box.home.xlings({"subos", "exec", "box", "--env", "API_SECRET=also-not-me", "--", + "/bin/sh", "-c", "test -n \"$GITHUB_TOKEN\" && echo passed-in"}, env); + (void)box.home.xlings({"subos", "stop", "box"}); + EXPECT_NE(r.out.find("passed-in"), std::string::npos) << r.transcript(); + std::string all; + for (auto it = fs::recursive_directory_iterator(box.home.dir() / "logs"); + it != std::default_sentinel; ++it) + if (it->is_regular_file()) all += tk::read_file(it->path()); + EXPECT_NE(all.find("GITHUB_TOKEN"), std::string::npos) << "the name is recorded"; + EXPECT_EQ(all.find("ghp-do-not-record-me"), std::string::npos); + EXPECT_EQ(all.find("also-not-me"), std::string::npos); +} + +XTEST(SubosObserve, InitialCommandArgumentsAreRedacted, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"OBS-REDACT", "OBS-LIFECYCLE"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}) { + Box box; + auto r = box.home.xlings({"subos", "exec", "box", "--sandbox", "--", "/bin/sh", "-c", + "test -n \"$1\" && echo executed", "_", "observe-argv-secret"}); + ASSERT_EQ(r.exit_code, 0) << r.transcript(); + EXPECT_NE(r.out.find("executed"), std::string::npos); + auto events = box.log("lifecycle"); + bool started = false; + for (const auto& e : events) { + EXPECT_EQ(e.dump().find("observe-argv-secret"), std::string::npos); + if (e.value("event", "") != "session-start") continue; + started = true; + EXPECT_FALSE(e["spec"].contains("argv")); + EXPECT_EQ(e["spec"]["program"], "/bin/sh"); + EXPECT_EQ(e["spec"]["argc"], 5); + const auto session_file = box.home.dir() / "logs" / "subos" / "box" / "sessions" + / (e["session"].get() + ".ndjson"); + EXPECT_TRUE(fs::is_regular_file(session_file)); + EXPECT_EQ(tk::read_file(session_file).find("observe-argv-secret"), std::string::npos); + } + EXPECT_TRUE(started); +} + +XTEST(SubosObserve, LockedRefusesAnUnwritableAuditBeforeStartingTheCommand, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"OBS-AUDIT-FAIL", "OBS-LIFECYCLE", "OBS-LOG"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}) { + Box box; + const auto journal = box.home.dir() / "logs" / "subos" / "box" / "events.ndjson"; + std::error_code ec; + fs::remove(journal, ec); + fs::create_directories(journal); + auto r = box.home.xlings({"subos", "exec", "box", "--sandbox=locked", "--", "/bin/sh", "-c", + "echo AUDIT_COMMAND_RAN"}); + EXPECT_EQ(r.exit_code, 125) << r.transcript(); + EXPECT_NE(r.err.find("E_AUDIT_WRITE"), std::string::npos) << r.transcript(); + EXPECT_EQ(r.out.find("AUDIT_COMMAND_RAN"), std::string::npos); + EXPECT_FALSE(fs::exists(box.home.dir() / "run" / "subos" / "box" / "session.json")); +} + +XTEST(SubosObserve, DevWarnsAndContinuesWhenTheAuditCannotBeWritten, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"OBS-AUDIT-FAIL", "OBS-LIFECYCLE", "OBS-LOG"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}) { + Box box; + const auto journal = box.home.dir() / "logs" / "subos" / "box" / "events.ndjson"; + std::error_code ec; + fs::remove(journal, ec); + fs::create_directories(journal); + auto r = box.home.xlings({"subos", "exec", "box", "--sandbox=dev", "--", "/bin/sh", "-c", + "echo AUDIT_COMMAND_RAN"}); + ASSERT_EQ(r.exit_code, 0) << r.transcript(); + EXPECT_NE(r.err.find("E_AUDIT_WRITE"), std::string::npos); + EXPECT_NE(r.out.find("AUDIT_COMMAND_RAN"), std::string::npos); +} + +XTEST(SubosObserve, LockedEndsALiveSessionWhenTheAuditBecomesUnwritable, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"OBS-AUDIT-FAIL", "OBS-LIFECYCLE", "OBS-LOG"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}) { + Box box; + auto declared = box.home.xlings({"subos", "config", "box", "--sandbox", "locked"}); + ASSERT_EQ(declared.exit_code, 0) << declared.transcript(); + auto start = box.home.xlings({"subos", "start", "box"}); + ASSERT_EQ(start.exit_code, 0) << start.transcript(); + const auto journal = box.home.dir() / "logs" / "subos" / "box" / "events.ndjson"; + ASSERT_TRUE(fs::is_regular_file(journal)); + ASSERT_TRUE(fs::remove(journal)); + fs::create_directory(journal); + auto r = box.home.xlings({"subos", "exec", "box", "--", "/bin/sh", "-c", "echo AUDIT_COMMAND_RAN"}); + EXPECT_EQ(r.exit_code, 125) << r.transcript(); + EXPECT_NE(r.err.find("E_AUDIT_WRITE"), std::string::npos) << r.transcript(); + EXPECT_EQ(r.out.find("AUDIT_COMMAND_RAN"), std::string::npos); + const auto session = box.home.dir() / "run" / "subos" / "box" / "session.json"; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(3); + while (fs::exists(session) && std::chrono::steady_clock::now() < deadline) + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + EXPECT_FALSE(fs::exists(session)) << "the supervisor must clean up the failed session"; +} diff --git a/tests/e2e/test_subos_output_safety.cpp b/tests/e2e/test_subos_output_safety.cpp new file mode 100644 index 000000000..fc80c308b --- /dev/null +++ b/tests/e2e/test_subos_output_safety.cpp @@ -0,0 +1,101 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; + +namespace { + +void expect_no_staging(const fs::path& dir) { + for (const auto& entry : fs::directory_iterator(dir)) + EXPECT_FALSE(entry.path().filename().string().starts_with(".xlings-stage-")); +} + +} // namespace + +XTEST(SubosOutputSafety, PackPreservesExistingDirectoriesAndRefusesExistingArchives, + .area = "subos", .covers = {"OUTPUT-PRESERVE"}, .requires_ = {"xlings-bin"}) { + auto home = tk::Home::isolated("pack-safety"); + const auto created = home.xlings({"subos", "new", "box"}); + ASSERT_EQ(created.exit_code, 0) << created.transcript(); + const auto out = home.root() / "out"; + tk::write_file(out / "bundle-1/user.txt", "keep directory"); + auto packed = home.xlings({"subos", "pack", "box", "--as", "demo:bundle@1", "--out", out.string()}); + ASSERT_EQ(packed.exit_code, 0) << packed.transcript(); + EXPECT_EQ(tk::read_file(out / "bundle-1/user.txt"), "keep directory"); + EXPECT_TRUE(fs::is_regular_file(out / "bundle-1.tar.gz")); + const auto archive = tk::read_file(out / "bundle-1.tar.gz"); + auto again = home.xlings({"subos", "pack", "box", "--as", "demo:bundle@1", "--out", out.string()}); + EXPECT_NE(again.exit_code, 0) << again.transcript(); + EXPECT_NE(again.transcript().find("output already exists"), std::string::npos); + EXPECT_EQ(tk::read_file(out / "bundle-1.tar.gz"), archive); + expect_no_staging(out); +} + +XTEST(SubosOutputSafety, PackRejectsPathTraversalNeedsNoHostTarAndCleansUpAfterAFailure, + .area = "subos", .covers = {"OUTPUT-PRESERVE", "TOOL-RESOLVE"}, .requires_ = {"xlings-bin"}) { + auto home = tk::Home::isolated("pack-errors"); + ASSERT_EQ(home.xlings({"subos", "new", "box"}).exit_code, 0); + const auto out = home.root() / "out"; + fs::create_directory(out); + for (const auto& spec : {"demo:../escape@1", "demo:bundle@../escape", "demo:bundle@1@2"}) { + const auto r = home.xlings({"subos", "pack", "box", "--as", spec, "--out", out.string()}); + EXPECT_NE(r.exit_code, 0) << spec << ": " << r.transcript(); + } + EXPECT_TRUE(fs::is_empty(out)); + if constexpr (tk::is_posix) { + // The archive is written in-process: a broken `tar` first on PATH + // changes nothing (it used to be what pack ran). + const auto tools = home.root() / "tools"; + tk::write_file(tools / "tar", "#!/bin/sh\nexit 37\n"); + fs::permissions(tools / "tar", fs::perms::owner_all); + const auto r = home.xlings({"subos", "pack", "box", "--as", "demo:bundle@1", "--out", out.string()}, + {{"PATH", tools.string()}}); + EXPECT_EQ(r.exit_code, 0) << r.transcript(); + EXPECT_TRUE(fs::exists(out / "bundle-1.tar.gz")); + expect_no_staging(out); + + // A failure leaves nothing behind: an output directory nobody may + // write into (root may, so there it proves nothing and is skipped). + const auto locked = home.root() / "locked"; + fs::create_directory(locked); + fs::permissions(locked, fs::perms::owner_read | fs::perms::owner_exec); + if (std::ofstream(locked / "probe")) { + fs::remove(locked / "probe"); + } else { + const auto failed = home.xlings({"subos", "pack", "box", "--as", "demo:bundle@2", "--out", + locked.string()}); + EXPECT_NE(failed.exit_code, 0) << failed.transcript(); + EXPECT_TRUE(fs::is_empty(locked)); + } + fs::permissions(locked, fs::perms::owner_all); + } +} + +XTEST(SubosOutputSafety, ExportRefusesFilesDirectoriesAndDanglingLinksBeforeStaging, + .area = "subos", .covers = {"OUTPUT-PRESERVE"}, .requires_ = {"linux", "xlings-bin"}) { + auto home = tk::Home::isolated("export-safety"); + const auto created = home.xlings({"subos", "new", "box", "--rootfs"}); + ASSERT_EQ(created.exit_code, 0) << created.transcript(); + const auto out = home.root() / "out"; + fs::create_directory(out); + const auto empty = out / "existing-root"; + fs::create_directory(empty); + const auto archive = out / "existing.tar.gz"; + tk::write_file(archive, "keep archive"); + const auto disk = out / "existing.img"; + fs::create_symlink("missing-image", disk); + for (const auto& [flag, path] : std::vector>{ + {"--rootfs", empty}, {"--tar", archive}, {"--disk", disk}}) { + const auto r = home.xlings({"subos", "export", "box", flag, path.string()}); + EXPECT_NE(r.exit_code, 0) << r.transcript(); + EXPECT_NE(r.transcript().find("output already exists"), std::string::npos) << r.transcript(); + } + EXPECT_TRUE(fs::is_empty(empty)); + EXPECT_EQ(tk::read_file(archive), "keep archive"); + EXPECT_EQ(fs::read_symlink(disk), fs::path("missing-image")); + expect_no_staging(out); +} diff --git a/tests/e2e/test_subos_perf.cpp b/tests/e2e/test_subos_perf.cpp new file mode 100644 index 000000000..0e044bca3 --- /dev/null +++ b/tests/e2e/test_subos_perf.cpp @@ -0,0 +1,251 @@ +// Release budgets from Part 1 §21.1 and Part 2 §15. Candidate and baseline +// use the same blocking fork/exec/wait runner; testkit's polling is not timed. +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.libs.json; +import xlings.platform; +import xlings.core.elfread; +import xlings.subos.caps; +import xlings.subos.ports; +import xlings.subos.spec; +import xlings.confine.provider; +import xlings.confine; +import xlings.subos.policy; +import xlings.subos.home_view; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; +namespace p = xlings::platform; +namespace subos = xlings::subos; +using Json = nlohmann::json; + +namespace { + +bool static_candidate() { + const auto elf = xlings::elfread::read(tk::xlings_binary()); + return elf && elf->interpreter.empty(); +} + +struct Box { + tk::Home home = tk::Home::isolated("perf"); + Box() { + if (!home.seed_sandbox_backend()) throw std::runtime_error("no real sandbox backend"); + const auto created = home.xlings({"subos", "new", "box"}); + if (created.exit_code != 0) throw std::runtime_error(created.transcript()); + fs::create_directories(home.dir() / "bin"); + fs::copy_file(tk::xlings_binary(), home.dir() / "bin/xlings"); + } + ~Box() { (void)home.xlings({"subos", "stop", "box"}); } + bool running() const { return fs::exists(home.dir() / "run/subos/box/session.json"); } + std::vector exec(std::string name, std::vector command) const { + std::vector argv{tk::xlings_binary().string(), "subos", "exec", std::move(name), "--sandbox=bwrap", "--"}; + argv.insert(argv.end(), command.begin(), command.end()); + return argv; + } +}; + +struct Environment { + std::map previous = tk::inherited_env(); + std::map applied; + fs::path cwd = fs::current_path(); + Environment(const std::map& values, const fs::path& directory) + : applied(values) { + fs::current_path(directory); + for (const auto& [name, value] : previous) p::unset_env_variable(name); + for (const auto& [name, value] : applied) p::set_env_variable(name, value); + } + ~Environment() { + for (const auto& [name, value] : applied) p::unset_env_variable(name); + for (const auto& [name, value] : previous) p::set_env_variable(name, value); + std::error_code ignored; + fs::current_path(cwd, ignored); + } +}; + +long long measured(const std::vector& argv, + const std::map& environment, const fs::path& cwd) { + Environment restored{environment, cwd}; + const auto started = std::chrono::steady_clock::now(); + const int result = p::run_argv(argv); + const auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - started).count(); + if (result != 0) throw std::runtime_error("performance command failed: " + Json(argv).dump() + " exit=" + std::to_string(result)); + return elapsed; +} + +struct Samples { + std::vector candidate, baseline, overhead; + long long median() const { + auto sorted = overhead; + std::ranges::sort(sorted); + return sorted.at(sorted.size() / 2); + } + void print(std::string_view label, long long budget) const { + std::cout << Json{{"perf", label}, {"candidate_us", candidate}, {"baseline_us", baseline}, + {"paired_overhead_us", overhead}, {"median_overhead_us", median()}, + {"budget_us", budget}}.dump() << '\n'; + } +}; + +Samples paired(const std::vector& candidate, const std::vector& baseline, + const std::map& candidateEnv, + const std::map& baselineEnv, const fs::path& cwd) { + for (int warm = 0; warm != 3; ++warm) { + (void)measured(baseline, baselineEnv, cwd); + (void)measured(candidate, candidateEnv, cwd); + } + Samples samples; + for (int sample = 0; sample != 11; ++sample) { + long long actual{}, direct{}; + if (sample % 2 == 0) { + direct = measured(baseline, baselineEnv, cwd); + actual = measured(candidate, candidateEnv, cwd); + } else { + actual = measured(candidate, candidateEnv, cwd); + direct = measured(baseline, baselineEnv, cwd); + } + samples.candidate.push_back(actual); + samples.baseline.push_back(direct); + samples.overhead.push_back(actual - direct); + } + return samples; +} + +struct DirectBackend { + std::vector argv; + std::map env; +}; + +// The pre-supervisor execution shape, generated from the same view policy, +// mounts and namespaces. This is an equivalent direct-provider baseline, +// not a claim to have measured an historical release artifact. +DirectBackend direct_backend(const Box& box, const std::vector& command) { + const subos::HomeView home{box.home.dir()}; + const auto environment = box.home.env(); + const auto caps = subos::caps::probe(home, subos::Ports{}); + subos::spec::Request request{ + .instance = "box", .instance_dir = home.instance("box"), .user = environment.at("USER"), + .argv = command, .preferred = subos::spec::Backend::Bwrap, .host_env = environment}; + const auto compiled = xlings::confine::compile(subos::policy::legacy(), home, caps, request); + if (!compiled) throw std::runtime_error("direct-provider baseline did not compile"); + if (compiled->backend != subos::spec::Backend::Bwrap || compiled->mounts.empty() || !compiled->unshare_pid) + throw std::runtime_error("direct-provider baseline omitted the sandbox"); + return {xlings::confine::provider::bwrap_argv(*compiled), xlings::confine::provider::process_env(*compiled, environment)}; +} + +} // namespace + +XTEST(SubosPerf, AShimInsideASandboxDispatchesWithoutTheBroker, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"PERF-SHIM"}, + .requires_ = {"linux", "xlings-bin", "bwrap"}, .resources = {"sandbox"}, .proves = "isolation") { + if (!static_candidate()) GTEST_SKIP() << "release performance requires a static candidate"; + Box box; + fs::create_directories(box.home.dir() / "subos/box/bin"); + fs::create_symlink("../../../bin/xlings", box.home.dir() / "subos/box/bin/true"); + const auto script = "shim_path=" + p::shell_quote((box.home.dir() / "subos/box/bin/true").string()) + R"SH( + printf '%s\n' "$shim_path" + shim() { i=0; while [ "$i" -lt 40 ]; do "$shim_path" || exit; i=$((i+1)); done; } + direct() { i=0; while [ "$i" -lt 40 ]; do /bin/true || exit; i=$((i+1)); done; } + sample=0 + while [ "$sample" -lt 7 ]; do + a=$(date +%s%N) + if [ $((sample % 2)) -eq 0 ]; then + shim; b=$(date +%s%N); direct; c=$(date +%s%N) + echo $(((b-a)/40/1000)) $(((c-b)/40/1000)) + else + direct; b=$(date +%s%N); shim; c=$(date +%s%N) + echo $(((c-b)/40/1000)) $(((b-a)/40/1000)) + fi + sample=$((sample+1)) + done + )SH"; + const auto result = box.home.xlings({"subos", "exec", "box", "--sandbox=bwrap", "--", "/bin/sh", "-c", script}); + ASSERT_EQ(result.exit_code, 0) << result.transcript(); + // `true` may be a shell builtin; the explicit shim path above guarantees + // every measured shim invocation executes the candidate dispatcher. + std::istringstream lines(result.out); + std::string line; + ASSERT_TRUE(std::getline(lines, line)); + EXPECT_EQ(line, (box.home.dir() / "subos/box/bin/true").string()); + Samples samples; + while (std::getline(lines, line)) { + std::istringstream values(line); + long long shim{}, direct{}; + ASSERT_TRUE(values >> shim >> direct) << line; + samples.candidate.push_back(shim); samples.baseline.push_back(direct); + samples.overhead.push_back(shim - direct); + } + ASSERT_EQ(samples.overhead.size(), 7u) << result.transcript(); + samples.print("shim_vs_direct_inside_bwrap", 10'000); + EXPECT_LT(samples.median(), 10'000); + const auto events = tk::read_file(box.home.dir() / "logs/subos/box/events.ndjson"); + EXPECT_EQ(events.find("\"kind\":\"perm\""), std::string::npos) << events; +} + +XTEST(SubosPerf, JoiningARunningSessionAddsAtMostFiveMilliseconds, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"PERF-EXEC-HOT"}, + .requires_ = {"linux", "xlings-bin", "bwrap"}, .resources = {"sandbox"}, .proves = "isolation") { + if (!static_candidate()) GTEST_SKIP() << "release performance requires a static candidate"; + Box box; + const auto started = box.home.xlings({"subos", "start", "box", "--sandbox=bwrap", "--ttl", "60s"}); + ASSERT_EQ(started.exit_code, 0) << started.transcript(); + ASSERT_TRUE(box.running()); + const auto session = tk::read_file(box.home.dir() / "run/subos/box/session.json"); + Samples samples; + ASSERT_NO_THROW(samples = paired(box.exec("box", {"/bin/true"}), {"/bin/true"}, + box.home.env(), box.home.env(), box.home.root())); + samples.print("hot_exec_vs_direct_exec", 5'000); + EXPECT_LE(samples.median(), 5'000); + EXPECT_EQ(tk::read_file(box.home.dir() / "run/subos/box/session.json"), session); +} + +XTEST(SubosPerf, ColdExecAddsAtMostTenMillisecondsToTheDirectProvider, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"PERF-EXEC-COLD"}, + .requires_ = {"linux", "xlings-bin", "bwrap"}, .resources = {"sandbox"}, .proves = "isolation") { + if (!static_candidate()) GTEST_SKIP() << "release performance requires a static candidate"; + Box box; + const auto environment = box.home.env(); + ASSERT_NO_THROW((void)measured(box.exec("box", {"/bin/true"}), environment, box.home.root())); + ASSERT_FALSE(box.running()) << "cold samples must not join a previous session"; + DirectBackend direct; + ASSERT_NO_THROW(direct = direct_backend(box, {"/bin/true"})); + Samples samples; + ASSERT_NO_THROW(samples = paired(box.exec("box", {"/bin/true"}), direct.argv, + environment, direct.env, box.home.root())); + ASSERT_FALSE(box.running()); + samples.print("cold_exec_vs_equivalent_direct_provider", 10'000); + EXPECT_LE(samples.median(), 10'000); +} + +XTEST(SubosPerf, RootEntryWithACheckedPayloadClosureAddsAtMostOneHundredMilliseconds, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"PERF-ROOT-ENTRY"}, + .requires_ = {"linux", "xlings-bin", "bwrap"}, .resources = {"sandbox"}, .proves = "isolation") { + if (!static_candidate()) GTEST_SKIP() << "release performance requires a static candidate"; + Box box; + const auto created = box.home.xlings({"subos", "new", "root-perf", "--rootfs"}); + ASSERT_EQ(created.exit_code, 0) << created.transcript(); + const auto payload = box.home.dir() / "data/xpkgs/fixture-x-xlings/1.0.0"; + fs::create_directories(payload / "bin"); + fs::copy_file(tk::xlings_binary(), payload / "bin/xlings"); + tk::write_file(payload / ".xlings-resolution.json", R"({"package":"fixture:xlings@1.0.0","deps":[]})"); + auto primary = Json::parse(tk::read_file(box.home.dir() / ".xlings.json")); + primary["versions"]["xlings"] = {{"type", "program"}, + {"versions", {{"fixture:1.0.0", {{"path", (payload / "bin").string()}}}}}}; + primary.erase("dbIndex"); + tk::write_file(box.home.dir() / ".xlings.json", primary.dump()); + fs::remove(box.home.dir() / "data/versions.json"); + auto workspace = Json::parse(tk::read_file(box.home.dir() / "subos/root-perf/.xlings.json")); + workspace["workspace"]["xlings"] = {{"active", "fixture:1.0.0"}, {"installed", {"fixture:1.0.0"}}}; + tk::write_file(box.home.dir() / "subos/root-perf/.xlings.json", workspace.dump()); + Samples samples; + ASSERT_NO_THROW(samples = paired(box.exec("root-perf", {"/usr/bin/xlings", "--version"}), + box.exec("box", {(payload / "bin/xlings").string(), "--version"}), + box.home.env(), box.home.env(), box.home.root())); + samples.print("root_exact_closure_vs_view_cold_exec", 100'000); + EXPECT_LE(samples.median(), 100'000); + EXPECT_TRUE(fs::is_symlink(box.home.dir() / "subos/root-perf/root/usr/bin/xlings")); + EXPECT_FALSE(fs::exists(box.home.dir() / "run/subos/root-perf/session.json")); +} diff --git a/tests/e2e/test_subos_policy.cpp b/tests/e2e/test_subos_policy.cpp new file mode 100644 index 000000000..67089d4df --- /dev/null +++ b/tests/e2e/test_subos_policy.cpp @@ -0,0 +1,354 @@ +// Policy, end to end (C14 + C20): `subos config` declares, the declaration +// holds however the instance is entered, presets isolate what they say they +// isolate, and a policy this version cannot enforce is refused. +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.libs.json; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; + +namespace { + +struct Box { + tk::Home home = tk::Home::isolated("policy"); + Box() { + home.seed_sandbox_backend(); + auto r = home.xlings({"subos", "new", "box"}); + if (r.exit_code != 0) ADD_FAILURE() << r.transcript(); + } + tk::RunResult run(std::vector args) const { return home.xlings(std::move(args)); } + fs::path policy_file() const { return home.dir() / "config" / "subos" / "box" / "policy.json"; } +}; + +std::string trim(std::string s) { + while (!s.empty() && (s.back() == '\n' || s.back() == ' ')) s.pop_back(); + return s; +} + +} // namespace + +XTEST(SubosPolicyE2E, ConfigDeclaresOutsideTheInstanceAndStatusReportsIt, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"POL-CONFIG-STATUS", "PLAT-STATUS-MATRIX"}, + .requires_ = {"xlings-bin"}) { + Box box; + auto c = box.run({"subos", "config", "box", "--sandbox=locked", "--json"}); + ASSERT_EQ(c.exit_code, 0) << c.transcript(); + ASSERT_TRUE(fs::exists(box.policy_file())); + EXPECT_FALSE(fs::exists(box.home.dir() / "subos" / "box" / "policy.json")) + << "the policy is not part of what it governs"; + auto ev = tk::read_file(box.home.dir() / "logs" / "subos" / "box" / "events.ndjson"); + EXPECT_NE(ev.find("policy-change"), std::string::npos); + + auto s = box.run({"subos", "status", "box", "--json"}); + ASSERT_EQ(s.exit_code, 0) << s.transcript(); + auto j = nlohmann::json::parse(s.out, nullptr, false); + ASSERT_FALSE(j.is_discarded()) << s.out; + EXPECT_EQ(j["policy_source"], "file"); + EXPECT_EQ(j["requested"]["isolation"]["net"], "none"); + EXPECT_EQ(j["effective"]["gates"].size(), 8u); +} + +XTEST(SubosPolicyE2E, ADeclaredInstanceIsIsolatedHoweverItIsEntered, + .area = "subos", .cost = tk::Cost::Medium, + .covers = {"ISO-NET-NONE", "F5", "F7", "ISO-DISABLE-USERNS"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}, + .proves = "isolation") { + Box box; + ASSERT_EQ(box.run({"subos", "config", "box", "--sandbox=locked"}).exit_code, 0); + // No --sandbox anywhere: the instance's declaration is what counts. + auto r = box.run({"subos", "use", "box", "--cmd", + "echo user=$(id -un) host=$(hostname) tz=$TZ; " + "echo ifaces=$(tail -n +3 /proc/net/dev | cut -d: -f1 | tr -d ' ' | tr '\\n' ,); " + "echo arp=$(tail -n +2 /proc/net/arp 2>/dev/null | wc -l); " + "unshare -U true 2>/dev/null && echo nested=yes || echo nested=no"}); + EXPECT_EQ(r.exit_code, 0) << r.transcript(); + EXPECT_NE(r.out.find("user=user host=box tz=UTC"), std::string::npos) << r.out; // F7 + EXPECT_NE(r.out.find("ifaces=lo,"), std::string::npos) << r.out; // F5, net=none + EXPECT_NE(r.out.find("arp=0"), std::string::npos) << r.out; // no neighbours + EXPECT_NE(r.out.find("nested=no"), std::string::npos) << r.out; +} + +XTEST(SubosPolicyE2E, APresetIsPickedPerCallAndCanOnlyTighten, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"POL-SANDBOX-ALIAS"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}) { + Box box; + auto host = box.run({"subos", "exec", "box", "--sandbox", "--", "/bin/sh", "-c", + "tail -n +3 /proc/net/dev | wc -l"}); + auto locked = box.run({"subos", "exec", "box", "--sandbox=locked", "--", "/bin/sh", "-c", + "tail -n +3 /proc/net/dev | wc -l"}); + ASSERT_EQ(locked.exit_code, 0) << locked.transcript(); + EXPECT_EQ(trim(locked.out), "1"); + EXPECT_NE(trim(host.out), "") << host.transcript(); + + ASSERT_EQ(box.run({"subos", "config", "box", "--sandbox=locked"}).exit_code, 0); + auto looser = box.run({"subos", "exec", "box", "--net", "host", "--", "true"}); + EXPECT_EQ(looser.exit_code, 125) << looser.transcript(); + EXPECT_NE(looser.transcript().find("loosen"), std::string::npos); + auto dev = box.run({"subos", "exec", "box", "--sandbox=dev", "--", "true"}); + EXPECT_EQ(dev.exit_code, 125) << dev.transcript(); +} + +XTEST(SubosPolicyE2E, APolicyThisVersionCannotEnforceIsRefused, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"POL-UNKNOWN-REFUSED"}, + .requires_ = {"xlings-bin"}) { + Box box; + tk::write_file(box.policy_file(), R"({"extends":"dev","isolation":{"net":"vpn"}})"); + auto r = box.run({"subos", "exec", "box", "--", "true"}); + EXPECT_EQ(r.exit_code, 125) << r.transcript(); + EXPECT_NE(r.transcript().find("vpn"), std::string::npos) << r.transcript(); +} + +XTEST(SubosPolicyE2E, OnlyTheOwnerOutsideChangesThePolicy, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"POL-DECIDE", "EXIT-13"}, + .requires_ = {"xlings-bin"}) { + Box box; + auto r = box.home.xlings({"subos", "config", "box", "--net", "host"}, + {{"XLINGS_SUBOS_MODE", "sandbox"}}); + EXPECT_EQ(r.exit_code, 13) << r.transcript(); + EXPECT_NE(r.transcript().find("E_PERMISSION"), std::string::npos); + EXPECT_FALSE(fs::exists(box.policy_file())); +} + +namespace { +// A unix socket in the abstract namespace -- what an X server listens on +// (@/tmp/.X11-unix/X0) and what a mount namespace does not hide (#640 F2). +const char* kAbstractProbe = + "import socket,sys\n" + "s=socket.socket(socket.AF_UNIX)\n" + "try:\n s.connect('\\0' + sys.argv[1]); print('REACHED')\n" + "except OSError as e: print('BLOCKED', e.errno)\n"; +} + +XTEST(SubosPolicyE2E, TheHostsAbstractSocketsAreOutOfReachWithAPrivateNetwork, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"F2"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}, + .proves = "isolation") { + Box box; + // The host listens on an abstract socket, as an X server does. + const auto name = std::format("xlings-f2-{}", std::chrono::steady_clock::now().time_since_epoch().count()); + tk::RunOptions server; + server.argv = {"/usr/bin/python3", "-c", + "import socket,sys,time\ns=socket.socket(socket.AF_UNIX)\n" + "s.bind('\\0'+sys.argv[1]); s.listen(); time.sleep(20)", name}; + server.env = {{"PATH", "/usr/bin:/bin"}}; + server.timeout = std::chrono::seconds(25); + std::thread t([&] { (void)tk::run(server); }); + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + auto dev = box.run({"subos", "exec", "box", "--sandbox", "--", "python3", "-c", kAbstractProbe, name}); + auto locked = box.run({"subos", "exec", "box", "--sandbox=locked", "--", "python3", "-c", + kAbstractProbe, name}); + t.detach(); + if (dev.out.find("No such file") != std::string::npos) GTEST_SKIP() << "no python3"; + // dev shares the host's network namespace and says so in the design: only + // a private network hides the abstract namespace. + EXPECT_NE(dev.out.find("REACHED"), std::string::npos) << dev.transcript(); + EXPECT_NE(locked.out.find("BLOCKED"), std::string::npos) << locked.transcript(); +} + +XTEST(SubosPolicyE2E, NatGivesAPrivateNetworkThroughPasta, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"ISO-NET-NAT", "F5"}, + .requires_ = {"linux", "xlings-bin", "sandbox", "pasta"}, .resources = {"sandbox"}, + .proves = "isolation") { + Box box; + auto r = box.run({"subos", "exec", "box", "--sandbox=private", "--", "/bin/sh", "-c", + "echo ifaces=$(tail -n +3 /proc/net/dev | cut -d: -f1 | tr -d ' ' | sort | tr '\\n' ,); " + "echo host=$(hostname)"}); + ASSERT_EQ(r.exit_code, 0) << r.transcript(); + EXPECT_NE(r.out.find("host=box"), std::string::npos) << r.out; + // lo and pasta's tap: a network of its own, not the host's interfaces. + auto line = r.out.substr(r.out.find("ifaces=")); + EXPECT_NE(line.find("lo,"), std::string::npos) << r.out; + EXPECT_EQ(std::ranges::count(line.substr(0, line.find('\n')), ','), 2) << r.out; +} + +XTEST(SubosPolicyE2E, MountsMapHostPathsReadWriteOrReadOnly, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"ISO-MOUNT", "ISO-GRANTS"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}, + .proves = "isolation") { + Box box; + auto work = box.home.root() / "work"; + auto ref = box.home.root() / "ref"; + fs::create_directories(work); + fs::create_directories(ref); + tk::write_file(ref / "README", "read me"); + tk::write_file(box.home.root() / "agent.sock", ""); + auto r = box.home.xlings({"subos", "exec", "box", "--sandbox", + "--mount", work.string() + ":/work", + "--mount", ref.string() + ":/ref:ro", + "--allow", "ssh-agent", "--", + "/bin/sh", "-c", + "echo made > /work/out && echo WROTE-WORK; " + "cat /ref/README; echo; touch /ref/x 2>/dev/null && echo WROTE-REF || echo REF-RO; " + "echo sock=$SSH_AUTH_SOCK; test -e $SSH_AUTH_SOCK && echo SOCK-THERE"}, + {{"SSH_AUTH_SOCK", (box.home.root() / "agent.sock").string()}}); + ASSERT_EQ(r.exit_code, 0) << r.transcript(); + EXPECT_NE(r.out.find("WROTE-WORK"), std::string::npos) << r.out; + EXPECT_EQ(tk::read_file(work / "out"), "made\n"); + EXPECT_NE(r.out.find("read me"), std::string::npos) << r.out; + EXPECT_NE(r.out.find("REF-RO"), std::string::npos) << r.out; + EXPECT_NE(r.out.find("sock=/tmp/.xlings-ssh-agent"), std::string::npos) << r.out; + EXPECT_NE(r.out.find("SOCK-THERE"), std::string::npos) << r.out; + + auto home = box.home.xlings({"subos", "exec", "box", "--sandbox", "--mount", + box.home.dir().string() + ":/x", "--", "true"}); + EXPECT_EQ(home.exit_code, 125) << home.transcript(); +} + +namespace { +// A subos-policy package as a local recipe: no download, the install hook +// writes the payload's policy.json. +// `versions` is what the index offers, the last one newest. +std::string policy_recipe(std::vector versions, std::string_view policy_json) { + std::string offered = std::format(R"(["latest"] = {{ ref = "{}" }})", versions.back()); + for (auto& v : versions) offered += std::format(R"(, ["{}"] = {{}})", v); + return std::format(R"LUA(package = {{ + spec = "1", + name = "policy-ci", + description = "a SubOS policy package (test fixture)", + type = "subos-policy", + archs = {{"x86_64", "aarch64"}}, + status = "dev", + xpm = {{ + linux = {{ {0} }}, + macosx = {{ {0} }}, + windows = {{ {0} }}, + }}, +}} +import("xim.libxpkg.pkginfo") +function install() + local dir = pkginfo.install_dir() + os.mkdir(dir) + local f = io.open(path.join(dir, "policy.json"), "w") + if not f then return false end + f:write('{1}') + f:close() + return true +end +)LUA", offered, policy_json); +} +} // namespace + +XTEST(SubosPolicyE2E, APolicyPackageIsSelectedLockedAndUpgradedByTheOwner, + .area = "subos", .cost = tk::Cost::Slow, .covers = {"POL-PACK"}, + .requires_ = {"xlings-bin", "network"}) { // install syncs the index first + Box box; + const auto recipe = box.home.root() / "policy-ci.lua"; + tk::write_file(recipe, policy_recipe({"1.0.0"}, + R"({"extends":"private","isolation":{"net":"none"},"permissions":{"fetch":{"default":"deny"}}})")); + ASSERT_EQ(box.run({"config", "--add-xpkg", recipe.string()}).exit_code, 0); + + auto select = box.run({"subos", "config", "box", "--sandbox", "local:policy-ci@1"}); + ASSERT_EQ(select.exit_code, 0) << select.transcript(); + // The owner is shown what the package changes against the preset it builds on. + EXPECT_NE(select.transcript().find("compared with the built-in private"), std::string::npos) + << select.transcript(); + auto doc = nlohmann::json::parse(tk::read_file(box.policy_file())); + EXPECT_EQ(doc["extends"], "local:policy-ci@1"); + EXPECT_EQ(doc["resolved"]["from"], "local:policy-ci@1.0.0"); + EXPECT_EQ(doc["resolved"]["sha256"].get().size(), 64u); + EXPECT_EQ(doc["isolation"]["net"], "none"); + EXPECT_EQ(doc["permissions"]["fetch"]["default"], "deny"); + + // A newer package changes nothing until the owner asks. + tk::write_file(recipe, policy_recipe({"1.0.0", "1.1.0"}, + R"({"extends":"private","isolation":{"net":"none"},"observe":{"level":"full"}})")); + ASSERT_EQ(box.run({"config", "--add-xpkg", recipe.string()}).exit_code, 0); + auto status = box.run({"subos", "status", "box", "--json"}); + auto s = nlohmann::json::parse(status.out, nullptr, false); + ASSERT_FALSE(s.is_discarded()) << status.transcript(); + EXPECT_EQ(s["requested"]["resolved"]["from"], "local:policy-ci@1.0.0"); + + auto upgrade = box.run({"subos", "config", "box", "--policy-upgrade"}); + ASSERT_EQ(upgrade.exit_code, 0) << upgrade.transcript(); + EXPECT_NE(upgrade.transcript().find("local:policy-ci@1.1.0"), std::string::npos) << upgrade.transcript(); + doc = nlohmann::json::parse(tk::read_file(box.policy_file())); + EXPECT_EQ(doc["resolved"]["from"], "local:policy-ci@1.1.0"); + EXPECT_EQ(doc["observe"]["level"], "full"); + + // A system install may limit where packages come from. + const auto sys = box.home.root() / "etc-xlings.json"; + tk::write_file(sys, R"({"subos_policy_sources":["xim:*"]})"); + auto refused = box.home.xlings({"subos", "config", "box", "--sandbox", "local:policy-ci"}, + {{"XLINGS_SYSTEM_CONFIG", sys.string()}}); + EXPECT_EQ(refused.exit_code, 13) << refused.transcript(); + + // A package extending another package is not a policy this version takes. + tk::write_file(recipe, policy_recipe({"1.0.0", "1.1.0", "1.2.0"}, R"({"extends":"xim:other@1"})")); + ASSERT_EQ(box.run({"config", "--add-xpkg", recipe.string()}).exit_code, 0); + auto chained = box.run({"subos", "config", "box", "--policy-upgrade"}); + EXPECT_NE(chained.exit_code, 0) << chained.transcript(); + EXPECT_EQ(nlohmann::json::parse(tk::read_file(box.policy_file()))["resolved"]["from"], + "local:policy-ci@1.1.0") << "a refused upgrade leaves the policy alone"; + + // The parts of a reference are names, never paths. + EXPECT_EQ(box.run({"subos", "config", "box", "--sandbox", "local:../../etc@1"}).exit_code, 2); +} + +XTEST(SubosPolicyE2E, DoctorSaysWhatEachInstanceCanDoHere, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"SUBOS-DOCTOR", "ISO-PROBE-SAME-SPEC"}, + .requires_ = {"xlings-bin"}) { + Box box; + ASSERT_EQ(box.run({"subos", "new", "broken"}).exit_code, 0); + tk::write_file(box.home.dir() / "config" / "subos" / "broken" / "policy.json", + R"({"isolation":{"net":"vpn"}})"); + auto r = box.run({"subos", "doctor", "--json"}); + EXPECT_EQ(r.exit_code, 1) << "an unreadable policy is an error\n" << r.transcript(); + auto j = nlohmann::json::parse(r.out, nullptr, false); + ASSERT_FALSE(j.is_discarded()) << r.transcript(); + EXPECT_EQ(j["gates"].size(), 8u); + std::map> level; // instance -> check -> level + for (auto& inst : j["instances"]) + for (auto& f : inst["findings"]) + level[inst["instance"]][f["check"]] = f["level"]; + EXPECT_EQ(level["box"]["policy"], "ok"); + EXPECT_EQ(level["broken"]["policy"], "error"); + EXPECT_EQ(level["broken"]["enters"], "error") << "entry refuses while the policy cannot be read"; + // The probe is the entry: the same spec, run with `true`. + if (tk::probe("sandbox") == std::nullopt) { + // ok, or warn where what the platform cannot give is listed (macOS, + // Windows: home redirection only) -- entered either way. + EXPECT_NE(level["box"]["enters"], "error") << r.out; + EXPECT_NE(r.out.find("entered"), std::string::npos) << r.out; + } + + // One instance, and a healthy one is exit 0. + auto one = box.run({"subos", "doctor", "box"}); + EXPECT_EQ(one.exit_code, 0) << one.transcript(); + EXPECT_NE(one.out.find("subos box"), std::string::npos) << one.out; + EXPECT_EQ(one.out.find("subos broken"), std::string::npos) << one.out; +} + +// How isolated an instance is, said once when it is made: every later entry +// is that, without being told; a copy is isolated as its source was. +XTEST(SubosPolicyE2E, IsolationIsDeclaredWhenTheInstanceIsMade, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"POL-DECLARE-AT-NEW"}, + .requires_ = {"xlings-bin"}) { + auto home = tk::Home::isolated("declare-at-new"); + home.seed_sandbox_backend(); + auto made = home.xlings({"subos", "new", "agent", "--sandbox=locked"}); + ASSERT_EQ(made.exit_code, 0) << made.transcript(); + const auto file = home.dir() / "config" / "subos" / "agent" / "policy.json"; + ASSERT_TRUE(fs::exists(file)); + EXPECT_EQ(nlohmann::json::parse(tk::read_file(file))["extends"], "locked"); + EXPECT_NE(tk::read_file(home.dir() / "logs" / "subos" / "agent" / "events.ndjson").find("policy-change"), + std::string::npos) << "the declaration is audited"; + + ASSERT_EQ(home.xlings({"subos", "new", "copy", "--from", "agent"}).exit_code, 0); + const auto copied = home.dir() / "config" / "subos" / "copy" / "policy.json"; + ASSERT_TRUE(fs::exists(copied)) << "a copy is isolated as its source was"; + EXPECT_EQ(nlohmann::json::parse(tk::read_file(copied))["extends"], "locked"); + + EXPECT_EQ(home.xlings({"subos", "new", "bad", "--sandbox=vpn"}).exit_code, 1); + EXPECT_FALSE(fs::exists(home.dir() / "subos" / "bad")); + + // No --sandbox on the entry: the declaration is what counts. + if (tk::probe("sandbox") == std::nullopt && tk::probe("linux") == std::nullopt) { + auto r = home.xlings({"subos", "use", "agent", "--cmd", "hostname"}); + EXPECT_EQ(r.exit_code, 0) << r.transcript(); + EXPECT_NE(r.out.find("agent"), std::string::npos) << r.transcript(); + } +} diff --git a/tests/e2e/test_subos_root_refresh.cpp b/tests/e2e/test_subos_root_refresh.cpp new file mode 100644 index 000000000..92934be82 --- /dev/null +++ b/tests/e2e/test_subos_root_refresh.cpp @@ -0,0 +1,75 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.libs.json; +import xlings.core.xvm.types; +import xlings.core.xvm.db; +import xlings.subos.rootfs; +import xlings.subos.roles; +import xlings.core.subos.root; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; +namespace xvm = xlings::xvm; + +XTEST(SubosRootRefresh, MachineLayoutFailureRestoresThePreviousProjection, + .area = "subos", .covers = {"ROOT-REFRESH-FAIL"}, .requires_ = {"linux"}) { + auto home = tk::Home::isolated("root-layout-rollback"); + const auto box = home.dir() / "subos/box"; + ASSERT_TRUE(xlings::subos_root::declare_kind(home.dir(), "box", xlings::subos::roles::Kind::Rootfs)); + const auto before = xlings::subos::rootfs::commit(box, {}, "prior empty projection"); + ASSERT_TRUE(before) << before.error(); + tk::write_file(box / "rootfs/var", "user data obstructing machine directories"); + const auto payload = home.dir() / "data/xpkgs/demo-x-probe/1/bin"; + tk::write_file(payload / "probe", "probe sentinel"); + xvm::VersionDB db; + xvm::add_version(db, "probe", "1", payload.string()); + const auto failed = xlings::subos_root::refresh(home.dir(), "box", box, + xvm::Workspace{{"probe", "1"}}, db, "failing machine layout"); + ASSERT_TRUE(failed.has_value()); + ASSERT_FALSE(failed->has_value()); + EXPECT_EQ(xlings::subos::rootfs::current(box), *before); + EXPECT_FALSE(fs::exists(box / "root/usr/bin/probe")); + EXPECT_EQ(tk::read_file(box / "rootfs/var"), "user data obstructing machine directories"); +} + +XTEST(SubosRootRefresh, UseReportsProjectionFailureAndRetryRepairsIt, + .area = "subos", .covers = {"ROOT-REFRESH-FAIL"}, .requires_ = {"linux", "xlings-bin"}) { + auto home = tk::Home::isolated("root-refresh"); + auto created = home.xlings({"subos", "new", "box", "--rootfs"}); + ASSERT_EQ(created.exit_code, 0) << created.transcript(); + const auto payload = home.dir() / "data/xpkgs/demo-x-probe/1/bin"; + tk::write_file(payload / "probe", "#!/bin/sh\nexit 0\n"); + fs::permissions(payload / "probe", fs::perms::owner_all); + xvm::VersionDB db; + xvm::add_version(db, "probe", "1", payload.string()); + auto config = nlohmann::json::parse(tk::read_file(home.dir() / ".xlings.json")); + config["versions"] = xvm::versions_to_json(db); + tk::write_file(home.dir() / ".xlings.json", config.dump(2)); + const auto box = home.dir() / "subos/box"; + xvm::SubosWorkspace scope; + scope.installed["probe"] = {"1"}; + auto scope_config = nlohmann::json::parse(tk::read_file(box / ".xlings.json")); + scope_config["workspace"] = xvm::subos_workspace_to_json(scope); + tk::write_file(box / ".xlings.json", scope_config.dump(2)); + fs::rename(box / xlings::subos::rootfs::kGenerations, box / "saved-generations"); + tk::write_file(box / xlings::subos::rootfs::kGenerations, "foreign file obstructing generation storage"); + const std::map env{{"XLINGS_ACTIVE_SUBOS", "box"}}; + auto failed = home.xlings({"use", "probe@1"}, env); + EXPECT_NE(failed.exit_code, 0) << failed.transcript(); + EXPECT_NE(failed.transcript().find("was not updated"), std::string::npos) << failed.transcript(); + EXPECT_EQ(tk::read_file(box / xlings::subos::rootfs::kGenerations), "foreign file obstructing generation storage"); + EXPECT_FALSE(fs::exists(box / "root/usr/bin/probe")); + const auto after = nlohmann::json::parse(tk::read_file(box / ".xlings.json")); + EXPECT_EQ(after.at("workspace"), scope_config.at("workspace")); + EXPECT_EQ(nlohmann::json::parse(tk::read_file(home.dir() / ".xlings.json")).at("versions"), + config.at("versions")); + + fs::remove(box / xlings::subos::rootfs::kGenerations); + fs::rename(box / "saved-generations", box / xlings::subos::rootfs::kGenerations); + auto repaired = home.xlings({"use", "probe@1"}, env); + ASSERT_EQ(repaired.exit_code, 0) << repaired.transcript(); + EXPECT_TRUE(fs::equivalent(box / "root/usr/bin/probe", payload / "probe")); +} diff --git a/tests/e2e/test_subos_session.cpp b/tests/e2e/test_subos_session.cpp new file mode 100644 index 000000000..3b324b61d --- /dev/null +++ b/tests/e2e/test_subos_session.cpp @@ -0,0 +1,175 @@ +// Sessions (C9): a supervisor outside the sandbox hosts it, records it, and +// lets later commands join it. +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.libs.json; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; + +namespace { + +struct Box { + tk::Home home = tk::Home::isolated("session"); + std::string name = "box"; + Box() { + home.seed_sandbox_backend(); + auto r = home.xlings({"subos", "new", name}); + if (r.exit_code != 0) ADD_FAILURE() << r.transcript(); + } + tk::RunResult cmd(const std::string& c) const { + return home.xlings({"subos", "use", name, "--sandbox", "--cmd", c}); + } + std::vector events() const { + std::vector out; + std::istringstream in(tk::read_file(home.dir() / "logs" / "subos" / name / "events.ndjson")); + std::string line; + while (std::getline(in, line)) { + auto j = nlohmann::json::parse(line, nullptr, false); + if (!j.is_discarded()) out.push_back(std::move(j)); + } + return out; + } + bool running() const { + return fs::exists(home.dir() / "run" / "subos" / name / "session.json"); + } +}; + +bool wait_for(const std::function& pred, std::chrono::seconds limit = std::chrono::seconds(20)) { + auto until = std::chrono::steady_clock::now() + limit; + while (std::chrono::steady_clock::now() < until) { + if (pred()) return true; + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + return false; +} + +} // namespace + +XTEST(SubosSession, TheSupervisorRecordsTheSessionOutsideTheInstance, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"SES-SUPERVISOR", "OBS-LIFECYCLE"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}) { + Box box; + auto r = box.cmd("exit 9"); + EXPECT_EQ(r.exit_code, 9) << r.transcript(); + auto ev = box.events(); + ASSERT_GE(ev.size(), 2u); + EXPECT_EQ(ev.front()["event"], "session-start"); + EXPECT_EQ(ev.front()["kind"], "lifecycle"); + EXPECT_TRUE(ev.front()["spec"].contains("mounts")) << "the isolation snapshot"; + EXPECT_EQ(ev.back()["event"], "session-end"); + EXPECT_EQ(ev.back()["exit"], 9); + EXPECT_FALSE(box.running()) << "an ended session leaves nothing behind"; +} + +XTEST(SubosSession, ASecondCommandJoinsTheRunningSession, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"SES-JOIN", "OBS-OPS"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}) { + Box box; + tk::RunResult first; + std::thread t([&] { first = box.cmd("echo shared > /tmp/marker; sleep 2; echo first-done"); }); + ASSERT_TRUE(wait_for([&] { return box.running(); })) << "the first session never started"; + // Same /tmp, same processes: the marker the first wrote is here. + auto second = box.cmd("cat /tmp/marker; exit 4"); + t.join(); + EXPECT_EQ(second.exit_code, 4) << second.transcript(); + EXPECT_NE(second.out.find("shared"), std::string::npos) << second.transcript(); + EXPECT_EQ(first.exit_code, 0) << first.transcript(); + EXPECT_NE(first.out.find("first-done"), std::string::npos); + + auto ev = box.events(); + auto exec = std::ranges::find_if(ev, [](auto& e) { return e.value("event", "") == "exec"; }); + ASSERT_NE(exec, ev.end()); + EXPECT_EQ((*exec)["kind"], "ops"); + EXPECT_TRUE((*exec)["env"].is_array()) << "names, not values"; + auto end = std::ranges::find_if(ev, [](auto& e) { return e.value("event", "") == "exec-end"; }); + ASSERT_NE(end, ev.end()); + EXPECT_EQ((*end)["exit"], 4); +} + +XTEST(SubosSession, PsListsAndStopEndsASession, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"SES-START-STOP", "OBS-LOG"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}) { + Box box; + tk::RunResult first; + std::thread t([&] { first = box.cmd("sleep 60"); }); + ASSERT_TRUE(wait_for([&] { return box.running(); })); + + auto ps = box.home.xlings({"subos", "ps", "--json"}); + EXPECT_EQ(ps.exit_code, 0) << ps.transcript(); + auto lines = ps.json_lines(); + ASSERT_EQ(lines.size(), 1u) << ps.transcript(); + EXPECT_EQ(lines[0]["instance"], "box"); + + auto stop = box.home.xlings({"subos", "stop", "box"}); + EXPECT_EQ(stop.exit_code, 0) << stop.transcript(); + t.join(); + EXPECT_NE(first.exit_code, 0) << "the command was ended, not completed"; + EXPECT_FALSE(box.running()); + + auto log = box.home.xlings({"subos", "log", "box", "--json", "--kind", "lifecycle"}); + EXPECT_EQ(log.exit_code, 0); + auto ev = log.json_lines(); + ASSERT_FALSE(ev.empty()); + for (auto& e : ev) EXPECT_EQ(e["kind"], "lifecycle"); + EXPECT_TRUE(std::ranges::any_of(ev, [](auto& e) { + return e.value("event", "") == "session-stop-requested"; })); +} + +XTEST(SubosSession, KeepAndTtlAreTheSessionsIdleTime, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"F11", "COMPAT-ALIASES"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}) { + Box box; + // --ttl: the session outlives the command, then ends once idle that long. + auto ttl = box.home.xlings({"subos", "use", "box", "--sandbox", "--ttl", "2", "--cmd", "echo hi"}); + EXPECT_EQ(ttl.exit_code, 0) << ttl.transcript(); + EXPECT_NE(ttl.out.find("hi"), std::string::npos) << ttl.transcript(); + std::size_t banners = 0; + for (auto at = ttl.out.find("entering subos"); at != std::string::npos; + at = ttl.out.find("entering subos", at + 1)) ++banners; + EXPECT_EQ(banners, 1u) << "one entry, one announcement\n" << ttl.out; + EXPECT_TRUE(box.running()) << "the session ended with the command"; + EXPECT_TRUE(wait_for([&] { return !box.running(); })) << "the session outlived --ttl"; + + // --keep: until `subos stop`. + auto keep = box.home.xlings({"subos", "use", "box", "--sandbox", "--keep", "--cmd", "echo kept"}); + EXPECT_EQ(keep.exit_code, 0) << keep.transcript(); + std::this_thread::sleep_for(std::chrono::seconds(3)); + EXPECT_TRUE(box.running()) << "--keep ended on its own"; + EXPECT_EQ(box.home.xlings({"subos", "stop", "box"}).exit_code, 0); + EXPECT_FALSE(box.running()); + + // --gpu still parses and enters (it binds /sys, which a host has). + if (fs::exists("/sys")) { + auto gpu = box.home.xlings({"subos", "use", "box", "--sandbox", "--gpu", "--cmd", "echo gpu"}); + EXPECT_EQ(gpu.exit_code, 0) << gpu.transcript(); + } +} + +XTEST(SubosSession, TermAndHupEndTheSessionAndCtrlCIsTheCommands, + .area = "subos", .cost = tk::Cost::Medium, .covers = {"SES-SIGNALS"}, + .requires_ = {"linux", "xlings-bin", "sandbox"}, .resources = {"sandbox"}) { + Box box; + // SIGINT to xlings alone (not the group, so not the command): it waits + // on, like system(). Then SIGTERM, or SIGHUP, ends the session. + for (const char* sig : {"TERM", "HUP"}) { + tk::RunOptions o; + o.argv = {"/bin/sh", "-c", + "\"$0\" subos use box --sandbox --cmd 'sleep 30; echo NOT-ENDED' & p=$!; " + "i=0; while [ ! -e \"$XLINGS_HOME/run/subos/box/session.json\" ] && [ $i -lt 200 ]; " + "do sleep 0.1; i=$((i+1)); done; " + "kill -INT $p; sleep 1; kill -0 $p 2>/dev/null && echo ALIVE-AFTER-INT; " + "kill -" + std::string(sig) + " $p; wait $p; echo rc=$?", + tk::xlings_binary().string()}; + o.env = box.home.env(); + o.timeout = std::chrono::seconds(60); + auto r = tk::run(o); + EXPECT_NE(r.out.find("ALIVE-AFTER-INT"), std::string::npos) << sig << "\n" << r.transcript(); + EXPECT_EQ(r.out.find("NOT-ENDED"), std::string::npos) << sig << "\n" << r.transcript(); + EXPECT_EQ(r.out.find("rc=0"), std::string::npos) << sig << "\n" << r.transcript(); + EXPECT_TRUE(wait_for([&] { return !box.running(); })) << sig << ": the session survived"; + } +} diff --git a/tests/e2e/test_subos_state.cpp b/tests/e2e/test_subos_state.cpp new file mode 100644 index 000000000..6eb70be38 --- /dev/null +++ b/tests/e2e/test_subos_state.cpp @@ -0,0 +1,122 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.core.home.prefix_domain; +import xlings.core.home.domain_producer; +import xlings.subos.rootfs; +import xlings.subos.home_view; +import xlings.libs.json; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; + +XTEST(SubosStateE2E, PrivateCopyAndRemovalCheckTheProducerRoleBeforeTouchingUserData, + .area = "subos", .covers = {"DOM-LAYOUTS", "ROOT-ROLE-TABLE"}, .requires_ = {"linux", "xlings-bin"}) { + if constexpr (!tk::is_linux) GTEST_SKIP() << "Linux root instances"; + auto home = tk::Home::isolated("domain-adapter-state"); + auto domain = xlings::home::prefix_domain::resolve(home.dir(), "/xlings", home.root() / "absent-system"); + ASSERT_TRUE(domain) << domain.error(); + ASSERT_TRUE(xlings::home::prefix_domain::prepare_private(*domain)); + const xlings::subos::HomeView producer{domain->physicalHome}; + const auto instance = producer.instance("box"); + tk::write_file(instance / ".xlings.json", R"({"workspace":{}})"); + tk::write_file(producer.instance_file("box"), R"({"kind":"rootfs"})"); + fs::create_directories(instance / std::string(xlings::subos::rootfs::kTree) / "tmp"); + ASSERT_TRUE(xlings::subos::rootfs::commit(instance, {}, "domain adapter fixture")); + ASSERT_TRUE(xlings::home::domain_producer::publish_scope(*domain, "box")); + const auto source = home.root() / "source.txt"; + tk::write_file(source, "user content"); + const auto copied = home.xlings({"subos", "cp", source.string(), "box:/tmp/copied.txt"}); + ASSERT_EQ(copied.exit_code, 0) << copied.transcript(); + const auto target = instance / std::string(xlings::subos::rootfs::kTree) / "tmp/copied.txt"; + EXPECT_EQ(tk::read_file(target), "user content"); + auto unconfirmed = home.xlings({"subos", "remove", "box"}); + EXPECT_EQ(unconfirmed.exit_code, 2) << unconfirmed.transcript(); + EXPECT_EQ(tk::read_file(target), "user content"); + tk::write_file(domain->physicalHome / "boot.json", R"({"default":"box"})"); + auto protectedRemoval = home.xlings({"interface", "remove_subos", "--args", R"({"name":"box","yes":true})"}); + EXPECT_NE(protectedRemoval.exit_code, 0) << protectedRemoval.transcript(); + EXPECT_NE(protectedRemoval.transcript().find("boot entry"), std::string::npos) << protectedRemoval.transcript(); + EXPECT_EQ(tk::read_file(target), "user content"); + tk::write_file(domain->physicalHome / "boot.json", "{broken"); + const auto corruptRole = home.xlings({"subos", "cp", source.string(), "box:/tmp/blocked.txt"}); + EXPECT_NE(corruptRole.exit_code, 0) << corruptRole.transcript(); + EXPECT_FALSE(fs::exists(target.parent_path() / "blocked.txt")); + fs::remove(domain->physicalHome / "boot.json"); + const auto foreign = home.dir() / "subos/box/user-owned"; + tk::write_file(foreign, "foreign control data"); + const auto unknownControl = home.xlings({"subos", "remove", "box", "-y"}); + EXPECT_NE(unknownControl.exit_code, 0) << unknownControl.transcript(); + EXPECT_NE(unknownControl.transcript().find("unowned data"), std::string::npos) << unknownControl.transcript(); + EXPECT_EQ(tk::read_file(foreign), "foreign control data"); + EXPECT_EQ(tk::read_file(target), "user content"); +} + +XTEST(SubosStateE2E, InvalidInstanceMetadataBlocksStatusCopyConfigAndPackWithoutMutation, + .area = "subos", .covers = {"STATE-STRICT"}, .requires_ = {"xlings-bin"}) { + auto home = tk::Home::isolated("instance-state"); + ASSERT_EQ(home.xlings({"subos", "new", "box"}).exit_code, 0); + const auto file = home.dir() / "config/subos/box/instance.json"; + const auto source = home.root() / "source.txt"; + tk::write_file(source, "copy content"); + for (auto bad : {"{broken", R"({"kind":"unknown"})", R"({"kind":false})"}) { + tk::write_file(file, bad); + for (const auto& args : std::vector>{ + {"subos", "status", "box", "--json"}, + {"subos", "cp", source.string(), "box:/tmp/copied.txt"}, + {"subos", "config", "box", "--sandbox=dev"}, + {"subos", "pack", "box", "--as", "demo:bundle@1", "--out", (home.root() / "out").string()}}) { + const auto result = home.xlings(args); + EXPECT_NE(result.exit_code, 0) << result.transcript(); + EXPECT_NE(result.transcript().find("instance.json"), std::string::npos) << result.transcript(); + EXPECT_EQ(tk::read_file(file), bad); + } + EXPECT_FALSE(fs::exists(home.dir() / "subos/box/tmp/copied.txt")); + EXPECT_FALSE(fs::exists(home.dir() / "config/subos/box/policy.json")); + } +} + +XTEST(SubosStateE2E, CorruptBootStateCannotRemoveProtectionOrBeOverwrittenByABootCommand, + .area = "subos", .covers = {"STATE-STRICT"}, .requires_ = {"xlings-bin"}) { + auto home = tk::Home::isolated("boot-state"); + ASSERT_EQ(home.xlings({"subos", "new", "box"}).exit_code, 0); + const auto boot = home.dir() / "boot.json"; + tk::write_file(boot, "{broken"); + const auto source = home.root() / "source.txt"; + tk::write_file(source, "copy content"); + for (const auto& args : std::vector>{ + {"subos", "status", "box", "--json"}, + {"subos", "cp", source.string(), "box:/tmp/copied.txt"}, + {"subos", "config", "box", "--sandbox=dev"}, + {"subos", "remove", "box", "-y"}, + {"interface", "remove_subos", "--args", R"({"name":"box","yes":true})"}, + {"subos", "boot", "--mark-good"}}) { + const auto result = home.xlings(args); + EXPECT_NE(result.exit_code, 0) << result.transcript(); + EXPECT_NE(result.transcript().find("boot.json"), std::string::npos) << result.transcript(); + EXPECT_EQ(tk::read_file(boot), "{broken"); + } + EXPECT_FALSE(fs::exists(home.dir() / "subos/box/tmp/copied.txt")); + EXPECT_FALSE(fs::exists(home.dir() / "config/subos/box/policy.json")); + EXPECT_TRUE(fs::is_directory(home.dir() / "subos/box")); +} + +XTEST(SubosStateE2E, LegacyInstancesRemainViewsAndDanglingMetadataIsAnError, + .area = "subos", .covers = {"STATE-STRICT"}, .requires_ = {"xlings-bin"}) { + auto home = tk::Home::isolated("legacy-state"); + ASSERT_EQ(home.xlings({"subos", "new", "box"}).exit_code, 0); + auto status = home.xlings({"subos", "status", "box", "--json"}); + ASSERT_EQ(status.exit_code, 0) << status.transcript(); + EXPECT_NE(status.out.find("view"), std::string::npos) << status.out; + if constexpr (tk::is_posix) { + const auto file = home.dir() / "config/subos/box/instance.json"; + fs::create_directories(file.parent_path()); + fs::create_symlink("missing-instance", file); + status = home.xlings({"subos", "status", "box", "--json"}); + EXPECT_NE(status.exit_code, 0) << status.transcript(); + EXPECT_NE(status.transcript().find("instance.json"), std::string::npos); + EXPECT_EQ(fs::read_symlink(file), fs::path("missing-instance")); + } +} diff --git a/tests/e2e/test_xdev_report.cpp b/tests/e2e/test_xdev_report.cpp new file mode 100644 index 000000000..28362c5a3 --- /dev/null +++ b/tests/e2e/test_xdev_report.cpp @@ -0,0 +1,44 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.libs.json; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; + +XTEST(XdevReport, SkippedIsolationIsDeclaredButNotVerified, + .area = "testkit", .covers = {"REPORT-EXECUTION-EVIDENCE"}) { + const auto* binary = std::getenv("XDEV_BIN"); + if (!binary || !*binary) GTEST_SKIP() << "run through xdev test or set XDEV_BIN"; + auto home = tk::Home::isolated("report"); + const auto run = home.root() / "run"; + const auto requirements = home.root() / "requirements.toml"; + tk::write_file(requirements, "[ISOLATION]\nkind = \"isolation\"\nstatus = \"required\"\n"); + tk::write_file(run / "meta.ndjson", + R"({"test":"probe","covers":["ISOLATION"],"proves":"isolation"})" "\n"); + tk::write_file(run / "cases.ndjson", R"({"test":"probe","status":"skip","message":"no sandbox"})" "\n"); + const auto report = [&](bool enforce) { + std::vector argv{binary, "report", "--in", run.string(), + "--requirements", requirements.string(), "--fail-uncovered", "--write", run.string()}; + if (enforce) argv.push_back("--fail-unverified"); + return tk::run({.argv = std::move(argv)}); + }; + auto declared = report(false); + ASSERT_EQ(declared.exit_code, 0) << declared.transcript(); + auto evidence = nlohmann::json::parse(tk::read_file(run / "report.json")); + EXPECT_EQ(evidence["requirements"]["ISOLATION"]["declared_by"].size(), 1); + EXPECT_TRUE(evidence["requirements"]["ISOLATION"]["passed_by"].empty()); + EXPECT_NE(report(true).exit_code, 0); + + tk::write_file(run / "cases.ndjson", R"({"test":"probe","status":"pass"})" "\n"); + auto passed = report(true); + ASSERT_EQ(passed.exit_code, 0) << passed.transcript(); + evidence = nlohmann::json::parse(tk::read_file(run / "report.json")); + EXPECT_EQ(evidence["requirements"]["ISOLATION"]["passed_by"].size(), 1); + + tk::write_file(run / "meta.ndjson", + R"({"test":"probe","covers":["ISOLATION"],"proves":"flow"})" "\n"); + EXPECT_NE(report(true).exit_code, 0) << "a passing flow cannot verify isolation"; +} diff --git a/tests/e2e/tui_utf8_test.sh b/tests/e2e/tui_utf8_test.sh index 38ab21af2..5127699bf 100755 --- a/tests/e2e/tui_utf8_test.sh +++ b/tests/e2e/tui_utf8_test.sh @@ -37,19 +37,14 @@ bytes=$(wc -c < "$out_file") log "'xlings config' emitted $bytes bytes (stdout+stderr)" # Look for any of the canonical multi-byte UTF-8 sequences. -declare -A markers=( - ["U+25C6 ◆ package"]="e2 97 86" - ["U+2500 ─ box-h"]="e2 94 80" - ["U+2502 │ box-v"]="e2 94 82" - ["U+2713 ✓ done"]="e2 9c 93" - ["U+25CB ○ pending"]="e2 97 8b" -) +labels=("U+25C6 ◆ package" "U+2500 ─ box-h" "U+2502 │ box-v" "U+2713 ✓ done" "U+25CB ○ pending") +markers=("e2 97 86" "e2 94 80" "e2 94 82" "e2 9c 93" "e2 97 8b") found=() hex=$(od -An -tx1 "$out_file" | tr -s ' \n' ' ') -for label in "${!markers[@]}"; do - if [[ "$hex" == *"${markers[$label]}"* ]]; then - found+=("$label") +for index in "${!markers[@]}"; do + if [[ "$hex" == *"${markers[$index]}"* ]]; then + found+=("${labels[$index]}") fi done diff --git a/tests/e2e/windows_carrier_wsl2_test.ps1 b/tests/e2e/windows_carrier_wsl2_test.ps1 new file mode 100644 index 000000000..13f9e9b11 --- /dev/null +++ b/tests/e2e/windows_carrier_wsl2_test.ps1 @@ -0,0 +1,63 @@ +#Requires -Version 5.1 +# E2E-08: the wsl2 carrier on a real Windows host (SubOS design part 3 §5.3). +# +# With WSL2: a Linux SubOS is created, used and removed through the carrier, +# and inside the carrier no Windows program runs and no Windows drive is +# mounted (CARRIER-WSL-LIFECYCLE, CARRIER-WSL-INTEROP-OFF). +# Without it (the hosted runner's usual state): the carrier is refused with +# exit 125 and the route that brings it, and a native SubOS is made as before +# (CARRIER-UNAVAILABLE). + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +. "$PSScriptRoot\release_test_lib.ps1" + +$ARCHIVE_PATH = if ($args.Count -ge 1) { $args[0] } else { Join-Path $ROOT_DIR 'build\release.zip' } +$ARCHIVE_PATH = Require-ReleaseArchive $ARCHIVE_PATH +Require-FixtureIndex +$PKG_DIR = Expand-ReleaseArchive $ARCHIVE_PATH 'carrier_wsl2' +Write-FixtureReleaseConfig $PKG_DIR +$env:XLINGS_HOME = $PKG_DIR +$env:Path = "$PKG_DIR\bin;$(Get-MinimalSystemPath)" +xlings self init | Out-Null + +$env:WSL_UTF8 = '1' +$wsl2 = $false +try { + $status = (& wsl.exe --status 2>&1 | Out-String) + $wsl2 = ($LASTEXITCODE -eq 0) -and ($status -notmatch 'WSL 2 is not supported') -and ($status -notmatch 'kernel component') + Write-Host "wsl --status (exit $LASTEXITCODE):`n$status" +} catch { + Write-Host "wsl.exe is not available: $_" +} + +$out = (& xlings subos new carrierbox --carrier wsl2 2>&1 | Out-String) +$rc = $LASTEXITCODE +Write-Host "subos new --carrier wsl2 (exit $rc):`n$out" + +if ($wsl2 -and $rc -eq 0) { + $info = (& xlings subos info carrierbox 2>&1 | Out-String) + if ($LASTEXITCODE -ne 0) { Fail "subos info through the carrier failed: $info" } + $list = (& xlings subos list --json 2>&1 | Out-String) + if ($list -notmatch '"carrier":"wsl2"') { Fail "the carrier SubOS is not listed with its carrier: $list" } + $distro = (& wsl.exe --list --quiet | Out-String) -split "`r?`n" | Where-Object { $_ -like 'xlings-*' } | Select-Object -First 1 + if (-not $distro) { Fail "no xlings carrier distribution registered" } + # Interop off: a Windows program does not run inside, and C: is not there. + & wsl.exe -d $distro -u root --exec /mnt/c/Windows/System32/cmd.exe /c exit 0 2>$null + if ($LASTEXITCODE -eq 0) { Fail "a Windows program ran inside the carrier (interop is on)" } + & xlings subos remove carrierbox -y + if ($LASTEXITCODE -ne 0) { Fail "subos remove through the carrier failed" } + & wsl.exe --unregister $distro | Out-Null + Write-Host "PASS: wsl2 carrier lifecycle and interop-off on a real WSL2 host" + exit 0 +} + +if ($rc -ne 125) { Fail "without WSL2 the carrier must refuse with exit 125 (got $rc)" } +if ($out -notmatch 'wsl|WSL') { Fail "the refusal does not name WSL or its route: $out" } +if (Test-Path "$PKG_DIR\subos\carrierbox") { Fail "a refused carrier SubOS left a directory behind" } +& xlings subos new nativebox +if ($LASTEXITCODE -ne 0) { Fail "a native SubOS could not be made after the carrier was refused" } +& xlings subos remove nativebox -y | Out-Null +Write-Host "PASS: wsl2 carrier unavailable here -> refused with its route; native SubOS unaffected" +exit 0 diff --git a/tests/e2e/windows_shim_identity_test.ps1 b/tests/e2e/windows_shim_identity_test.ps1 index c64645a08..b94b966e1 100644 --- a/tests/e2e/windows_shim_identity_test.ps1 +++ b/tests/e2e/windows_shim_identity_test.ps1 @@ -229,9 +229,18 @@ end $env:XLINGS_FIXTURE_PAYLOAD = $payload try { - & $entry config --add-xpkg $fixtureRecipe - if ($LASTEXITCODE -ne 0) { throw "S3: fixture recipe import failed" } - & $entry install local:xlings@9.9.9 --use -y + # The shared client is owned by xim:xlings; another namespace must not + # acquire its entry authority. Give this isolated home a private xim. + $fixtureIndex = Join-Path $work "xim-index" + $fixturePkgs = Join-Path $fixtureIndex "pkgs\x" + New-Item -ItemType Directory -Force -Path $fixturePkgs | Out-Null + Copy-Item $fixtureRecipe (Join-Path $fixturePkgs "xlings.lua") + 'xim_indexrepos = {}' | Set-Content (Join-Path $fixtureIndex "xim-indexrepos.lua") + $configPath = Join-Path $env:XLINGS_HOME ".xlings.json" + $config = Get-Content -Raw $configPath | ConvertFrom-Json + $config | Add-Member -Force -NotePropertyName index_repos -NotePropertyValue @(@{ name = "xim"; url = $fixtureIndex }) + $config | ConvertTo-Json -Depth 100 | Set-Content $configPath + & $entry install xim:xlings@9.9.9 --use -y if ($LASTEXITCODE -ne 0) { throw "S3: install --use of the fixture xlings failed" } } finally { Remove-Item Env:XLINGS_FIXTURE_PAYLOAD -ErrorAction SilentlyContinue diff --git a/tests/fixtures/carrier/fake-vz-helper.sh b/tests/fixtures/carrier/fake-vz-helper.sh new file mode 100755 index 000000000..65c8a72d7 --- /dev/null +++ b/tests/fixtures/carrier/fake-vz-helper.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# A stand-in for xlings-vm, the vz carrier's helper (SubOS design part 3 +# §5.4), on a Linux CI host: XLINGS_VZ_HELPER points at this file. A "VM" is +# a directory $FAKE_VZ_ROOT/vms/ with the image extracted in it, a +# `running` marker, and shares recorded in `shares`. Calls go to calls.log. +set -eu +root="${FAKE_VZ_ROOT:?FAKE_VZ_ROOT}" +mkdir -p "$root/vms" +printf '%s\n' "$*" >> "$root/calls.log" +cmd="${1:-}"; shift || true +name=""; dir=""; image=""; host=""; tag=""; mode="" +while [ $# -gt 0 ]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --dir) dir="$2"; shift 2 ;; + --image) image="$2"; shift 2 ;; + --host) host="$2"; shift 2 ;; + --tag) tag="$2"; shift 2 ;; + --mode) mode="$2"; shift 2 ;; + --) shift; break ;; + *) break ;; + esac +done +vm="$root/vms/$name" +case "$cmd" in + probe) echo "fake Virtualization.framework"; exit 0 ;; + status) [ -d "$vm" ] || exit 4; [ -e "$vm/running" ] && exit 0; exit 3 ;; + create) [ -d "$vm" ] && exit 1; mkdir -p "$vm"; tar -xzf "$image" -C "$vm"; exit 0 ;; + start) [ -d "$vm" ] || exit 4; touch "$vm/running"; exit 0 ;; + stop) rm -f "$vm/running"; exit 0 ;; + share) [ -e "$vm/running" ] || exit 3; printf '%s %s %s\n' "$host" "$tag" "$mode" >> "$vm/shares"; echo "/grant/$tag"; exit 0 ;; + exec) + [ -e "$vm/running" ] || { echo "not running" >&2; exit 125; } + prog="$1"; shift + case "$prog" in /*) prog="$vm$prog" ;; esac + exec env -u XLINGS_HOME -u XLINGS_ACTIVE_SUBOS HOME="$vm/root" "$prog" "$@" ;; + *) echo "fake xlings-vm: unsupported $cmd" >&2; exit 2 ;; +esac diff --git a/tests/fixtures/carrier/fake-wsl.sh b/tests/fixtures/carrier/fake-wsl.sh new file mode 100755 index 000000000..6991d4e89 --- /dev/null +++ b/tests/fixtures/carrier/fake-wsl.sh @@ -0,0 +1,37 @@ +#!/bin/sh +# A stand-in for wsl.exe (SubOS design part 3 §5.3), for the wsl2 carrier's +# lifecycle on a Linux CI host: XLINGS_WSL_EXE points at this file. +# +# A "distribution" is a directory under $FAKE_WSL_ROOT/distros/: --import +# extracts the image tarball there, --exec runs the guest's absolute program +# from inside it (its own path, so the guest xlings finds its home at +# /xlings), --terminate and --unregister do what they say. Every call +# is appended to $FAKE_WSL_ROOT/calls.log. +set -eu +root="${FAKE_WSL_ROOT:?FAKE_WSL_ROOT}" +mkdir -p "$root/distros" +printf '%s\n' "$*" >> "$root/calls.log" +case "${1:-}" in + --status) echo "Default Distribution: none"; echo "Default Version: 2"; exit 0 ;; + --list) ls -1 "$root/distros"; exit 0 ;; + --import) + name="$2"; image="$4" + [ "${5:-}" = "--version" ] && [ "${6:-}" = "2" ] || { echo "expected --version 2" >&2; exit 2; } + [ -e "$root/distros/$name" ] && { echo "A distribution with the supplied name already exists." >&2; exit 1; } + mkdir -p "$root/distros/$name" + tar -xzf "$image" -C "$root/distros/$name" + exit 0 ;; + --terminate) touch "$root/terminated-$2"; exit 0 ;; + --unregister) rm -rf "$root/distros/$2"; exit 0 ;; + -d) + name="$2"; shift 2 + [ "$1" = "-u" ] && shift 2 + [ "$1" = "--exec" ] || { echo "expected --exec" >&2; exit 2; } + shift + dir="$root/distros/$name" + [ -d "$dir" ] || { echo "There is no distribution with the supplied name." >&2; exit 1; } + prog="$1"; shift + case "$prog" in /*) prog="$dir$prog" ;; esac + exec env -u XLINGS_HOME -u XLINGS_ACTIVE_SUBOS HOME="$dir/root" "$prog" "$@" ;; + *) echo "fake wsl: unsupported $*" >&2; exit 2 ;; +esac diff --git a/tests/fixtures/intent-eq/golden.tsv b/tests/fixtures/intent-eq/golden.tsv new file mode 100644 index 000000000..7215e5f9e --- /dev/null +++ b/tests/fixtures/intent-eq/golden.tsv @@ -0,0 +1,872 @@ +A0 preset0 pref0 host0 st0 net0 gr0 x0 itrue roottrue ok:bwrap 8938df0983b4dc6f582ab43863bbaa3b496b2ed72acb4821c30112cb2b69eeaf +A0 preset0 pref0 host1 st1 net0 gr0 x0 ifalse rootfalse refused:storage 9e612a83af2724b34b46ddac0da45a1e3a31af44c5276c26592a6cebe9e2ede1 +A0 preset0 pref0 host2 st2 net0 gr1 x0 itrue rootfalse refused:backend 54018b28180109ba010b4a45494d558403ca2280ed486e4b892c3e69db5a99e6 +A0 preset0 pref0 host3 st0 net1 gr1 x0 ifalse rootfalse ok:home-redirect 1326390bba4e25d09e861db5a02521bab11fc45c3c8e7b722aab9aee359fd0e7 +A0 preset0 pref0 host4 st1 net1 gr2 x0 itrue rootfalse ok:home-redirect 5476ff21d6fcb89aa169b8bd49db513e6cb5732090089983d72f1a5aece88f1b +A0 preset0 pref1 host0 st2 net1 gr2 x1 ifalse rootfalse ok:bwrap e055f0fe519625515efc374297c6f657f7213cdaa3e8879fae434acaa7ef1f2a +A0 preset0 pref1 host1 st0 net2 gr0 x1 itrue rootfalse refused:backend cd9059ca6e2774ca25885318b51e01da71013254dd9e67854bbb2bbaf5e2c43d +A0 preset0 pref1 host2 st1 net2 gr0 x1 ifalse roottrue refused:backend 14cfa70315c15d5fd2b85c7b90d61d3babb932456435b060539b4bb097c65de3 +A0 preset0 pref1 host3 st2 net2 gr1 x1 itrue rootfalse refused:net 7d3a3727db31098b3774d3e70bbec91eacd88d87b77ea503aeeee8fcdbc0b71d +A0 preset0 pref1 host4 st0 net3 gr1 x1 ifalse rootfalse ok:home-redirect a42460f4fdd5dcd93cde3f81807994784e980d936326cdf0d66b8a9649f0632b +A0 preset0 pref2 host0 st1 net3 gr2 x2 itrue rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +A0 preset0 pref2 host1 st2 net3 gr2 x2 ifalse rootfalse refused:storage 5b82f379c16df237095688c107aa044ecdae22017c10df5453d4e8ab1c7e087f +A0 preset0 pref2 host2 st0 net0 gr0 x2 itrue rootfalse refused:backend 0fff0f9b5a5c798abcac5e9e45a4b1e9ef78853e36125254a4c632a042c019c7 +A0 preset0 pref2 host3 st1 net0 gr0 x2 ifalse rootfalse ok:home-redirect 45f7c9bff4432c8f3ef3e81175008dabbf2c48b78ea80d3219a8d17db9ec17a9 +A0 preset0 pref2 host4 st2 net0 gr1 x2 itrue roottrue refused:root e381a0087b5ffbb1d18e83ed4616873975c99162dd248c128fb89abcdf3988ed +A0 preset0 pref3 host0 st0 net1 gr1 x3 ifalse rootfalse ok:landlock 7023b9a694f2bc612b8909fa0ff643d4ed1ca29c978d95478a9a3b5a67fc0eab +A0 preset0 pref3 host1 st1 net1 gr2 x3 itrue rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +A0 preset0 pref3 host2 st2 net1 gr2 x3 ifalse rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +A0 preset0 pref3 host3 st0 net2 gr0 x3 itrue rootfalse refused:net 7d3a3727db31098b3774d3e70bbec91eacd88d87b77ea503aeeee8fcdbc0b71d +A0 preset0 pref3 host4 st1 net2 gr0 x3 ifalse rootfalse refused:net 7d3a3727db31098b3774d3e70bbec91eacd88d87b77ea503aeeee8fcdbc0b71d +A0 preset0 pref4 host0 st2 net2 gr1 x4 itrue rootfalse ok:fake a5399f48dc0b21c01d79726ff34e334dbc1f4457b2d3eb3ba616d2d231f3d87c +A0 preset0 pref4 host1 st0 net3 gr1 x4 ifalse roottrue ok:fake c8346012b20bf045a7924dda5f7c9c9568e6fcd6f1479ccc7b9fb53a700b2cc5 +A0 preset0 pref4 host2 st1 net3 gr2 x4 itrue rootfalse refused:publish c61a3da4bee7febf63423606a86d686933099c6bb31acdf03150c1920054ef9b +A0 preset0 pref4 host3 st2 net3 gr2 x4 ifalse rootfalse refused:net,publish,fs,mount a6c6c9f21cba32788a4b989f84af3223720f0e240a2f5a5adf0ded73e0c44ceb +A0 preset0 pref4 host4 st0 net0 gr0 x4 itrue rootfalse refused:fs,mount 3d15309ef1aba0dbe2f61369423bdeacb55aa6218232dd5a30a7fe54ab871b74 +A0 preset1 pref0 host0 st1 net0 gr0 x5 ifalse rootfalse refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +A0 preset1 pref0 host1 st2 net0 gr1 x5 itrue rootfalse refused:storage 5b82f379c16df237095688c107aa044ecdae22017c10df5453d4e8ab1c7e087f +A0 preset1 pref0 host2 st0 net1 gr1 x5 ifalse rootfalse refused:backend 54018b28180109ba010b4a45494d558403ca2280ed486e4b892c3e69db5a99e6 +A0 preset1 pref0 host3 st1 net1 gr2 x5 itrue roottrue refused:root e381a0087b5ffbb1d18e83ed4616873975c99162dd248c128fb89abcdf3988ed +A0 preset1 pref0 host4 st2 net1 gr2 x5 ifalse rootfalse refused:net,mount,mount beb1c53bffc9bc838a0c5250bdb821da892d8ae9d3c7c04e3ad3e7114a75dd30 +A0 preset1 pref1 host0 st0 net2 gr0 x6 itrue rootfalse ok:bwrap 53a16877f348f7840f42d2679c7ab68db95fac12b897ed3b6bd7cdd8a03af34c +A0 preset1 pref1 host1 st1 net2 gr0 x6 ifalse rootfalse refused:backend cd9059ca6e2774ca25885318b51e01da71013254dd9e67854bbb2bbaf5e2c43d +A0 preset1 pref1 host2 st2 net2 gr1 x6 itrue rootfalse refused:backend 14cfa70315c15d5fd2b85c7b90d61d3babb932456435b060539b4bb097c65de3 +A0 preset1 pref1 host3 st0 net3 gr1 x6 ifalse rootfalse ok:home-redirect 1326390bba4e25d09e861db5a02521bab11fc45c3c8e7b722aab9aee359fd0e7 +A0 preset1 pref1 host4 st1 net3 gr2 x6 itrue rootfalse ok:home-redirect 5476ff21d6fcb89aa169b8bd49db513e6cb5732090089983d72f1a5aece88f1b +A0 preset1 pref2 host0 st2 net3 gr2 x7 ifalse roottrue refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +A0 preset1 pref2 host1 st0 net0 gr0 x7 itrue rootfalse ok:proot b123df546509e59e089db80e034315397a5b3aa4a507f7e0d239503222a96156 +A0 preset1 pref2 host2 st1 net0 gr0 x7 ifalse rootfalse refused:backend 0fff0f9b5a5c798abcac5e9e45a4b1e9ef78853e36125254a4c632a042c019c7 +A0 preset1 pref2 host3 st2 net0 gr1 x7 itrue rootfalse ok:home-redirect 8312d70feb316669e0561dd6c2c56099f0c4ab089e7f9ca6a1aa723a5c136292 +A0 preset1 pref2 host4 st0 net1 gr1 x7 ifalse rootfalse ok:home-redirect a42460f4fdd5dcd93cde3f81807994784e980d936326cdf0d66b8a9649f0632b +A0 preset1 pref3 host0 st1 net1 gr2 x8 itrue rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +A0 preset1 pref3 host1 st2 net1 gr2 x8 ifalse rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +A0 preset1 pref3 host2 st0 net2 gr0 x8 itrue roottrue refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +A0 preset1 pref3 host3 st1 net2 gr0 x8 ifalse rootfalse refused:net 7d3a3727db31098b3774d3e70bbec91eacd88d87b77ea503aeeee8fcdbc0b71d +A0 preset1 pref3 host4 st2 net2 gr1 x8 itrue rootfalse refused:net 7d3a3727db31098b3774d3e70bbec91eacd88d87b77ea503aeeee8fcdbc0b71d +A0 preset1 pref4 host0 st0 net3 gr1 x9 ifalse rootfalse ok:fake 8727992eae843894e1c887286f8fd49c091c7970d66695b5e65bfc300e84b3ab +A0 preset1 pref4 host1 st1 net3 gr2 x9 itrue rootfalse refused:publish c61a3da4bee7febf63423606a86d686933099c6bb31acdf03150c1920054ef9b +A0 preset1 pref4 host2 st2 net3 gr2 x9 ifalse rootfalse refused:publish c61a3da4bee7febf63423606a86d686933099c6bb31acdf03150c1920054ef9b +A0 preset1 pref4 host3 st0 net0 gr0 x9 itrue rootfalse refused:fs d58c2e555ed387687b4d20e937b77e94bc6136df5969467e76ef2ec96d49f1d4 +A0 preset1 pref4 host4 st1 net0 gr0 x9 ifalse roottrue refused:root e381a0087b5ffbb1d18e83ed4616873975c99162dd248c128fb89abcdf3988ed +A0 preset2 pref0 host0 st2 net0 gr1 x10 itrue rootfalse ok:bwrap 468fc2d9efc3556dbda1b0c244ece71e21af82711c84d139117fd8658a30e9e0 +A0 preset2 pref0 host1 st0 net1 gr1 x10 ifalse rootfalse refused:net,fs,pid,mount 6bed5de033d25cec93df90e9ccaa926722e45d9a4a95383f21f6e85cb6980550 +A0 preset2 pref0 host2 st1 net1 gr2 x10 itrue rootfalse refused:backend 54018b28180109ba010b4a45494d558403ca2280ed486e4b892c3e69db5a99e6 +A0 preset2 pref0 host3 st2 net1 gr2 x10 ifalse rootfalse refused:net,fs,mount 055b8cd5176d3028f9e6cdb139feb22e2611dfdc477e7ba1471616fbf158b462 +A0 preset2 pref0 host4 st0 net2 gr0 x10 itrue rootfalse refused:net,fs,mount 055b8cd5176d3028f9e6cdb139feb22e2611dfdc477e7ba1471616fbf158b462 +A0 preset2 pref1 host0 st1 net2 gr0 x11 ifalse rootfalse refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +A0 preset2 pref1 host1 st2 net2 gr1 x11 itrue roottrue refused:backend cd9059ca6e2774ca25885318b51e01da71013254dd9e67854bbb2bbaf5e2c43d +A0 preset2 pref1 host2 st0 net3 gr1 x11 ifalse rootfalse refused:backend 14cfa70315c15d5fd2b85c7b90d61d3babb932456435b060539b4bb097c65de3 +A0 preset2 pref1 host3 st1 net3 gr2 x11 itrue rootfalse refused:net,fs,mount,mount 4ef124741a0c7cff143d3a28608f7a5a43e1570e6a598d57f04c82412ebd16cf +A0 preset2 pref1 host4 st2 net3 gr2 x11 ifalse rootfalse refused:net,fs,mount,mount 4ef124741a0c7cff143d3a28608f7a5a43e1570e6a598d57f04c82412ebd16cf +A0 preset2 pref2 host0 st0 net0 gr0 x12 itrue rootfalse refused:net,fs,pid ce7b1275459339a24a7a912252f6ec086ccc19f25e2db8ee11f1548906afdb1e +A0 preset2 pref2 host1 st1 net0 gr0 x12 ifalse rootfalse refused:storage 9e612a83af2724b34b46ddac0da45a1e3a31af44c5276c26592a6cebe9e2ede1 +A0 preset2 pref2 host2 st2 net0 gr1 x12 itrue rootfalse refused:backend 0fff0f9b5a5c798abcac5e9e45a4b1e9ef78853e36125254a4c632a042c019c7 +A0 preset2 pref2 host3 st0 net1 gr1 x12 ifalse roottrue refused:root e381a0087b5ffbb1d18e83ed4616873975c99162dd248c128fb89abcdf3988ed +A0 preset2 pref2 host4 st1 net1 gr2 x12 itrue rootfalse refused:net,fs 3638ceea5a51eb70ab43f7569b7dd3972efe24cbb25b4a37d54b359b923d47c7 +A0 preset2 pref3 host0 st2 net1 gr2 x13 ifalse rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +A0 preset2 pref3 host1 st0 net2 gr0 x13 itrue rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +A0 preset2 pref3 host2 st1 net2 gr0 x13 ifalse rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +A0 preset2 pref3 host3 st2 net2 gr1 x13 itrue rootfalse refused:net,fs,mount 055b8cd5176d3028f9e6cdb139feb22e2611dfdc477e7ba1471616fbf158b462 +A0 preset2 pref3 host4 st0 net3 gr1 x13 ifalse rootfalse refused:net,fs,mount 055b8cd5176d3028f9e6cdb139feb22e2611dfdc477e7ba1471616fbf158b462 +A0 preset2 pref4 host0 st1 net3 gr2 x14 itrue roottrue refused:publish c61a3da4bee7febf63423606a86d686933099c6bb31acdf03150c1920054ef9b +A0 preset2 pref4 host1 st2 net3 gr2 x14 ifalse rootfalse refused:publish c61a3da4bee7febf63423606a86d686933099c6bb31acdf03150c1920054ef9b +A0 preset2 pref4 host2 st0 net0 gr0 x14 itrue rootfalse refused:net 2471fd4dd1b419a9a0274d7b89f3796246c2ea5f7dcaaf3bbda77cfbb5ccbfc3 +A0 preset2 pref4 host3 st1 net0 gr0 x14 ifalse rootfalse refused:net,identity,userns,fs bc6674d560eafe512acfc90c859eb411d4a8c2251dca757cd0b4d63a85f0faa6 +A0 preset2 pref4 host4 st2 net0 gr1 x14 itrue rootfalse refused:net,identity,userns,fs bc6674d560eafe512acfc90c859eb411d4a8c2251dca757cd0b4d63a85f0faa6 +A0 preset3 pref0 host0 st0 net1 gr1 x15 ifalse rootfalse ok:bwrap 1c5c0ef7ad34962601a0979325654884949cc34478bdb7e30c84924c81e0cb3c +A0 preset3 pref0 host1 st1 net1 gr2 x15 itrue rootfalse refused:storage 9e612a83af2724b34b46ddac0da45a1e3a31af44c5276c26592a6cebe9e2ede1 +A0 preset3 pref0 host2 st2 net1 gr2 x15 ifalse roottrue refused:backend 54018b28180109ba010b4a45494d558403ca2280ed486e4b892c3e69db5a99e6 +A0 preset3 pref0 host3 st0 net2 gr0 x15 itrue rootfalse refused:net,identity,fs 01f4011eeb0148fe010ac7e2de9f02a4415ee1af20404207f5daf950846a007c +A0 preset3 pref0 host4 st1 net2 gr0 x15 ifalse rootfalse refused:net,identity,fs 01f4011eeb0148fe010ac7e2de9f02a4415ee1af20404207f5daf950846a007c +A0 preset3 pref1 host0 st2 net2 gr1 x16 itrue rootfalse ok:bwrap cc48c87a47f5a64deaeea700309bd3600e8783bd39799b89c119c932fc799db4 +A0 preset3 pref1 host1 st0 net3 gr1 x16 ifalse rootfalse refused:backend cd9059ca6e2774ca25885318b51e01da71013254dd9e67854bbb2bbaf5e2c43d +A0 preset3 pref1 host2 st1 net3 gr2 x16 itrue rootfalse refused:backend 14cfa70315c15d5fd2b85c7b90d61d3babb932456435b060539b4bb097c65de3 +A0 preset3 pref1 host3 st2 net3 gr2 x16 ifalse rootfalse refused:net,identity,fs,mount 02e2609fbf38a6dd0d33ab914d647c1f82592877f570ba5e9b90aba19dcaec21 +A0 preset3 pref1 host4 st0 net0 gr0 x16 itrue roottrue refused:root e381a0087b5ffbb1d18e83ed4616873975c99162dd248c128fb89abcdf3988ed +A0 preset3 pref2 host0 st1 net0 gr0 x17 ifalse rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +A0 preset3 pref2 host1 st2 net0 gr1 x17 itrue rootfalse refused:storage 5b82f379c16df237095688c107aa044ecdae22017c10df5453d4e8ab1c7e087f +A0 preset3 pref2 host2 st0 net1 gr1 x17 ifalse rootfalse refused:backend 0fff0f9b5a5c798abcac5e9e45a4b1e9ef78853e36125254a4c632a042c019c7 +A0 preset3 pref2 host3 st1 net1 gr2 x17 itrue rootfalse refused:net,identity,fs,mount,mount ac4cb20885e43915a2c57feaafbff00d89174f47c76d76904fc374e6121a37aa +A0 preset3 pref2 host4 st2 net1 gr2 x17 ifalse rootfalse refused:net,identity,fs,mount,mount ac4cb20885e43915a2c57feaafbff00d89174f47c76d76904fc374e6121a37aa +A0 preset3 pref3 host0 st0 net2 gr0 x18 itrue rootfalse refused:net,identity,fs,pid,terminal 26f40ad53fe65313477e1b20e993190d74316e9b6f2ce716d3af57c54231fa8a +A0 preset3 pref3 host1 st1 net2 gr0 x18 ifalse roottrue refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +A0 preset3 pref3 host2 st2 net2 gr1 x18 itrue rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +A0 preset3 pref3 host3 st0 net3 gr1 x18 ifalse rootfalse refused:net,identity,fs 01f4011eeb0148fe010ac7e2de9f02a4415ee1af20404207f5daf950846a007c +A0 preset3 pref3 host4 st1 net3 gr2 x18 itrue rootfalse refused:net,identity,fs 01f4011eeb0148fe010ac7e2de9f02a4415ee1af20404207f5daf950846a007c +A0 preset3 pref4 host0 st2 net3 gr2 x19 ifalse rootfalse refused:publish c61a3da4bee7febf63423606a86d686933099c6bb31acdf03150c1920054ef9b +A0 preset3 pref4 host1 st0 net0 gr0 x19 itrue rootfalse ok:fake 680f69cd2813207b5343b67f09088fb3dbd1e8ab7659290cbfeeed8b0167050c +A0 preset3 pref4 host2 st1 net0 gr0 x19 ifalse rootfalse ok:fake 74616daa108a0ceee3360530c895e3946921b6c5f9eac3c1ec3fd6916c09894d +A0 preset3 pref4 host3 st2 net0 gr1 x19 itrue roottrue refused:root e381a0087b5ffbb1d18e83ed4616873975c99162dd248c128fb89abcdf3988ed +A0 preset3 pref4 host4 st0 net1 gr1 x19 ifalse rootfalse refused:net,identity,userns,fs,mount ac294ff94f3dce0294a600cfe9d95253b0d41323ca95be03345f1ef79b14c0db +A1 preset0 pref0 host0 st2 net3 gr2 x21 ifalse rootfalse ok:bwrap f0f2d96206c0505d8d878d9903f01d6b6a8544a4fb0681f006b6d0622f760039 +A1 preset0 pref0 host1 st0 net0 gr0 x21 itrue rootfalse ok:proot 96270c7ca10c5d5b583b0d6f27a2871c3d1303eb6fc0040d9a7e5f9180c09b5e +A1 preset0 pref0 host2 st1 net0 gr0 x21 ifalse rootfalse refused:backend 54018b28180109ba010b4a45494d558403ca2280ed486e4b892c3e69db5a99e6 +A1 preset0 pref0 host3 st2 net0 gr1 x22 itrue rootfalse ok:home-redirect 8312d70feb316669e0561dd6c2c56099f0c4ab089e7f9ca6a1aa723a5c136292 +A1 preset0 pref0 host4 st0 net1 gr1 x22 ifalse rootfalse ok:home-redirect a42460f4fdd5dcd93cde3f81807994784e980d936326cdf0d66b8a9649f0632b +A1 preset0 pref1 host0 st1 net1 gr2 x22 itrue rootfalse ok:bwrap 18a0993337f87ce3d7246630656fa23fcfd00cdcd2e0fecaecd969522a60c035 +A1 preset0 pref1 host1 st2 net1 gr2 x22 ifalse roottrue refused:backend cd9059ca6e2774ca25885318b51e01da71013254dd9e67854bbb2bbaf5e2c43d +A1 preset0 pref1 host2 st0 net2 gr0 x22 itrue rootfalse refused:backend 14cfa70315c15d5fd2b85c7b90d61d3babb932456435b060539b4bb097c65de3 +A1 preset0 pref1 host3 st1 net2 gr0 x23 ifalse rootfalse refused:net,mount,mount beb1c53bffc9bc838a0c5250bdb821da892d8ae9d3c7c04e3ad3e7114a75dd30 +A1 preset0 pref1 host4 st2 net2 gr1 x23 itrue rootfalse refused:net,mount,mount beb1c53bffc9bc838a0c5250bdb821da892d8ae9d3c7c04e3ad3e7114a75dd30 +A1 preset0 pref2 host0 st0 net3 gr1 x23 ifalse rootfalse refused:net,mount,mount 73792dd964b98b36cdace71c2a2b421d70338ff0520034ff3e65680ddab1f1eb +A1 preset0 pref2 host1 st1 net3 gr2 x23 itrue rootfalse refused:storage 9e612a83af2724b34b46ddac0da45a1e3a31af44c5276c26592a6cebe9e2ede1 +A1 preset0 pref2 host2 st2 net3 gr2 x23 ifalse rootfalse refused:backend 0fff0f9b5a5c798abcac5e9e45a4b1e9ef78853e36125254a4c632a042c019c7 +A1 preset0 pref2 host3 st0 net0 gr0 x24 itrue roottrue refused:root e381a0087b5ffbb1d18e83ed4616873975c99162dd248c128fb89abcdf3988ed +A1 preset0 pref2 host4 st1 net0 gr0 x24 ifalse rootfalse refused:fs d58c2e555ed387687b4d20e937b77e94bc6136df5969467e76ef2ec96d49f1d4 +A1 preset0 pref3 host0 st2 net0 gr1 x24 itrue rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +A1 preset0 pref3 host1 st0 net1 gr1 x24 ifalse rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +A1 preset0 pref3 host2 st1 net1 gr2 x24 itrue rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +A1 preset0 pref3 host3 st2 net1 gr2 x25 ifalse rootfalse ok:home-redirect ee0c90674f29325f82055117c2b26a1925ea481309aee3cbf8502ad4a123c2fe +A1 preset0 pref3 host4 st0 net2 gr0 x25 itrue rootfalse refused:net 7d3a3727db31098b3774d3e70bbec91eacd88d87b77ea503aeeee8fcdbc0b71d +A1 preset0 pref4 host0 st1 net2 gr0 x25 ifalse roottrue ok:fake 20535bc42a09c878da1b2a74a5d1f565a61ceafb7dd1404c7c04a82920b618bd +A1 preset0 pref4 host1 st2 net2 gr1 x25 itrue rootfalse ok:fake a5399f48dc0b21c01d79726ff34e334dbc1f4457b2d3eb3ba616d2d231f3d87c +A1 preset0 pref4 host2 st0 net3 gr1 x25 ifalse rootfalse ok:fake d192882945f9e233e19c4125d126ea5eb4763389b700798c3e24fa3572f53034 +A1 preset0 pref4 host3 st1 net3 gr2 x26 itrue rootfalse refused:net 7d3a3727db31098b3774d3e70bbec91eacd88d87b77ea503aeeee8fcdbc0b71d +A1 preset0 pref4 host4 st2 net3 gr2 x26 ifalse rootfalse refused:net 7d3a3727db31098b3774d3e70bbec91eacd88d87b77ea503aeeee8fcdbc0b71d +A1 preset1 pref0 host0 st0 net0 gr0 x26 itrue rootfalse ok:bwrap ab5ee8d3ab16494d4d80afab87c177a611b9ad1fc8a1f68d7a461d17f40a5da5 +A1 preset1 pref0 host1 st1 net0 gr0 x26 ifalse rootfalse refused:storage 9e612a83af2724b34b46ddac0da45a1e3a31af44c5276c26592a6cebe9e2ede1 +A1 preset1 pref0 host2 st2 net0 gr1 x26 itrue roottrue refused:backend 54018b28180109ba010b4a45494d558403ca2280ed486e4b892c3e69db5a99e6 +A1 preset1 pref0 host3 st0 net1 gr1 x27 ifalse rootfalse ok:home-redirect 1326390bba4e25d09e861db5a02521bab11fc45c3c8e7b722aab9aee359fd0e7 +A1 preset1 pref0 host4 st1 net1 gr2 x27 itrue rootfalse ok:home-redirect 5476ff21d6fcb89aa169b8bd49db513e6cb5732090089983d72f1a5aece88f1b +A1 preset1 pref1 host0 st2 net1 gr2 x27 ifalse rootfalse ok:bwrap 8edeb3fba6af4b9d965395de3dcd7dd46539645d2be4dbeddba5825f7e49944b +A1 preset1 pref1 host1 st0 net2 gr0 x27 itrue rootfalse refused:backend cd9059ca6e2774ca25885318b51e01da71013254dd9e67854bbb2bbaf5e2c43d +A1 preset1 pref1 host2 st1 net2 gr0 x27 ifalse rootfalse refused:backend 14cfa70315c15d5fd2b85c7b90d61d3babb932456435b060539b4bb097c65de3 +A1 preset1 pref1 host3 st2 net2 gr1 x28 itrue rootfalse refused:net 7d3a3727db31098b3774d3e70bbec91eacd88d87b77ea503aeeee8fcdbc0b71d +A1 preset1 pref1 host4 st0 net3 gr1 x28 ifalse roottrue refused:root e381a0087b5ffbb1d18e83ed4616873975c99162dd248c128fb89abcdf3988ed +A1 preset1 pref2 host0 st1 net3 gr2 x28 itrue rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +A1 preset1 pref2 host1 st2 net3 gr2 x28 ifalse rootfalse refused:storage 5b82f379c16df237095688c107aa044ecdae22017c10df5453d4e8ab1c7e087f +A1 preset1 pref2 host2 st0 net0 gr0 x28 itrue rootfalse refused:backend 0fff0f9b5a5c798abcac5e9e45a4b1e9ef78853e36125254a4c632a042c019c7 +A1 preset1 pref2 host3 st1 net0 gr0 x29 ifalse rootfalse refused:userns,fs,mount,mount 8be69e08277c100d069085aff40d2ab82126dab82ef9e87ae8365e92cec020ea +A1 preset1 pref2 host4 st2 net0 gr1 x29 itrue rootfalse refused:userns,fs,mount,mount 8be69e08277c100d069085aff40d2ab82126dab82ef9e87ae8365e92cec020ea +A1 preset1 pref3 host0 st0 net1 gr1 x29 ifalse rootfalse refused:net,userns,fs,sockets,pid,ipc,terminal,mount,mount 446c3cf96283e3565961837defccecbe14d968b459fc226a7cbcbf43a3a51557 +A1 preset1 pref3 host1 st1 net1 gr2 x29 itrue roottrue refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +A1 preset1 pref3 host2 st2 net1 gr2 x29 ifalse rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +A1 preset1 pref3 host3 st0 net2 gr0 x30 itrue rootfalse refused:net 7d3a3727db31098b3774d3e70bbec91eacd88d87b77ea503aeeee8fcdbc0b71d +A1 preset1 pref3 host4 st1 net2 gr0 x30 ifalse rootfalse refused:net 7d3a3727db31098b3774d3e70bbec91eacd88d87b77ea503aeeee8fcdbc0b71d +A1 preset1 pref4 host0 st2 net2 gr1 x30 itrue rootfalse ok:fake 3f8ef5184501594624981e9cba2687911a1ed4887c610ad71372e5a1d6123c68 +A1 preset1 pref4 host1 st0 net3 gr1 x30 ifalse rootfalse ok:fake 8727992eae843894e1c887286f8fd49c091c7970d66695b5e65bfc300e84b3ab +A1 preset1 pref4 host2 st1 net3 gr2 x30 itrue rootfalse ok:fake ba00d47160e45ae4c12e69ae15b1c0a4e12072250ffa57e83f5ef4568d55331b +A1 preset1 pref4 host3 st2 net3 gr2 x31 ifalse roottrue refused:root e381a0087b5ffbb1d18e83ed4616873975c99162dd248c128fb89abcdf3988ed +A1 preset1 pref4 host4 st0 net0 gr0 x31 itrue rootfalse ok:home-redirect ea51f2df2b23029a9b486499833d8dfe7d280bd8f49bc95c00064c842aea57f6 +A1 preset2 pref0 host0 st1 net0 gr0 x31 ifalse rootfalse ok:bwrap e527f5f9b81d6792b79dbbb03cdef00b8e702661e7000d730705872013efb9c3 +A1 preset2 pref0 host1 st2 net0 gr1 x31 itrue rootfalse refused:storage 5b82f379c16df237095688c107aa044ecdae22017c10df5453d4e8ab1c7e087f +A1 preset2 pref0 host2 st0 net1 gr1 x31 ifalse rootfalse refused:backend 54018b28180109ba010b4a45494d558403ca2280ed486e4b892c3e69db5a99e6 +A1 preset2 pref0 host3 st1 net1 gr2 x32 itrue rootfalse refused:net,fs 3638ceea5a51eb70ab43f7569b7dd3972efe24cbb25b4a37d54b359b923d47c7 +A1 preset2 pref0 host4 st2 net1 gr2 x32 ifalse rootfalse refused:net,fs 3638ceea5a51eb70ab43f7569b7dd3972efe24cbb25b4a37d54b359b923d47c7 +A1 preset2 pref1 host0 st0 net2 gr0 x32 itrue roottrue ok:bwrap 7484852b39a41cda52901f47282055044356f972b4215dbbdd5f5b1950130aa0 +A1 preset2 pref1 host1 st1 net2 gr0 x32 ifalse rootfalse refused:backend cd9059ca6e2774ca25885318b51e01da71013254dd9e67854bbb2bbaf5e2c43d +A1 preset2 pref1 host2 st2 net2 gr1 x32 itrue rootfalse refused:backend 14cfa70315c15d5fd2b85c7b90d61d3babb932456435b060539b4bb097c65de3 +A1 preset2 pref1 host3 st0 net3 gr1 x33 ifalse rootfalse refused:net,fs 3638ceea5a51eb70ab43f7569b7dd3972efe24cbb25b4a37d54b359b923d47c7 +A1 preset2 pref1 host4 st1 net3 gr2 x33 itrue rootfalse refused:net,fs 3638ceea5a51eb70ab43f7569b7dd3972efe24cbb25b4a37d54b359b923d47c7 +A1 preset2 pref2 host0 st2 net3 gr2 x33 ifalse rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +A1 preset2 pref2 host1 st0 net0 gr0 x33 itrue rootfalse refused:net,fs,pid ce7b1275459339a24a7a912252f6ec086ccc19f25e2db8ee11f1548906afdb1e +A1 preset2 pref2 host2 st1 net0 gr0 x33 ifalse roottrue refused:backend 0fff0f9b5a5c798abcac5e9e45a4b1e9ef78853e36125254a4c632a042c019c7 +A1 preset2 pref2 host3 st2 net0 gr1 x34 itrue rootfalse refused:net,identity,fs,mount 02e2609fbf38a6dd0d33ab914d647c1f82592877f570ba5e9b90aba19dcaec21 +A1 preset2 pref2 host4 st0 net1 gr1 x34 ifalse rootfalse refused:net,identity,fs,mount 02e2609fbf38a6dd0d33ab914d647c1f82592877f570ba5e9b90aba19dcaec21 +A1 preset2 pref3 host0 st1 net1 gr2 x34 itrue rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +A1 preset2 pref3 host1 st2 net1 gr2 x34 ifalse rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +A1 preset2 pref3 host2 st0 net2 gr0 x34 itrue rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +A1 preset2 pref3 host3 st1 net2 gr0 x35 ifalse rootfalse refused:net,fs,mount,mount 4ef124741a0c7cff143d3a28608f7a5a43e1570e6a598d57f04c82412ebd16cf +A1 preset2 pref3 host4 st2 net2 gr1 x35 itrue roottrue refused:root e381a0087b5ffbb1d18e83ed4616873975c99162dd248c128fb89abcdf3988ed +A1 preset2 pref4 host0 st0 net3 gr1 x35 ifalse rootfalse refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +A1 preset2 pref4 host1 st1 net3 gr2 x35 itrue rootfalse refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +A1 preset2 pref4 host2 st2 net3 gr2 x35 ifalse rootfalse refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +A1 preset2 pref4 host3 st0 net0 gr0 x36 itrue rootfalse refused:net,fs 3638ceea5a51eb70ab43f7569b7dd3972efe24cbb25b4a37d54b359b923d47c7 +A1 preset2 pref4 host4 st1 net0 gr0 x36 ifalse rootfalse refused:net,fs 3638ceea5a51eb70ab43f7569b7dd3972efe24cbb25b4a37d54b359b923d47c7 +A1 preset3 pref0 host0 st2 net0 gr1 x36 itrue rootfalse ok:bwrap 1793c4ce81aaeaf6465732d9278f33c9e8c231b614e46962555dacac33650ed6 +A1 preset3 pref0 host1 st0 net1 gr1 x36 ifalse roottrue refused:root fd3db7c07c119e43d3792c1cc6c594cbded32d48f35542f0d71b6ab128ae8ee9 +A1 preset3 pref0 host2 st1 net1 gr2 x36 itrue rootfalse refused:backend 54018b28180109ba010b4a45494d558403ca2280ed486e4b892c3e69db5a99e6 +A1 preset3 pref0 host3 st2 net1 gr2 x37 ifalse rootfalse refused:net,identity,fs,mount 02e2609fbf38a6dd0d33ab914d647c1f82592877f570ba5e9b90aba19dcaec21 +A1 preset3 pref0 host4 st0 net2 gr0 x37 itrue rootfalse refused:net,identity,fs,mount 02e2609fbf38a6dd0d33ab914d647c1f82592877f570ba5e9b90aba19dcaec21 +A1 preset3 pref1 host0 st1 net2 gr0 x37 ifalse rootfalse ok:bwrap aa6554876c96d9863816bd9d9abb6f5fd7d383eaca727a3521d2522d05afff24 +A1 preset3 pref1 host1 st2 net2 gr1 x37 itrue rootfalse refused:backend cd9059ca6e2774ca25885318b51e01da71013254dd9e67854bbb2bbaf5e2c43d +A1 preset3 pref1 host2 st0 net3 gr1 x37 ifalse rootfalse refused:backend 14cfa70315c15d5fd2b85c7b90d61d3babb932456435b060539b4bb097c65de3 +A1 preset3 pref1 host3 st1 net3 gr2 x38 itrue roottrue refused:root e381a0087b5ffbb1d18e83ed4616873975c99162dd248c128fb89abcdf3988ed +A1 preset3 pref1 host4 st2 net3 gr2 x38 ifalse rootfalse refused:net,identity,fs 01f4011eeb0148fe010ac7e2de9f02a4415ee1af20404207f5daf950846a007c +A1 preset3 pref2 host0 st0 net0 gr0 x38 itrue rootfalse refused:net,identity,fs,pid,terminal 0cb38313e680815177c071c0555c489439dcacf394a5bad9b68a4243f805cdf6 +A1 preset3 pref2 host1 st1 net0 gr0 x38 ifalse rootfalse refused:storage 9e612a83af2724b34b46ddac0da45a1e3a31af44c5276c26592a6cebe9e2ede1 +A1 preset3 pref2 host2 st2 net0 gr1 x38 itrue rootfalse refused:backend 0fff0f9b5a5c798abcac5e9e45a4b1e9ef78853e36125254a4c632a042c019c7 +A1 preset3 pref2 host3 st0 net1 gr1 x39 ifalse rootfalse refused:net,identity,userns,fs bc6674d560eafe512acfc90c859eb411d4a8c2251dca757cd0b4d63a85f0faa6 +A1 preset3 pref2 host4 st1 net1 gr2 x39 itrue rootfalse refused:net,publish,identity,userns,fs 3ec0d1c72705508a59f6a2ac0f50b0f3a03898b043b6406a930749a811d44943 +A1 preset3 pref3 host0 st2 net1 gr2 x39 ifalse roottrue refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +A1 preset3 pref3 host1 st0 net2 gr0 x39 itrue rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +A1 preset3 pref3 host2 st1 net2 gr0 x39 ifalse rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +A1 preset3 pref3 host3 st2 net2 gr1 x40 itrue rootfalse refused:net,identity,fs,mount 02e2609fbf38a6dd0d33ab914d647c1f82592877f570ba5e9b90aba19dcaec21 +A1 preset3 pref3 host4 st0 net3 gr1 x40 ifalse rootfalse refused:net,identity,fs,mount 02e2609fbf38a6dd0d33ab914d647c1f82592877f570ba5e9b90aba19dcaec21 +A1 preset3 pref4 host0 st1 net3 gr2 x40 itrue rootfalse ok:fake 444daea2e6de86938a591c6a1c8e63ed87021a96d3b60d93f2e6fd265e3988e6 +A1 preset3 pref4 host1 st2 net3 gr2 x40 ifalse rootfalse ok:fake 1366ea04e75be9edae1046352aaddb483e603f993bd59f7f974276514e1e9d2a +A1 preset3 pref4 host2 st0 net0 gr0 x40 itrue roottrue ok:fake d5f58471e89697152ba6398dbe570a160d041baff1d982cf17eeb68ff0a4adce +A1 preset3 pref4 host3 st1 net0 gr0 x41 ifalse rootfalse refused:net,identity,fs,mount,mount ac4cb20885e43915a2c57feaafbff00d89174f47c76d76904fc374e6121a37aa +A1 preset3 pref4 host4 st2 net0 gr1 x41 itrue rootfalse refused:net,identity,fs,mount,mount ac4cb20885e43915a2c57feaafbff00d89174f47c76d76904fc374e6121a37aa +B preset0 pref0 host0 st0 net0 gr0 x0 ifalse roottrue ok:bwrap e2b5ecc72187334d9d98af1fc266688db52837a120570d00334c043873cd229c +B preset0 pref0 host0 st0 net0 gr1 x1 itrue rootfalse ok:bwrap 0b692b514075f961cae8314953d91b4d45878cfa9a14e5f24d54bb5727898197 +B preset0 pref0 host0 st0 net1 gr0 x3 ifalse rootfalse ok:bwrap cfab2675ecb0214b4694d35023d070cc069d53449b182d1c5f1b3906e401a483 +B preset0 pref0 host0 st0 net1 gr1 x4 itrue roottrue ok:bwrap 1b19ed5825bcbbaedc8518f225645199555023a6b65ee8c5ab4a808053601dc5 +B preset0 pref0 host0 st0 net2 gr0 x6 ifalse rootfalse ok:bwrap 42d7f080ef8d19c2cb993a12920ca4aa5ed20ce3e52989b588216fa895ea3775 +B preset0 pref0 host0 st0 net2 gr1 x7 itrue rootfalse ok:bwrap 3ff9ae530ed0f6105254aef34f62dc74c13cfc53dd1a145d236c6972e4f51ec1 +B preset0 pref0 host0 st0 net3 gr0 x9 ifalse rootfalse ok:bwrap 01e68075ee76b2846cde4d0821ecccf4afb98f9cee5d17f936fc19a5df829a92 +B preset0 pref0 host0 st0 net3 gr1 x10 itrue rootfalse ok:bwrap 4528eb8bd99c7394273929c12ac45063a498c8ee9e53caa2dc53636cbfd4b711 +B preset0 pref0 host0 st1 net0 gr2 x5 ifalse rootfalse refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +B preset0 pref0 host0 st1 net0 gr0 x6 itrue rootfalse ok:bwrap 29addba0be55377cd3112973ad69c74c5debabc832811b6d779842e64e556e0f +B preset0 pref0 host0 st1 net1 gr2 x8 ifalse roottrue ok:bwrap 7213cc7f09b69e7f726a360ddc87a08a16c8ac61271b418ce963badb1ed53d85 +B preset0 pref0 host0 st1 net1 gr0 x9 itrue rootfalse ok:bwrap 8d64ba6cda4ec24d578a1f2f8a8c9784e5c0b2b59a83941d7b0f1683154ef9bc +B preset0 pref0 host0 st1 net2 gr2 x11 ifalse rootfalse refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +B preset0 pref0 host0 st1 net2 gr0 x12 itrue roottrue ok:bwrap ef4a3d22d9d563fc357bfa45316ff9421b615a7941eb6ad7835443147263cdb7 +B preset0 pref0 host0 st1 net3 gr2 x1 ifalse rootfalse ok:bwrap 4362a3144a9857a2e0ddd9f018b9074f71927ae319681964cdffc9b461650eb1 +B preset0 pref0 host0 st1 net3 gr0 x2 itrue rootfalse ok:bwrap c6a221e1f711195286467713909f47a5b030c7ae2deab38ef57e231ea0b61dcf +B preset0 pref0 host0 st2 net0 gr1 x10 ifalse rootfalse ok:bwrap 9ccc5b74745e2af87f611ae97158f2c49e8728ce2f7f037f274eac60bf3e890b +B preset0 pref0 host0 st2 net0 gr2 x11 itrue rootfalse refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +B preset0 pref0 host0 st2 net1 gr1 x0 ifalse rootfalse ok:bwrap aa202e88ca87a49c22a499f875786ee98999ec36918442adddeb7901ee86f7b2 +B preset0 pref0 host0 st2 net1 gr2 x1 itrue rootfalse ok:bwrap 3fbd0ea277c4633616daf07db8e0ecbf8d50e9100dfeefdba3c46e4a13764508 +B preset0 pref0 host0 st2 net2 gr1 x3 ifalse roottrue ok:bwrap c8de253bd0ac821e102fb51a070db482a638d6e636a697137990c524d8d30eb5 +B preset0 pref0 host0 st2 net2 gr2 x4 itrue rootfalse refused:publish c61a3da4bee7febf63423606a86d686933099c6bb31acdf03150c1920054ef9b +B preset0 pref0 host0 st2 net3 gr1 x6 ifalse rootfalse ok:bwrap 20a4893d082adf6f54018a927c77a7f78dde4850161e3b7b0a3aaf891678678e +B preset0 pref0 host0 st2 net3 gr2 x7 itrue roottrue ok:bwrap 7edfb5a4a9eb327d4cfbcc4ed95f5fb79b9f21d96eb13248a31cfef1baf3d6a7 +B preset0 pref1 host0 st0 net0 gr1 x7 ifalse rootfalse ok:bwrap 940eed766d64386ed782f15692608e9f3826f08e1a284dee416ccc3321f35ec5 +B preset0 pref1 host0 st0 net0 gr2 x8 itrue roottrue ok:bwrap 43733a169f7f501e621de09303088959a8231fc0991b06b7e03f2675d489f522 +B preset0 pref1 host0 st0 net1 gr1 x10 ifalse rootfalse ok:bwrap f9687f1935e2e0d94fe5fc3df84ff56496fa5e31865a9189493c763c7f64dc72 +B preset0 pref1 host0 st0 net1 gr2 x11 itrue rootfalse refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +B preset0 pref1 host0 st0 net2 gr1 x0 ifalse rootfalse ok:bwrap ff669fcc1067d37bf54224ed1afffc82f5ac7e2bbfc6543aac4d9df5e0b24ed5 +B preset0 pref1 host0 st0 net2 gr2 x1 itrue rootfalse ok:bwrap da12616fbdd3735a4d013b24942bdcd4c99e3bd1beab8cf50ed3ff49f530d797 +B preset0 pref1 host0 st0 net3 gr1 x3 ifalse roottrue ok:bwrap b47a63da3653e0c55a1f89327766762e19502c456a6f13964a3167a8bb009685 +B preset0 pref1 host0 st0 net3 gr2 x4 itrue rootfalse refused:publish c61a3da4bee7febf63423606a86d686933099c6bb31acdf03150c1920054ef9b +B preset0 pref1 host0 st1 net0 gr0 x12 ifalse roottrue ok:bwrap e2b5ecc72187334d9d98af1fc266688db52837a120570d00334c043873cd229c +B preset0 pref1 host0 st1 net0 gr1 x0 itrue rootfalse ok:bwrap 321ca584c01abf4598ac8fa16238a1f57186af6e110d33af68c4c26c70873dac +B preset0 pref1 host0 st1 net1 gr0 x2 ifalse rootfalse ok:bwrap 7a81728599c053134e7b73d02b44c05f3ae59f18c604f093fd2403c186a6e242 +B preset0 pref1 host0 st1 net1 gr1 x3 itrue roottrue ok:bwrap 1f3317e300373c68e93aa29fe90493ee413da876352e1541fb670aa0fbad696a +B preset0 pref1 host0 st1 net2 gr0 x5 ifalse rootfalse refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +B preset0 pref1 host0 st1 net2 gr1 x6 itrue rootfalse ok:bwrap f29db8df25eab1d99338e2236b4b8dd114b05c513a10d36409f45372107d08d4 +B preset0 pref1 host0 st1 net3 gr0 x8 ifalse rootfalse ok:bwrap d2296cdf5f97aa080a0982cd7d72a67a0d073494a02a8b91b705984b6b2dd767 +B preset0 pref1 host0 st1 net3 gr1 x9 itrue rootfalse ok:bwrap 292c33bdb5e74732460afa5807f56cf7210c1b205ff2b73bdd950e554984b7c7 +B preset0 pref1 host0 st2 net0 gr2 x4 ifalse rootfalse refused:publish c61a3da4bee7febf63423606a86d686933099c6bb31acdf03150c1920054ef9b +B preset0 pref1 host0 st2 net0 gr0 x5 itrue rootfalse refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +B preset0 pref1 host0 st2 net1 gr2 x7 ifalse roottrue ok:bwrap d0a2ad3a1e570a57fb8eae264c4144fc0c6e595f2d0c2685492a72b11c0b832f +B preset0 pref1 host0 st2 net1 gr0 x8 itrue rootfalse ok:bwrap dfe62cb490c45e9f1ca597eb94874ece58830a3143a09ad284b171dbc2d1638c +B preset0 pref1 host0 st2 net2 gr2 x10 ifalse rootfalse ok:bwrap b2d6881faca2c3d9b414107db2886161ae211bcb3e13699f442ab680542f27bd +B preset0 pref1 host0 st2 net2 gr0 x11 itrue roottrue refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +B preset0 pref1 host0 st2 net3 gr2 x0 ifalse rootfalse ok:bwrap f0f2d96206c0505d8d878d9903f01d6b6a8544a4fb0681f006b6d0622f760039 +B preset0 pref1 host0 st2 net3 gr0 x1 itrue rootfalse ok:bwrap 556c8ad0ee76f5f7d93fd4e16fbb93f7019e8a83d9074c6f357f3b0b9ee1bbfb +B preset0 pref2 host0 st0 net0 gr2 x1 ifalse rootfalse ok:proot a79b715159750173a2fd77b04c64abe5555752deef7978aec0faa4a1256bf3eb +B preset0 pref2 host0 st0 net0 gr0 x2 itrue rootfalse ok:proot 342073a148641dadb70dfb8064e10ffe381f48ebaf7940ac5742917f805d2c6b +B preset0 pref2 host0 st0 net1 gr2 x4 ifalse rootfalse refused:net,publish,fs,pid,ipc,terminal,mount 23c8563bafbfa870d23a252605df72f506b9ec7124f75d3dd9dc6f9352af31a8 +B preset0 pref2 host0 st0 net1 gr0 x5 itrue rootfalse refused:net,mount,mount 73792dd964b98b36cdace71c2a2b421d70338ff0520034ff3e65680ddab1f1eb +B preset0 pref2 host0 st0 net2 gr2 x7 ifalse rootfalse refused:net de6078de5f0322bae595172d264551989f9a5fc12640fe4643b79491f7e85ad7 +B preset0 pref2 host0 st0 net2 gr0 x8 itrue rootfalse refused:net de6078de5f0322bae595172d264551989f9a5fc12640fe4643b79491f7e85ad7 +B preset0 pref2 host0 st0 net3 gr2 x10 ifalse rootfalse ok:proot 95f3abd3efcba8751f4524d13cd1fcfd62f00ba5507dea8b8262dbcf3e2b491e +B preset0 pref2 host0 st0 net3 gr0 x11 itrue rootfalse refused:net,mount,mount 73792dd964b98b36cdace71c2a2b421d70338ff0520034ff3e65680ddab1f1eb +B preset0 pref2 host0 st1 net0 gr1 x6 ifalse rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +B preset0 pref2 host0 st1 net0 gr2 x7 itrue rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +B preset0 pref2 host0 st1 net1 gr1 x9 ifalse rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +B preset0 pref2 host0 st1 net1 gr2 x10 itrue rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +B preset0 pref2 host0 st1 net2 gr1 x12 ifalse rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +B preset0 pref2 host0 st1 net2 gr2 x0 itrue rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +B preset0 pref2 host0 st1 net3 gr1 x2 ifalse rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +B preset0 pref2 host0 st1 net3 gr2 x3 itrue rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +B preset0 pref2 host0 st2 net0 gr0 x11 ifalse rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +B preset0 pref2 host0 st2 net0 gr1 x12 itrue rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +B preset0 pref2 host0 st2 net1 gr0 x1 ifalse rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +B preset0 pref2 host0 st2 net1 gr1 x2 itrue rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +B preset0 pref2 host0 st2 net2 gr0 x4 ifalse rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +B preset0 pref2 host0 st2 net2 gr1 x5 itrue rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +B preset0 pref2 host0 st2 net3 gr0 x7 ifalse rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +B preset0 pref2 host0 st2 net3 gr1 x8 itrue rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +B preset0 pref3 host0 st0 net0 gr0 x8 ifalse rootfalse ok:landlock 845453d669c912654e219d8eb7f845a75cebbc381e9690c2346cca5ed6ae40f8 +B preset0 pref3 host0 st0 net0 gr1 x9 itrue rootfalse refused:fs,sockets,pid,ipc,terminal 03921a83910b8ddeb32cb9a0708ba5b28a76e0955ec7583e7282f5476d3797ef +B preset0 pref3 host0 st0 net1 gr0 x11 ifalse rootfalse refused:net,mount,mount e8cba95263567aeddc67ee072ad99c6917c9943f2eb36d56292e2517b223fc1a +B preset0 pref3 host0 st0 net1 gr1 x12 itrue rootfalse ok:landlock fdb608dc335a4ada5f59c35551b64a1d42074b09f17040f04bd492398d63f7dd +B preset0 pref3 host0 st0 net2 gr0 x1 ifalse rootfalse refused:net,mount 023d7daace7060f780b4ebcc928a35d1d2b1f319ba691aeaa46e3d3bfdd357e0 +B preset0 pref3 host0 st0 net2 gr1 x2 itrue rootfalse refused:net 49ce79fb0a6bc74c4ff2730b5ba1e4f4d24161361d2098f1b2cabd62ee2b4ea2 +B preset0 pref3 host0 st0 net3 gr0 x4 ifalse rootfalse refused:net,fs,sockets,pid,ipc,terminal,mount cd3a1f8f1f8c753df1df032f823cf8491512b09e3b36b29aaa64a886cf38eb58 +B preset0 pref3 host0 st0 net3 gr1 x5 itrue rootfalse refused:net,mount,mount e8cba95263567aeddc67ee072ad99c6917c9943f2eb36d56292e2517b223fc1a +B preset0 pref3 host0 st1 net0 gr2 x0 ifalse rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +B preset0 pref3 host0 st1 net0 gr0 x1 itrue rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +B preset0 pref3 host0 st1 net1 gr2 x3 ifalse rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +B preset0 pref3 host0 st1 net1 gr0 x4 itrue rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +B preset0 pref3 host0 st1 net2 gr2 x6 ifalse rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +B preset0 pref3 host0 st1 net2 gr0 x7 itrue rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +B preset0 pref3 host0 st1 net3 gr2 x9 ifalse rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +B preset0 pref3 host0 st1 net3 gr0 x10 itrue rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +B preset0 pref3 host0 st2 net0 gr1 x5 ifalse rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +B preset0 pref3 host0 st2 net0 gr2 x6 itrue rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +B preset0 pref3 host0 st2 net1 gr1 x8 ifalse rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +B preset0 pref3 host0 st2 net1 gr2 x9 itrue rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +B preset0 pref3 host0 st2 net2 gr1 x11 ifalse rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +B preset0 pref3 host0 st2 net2 gr2 x12 itrue rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +B preset0 pref3 host0 st2 net3 gr1 x1 ifalse rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +B preset0 pref3 host0 st2 net3 gr2 x2 itrue rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +B preset1 pref0 host0 st0 net0 gr1 x5 ifalse rootfalse refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +B preset1 pref0 host0 st0 net0 gr2 x6 itrue roottrue ok:bwrap 3e207e538404c500b385da4d41e81527bacdf64fda9b36ab21531f5639887742 +B preset1 pref0 host0 st0 net1 gr1 x8 ifalse rootfalse ok:bwrap 74942c8153af0ffeccf6ee94bfe7d47e79634986b7bd9e11b1ec7b216e2f5bdd +B preset1 pref0 host0 st0 net1 gr2 x9 itrue rootfalse ok:bwrap 01d60992a943d8c00b4fd06c9eea6dddabf987b6a63a7762324c4c0117bc23e4 +B preset1 pref0 host0 st0 net2 gr1 x11 ifalse rootfalse refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +B preset1 pref0 host0 st0 net2 gr2 x12 itrue rootfalse ok:bwrap 2064d6873f880cf448257855cab3bad56e7365d01164d591c17c01cca99372fe +B preset1 pref0 host0 st0 net3 gr1 x1 ifalse roottrue ok:bwrap a47ce927f1b3f23b70c335b400d87739144311e4376210f0332b1af85ae3b24b +B preset1 pref0 host0 st0 net3 gr2 x2 itrue rootfalse ok:bwrap 702f0a3567b54917c5fcff977f04ee9fca9a923c6cb7ff511f4d77784335865b +B preset1 pref0 host0 st1 net0 gr0 x10 ifalse roottrue ok:bwrap 2af22d41b92598bfd57c94c04fce825b95f72ce9edd7271481b17cca25a23027 +B preset1 pref0 host0 st1 net0 gr1 x11 itrue rootfalse refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +B preset1 pref0 host0 st1 net1 gr0 x0 ifalse rootfalse ok:bwrap f35836a11e56961d8fdb54c94b3b94dbfbe82ea4844a1c66cf0928e4660a7b0f +B preset1 pref0 host0 st1 net1 gr1 x1 itrue roottrue ok:bwrap 1b19ed5825bcbbaedc8518f225645199555023a6b65ee8c5ab4a808053601dc5 +B preset1 pref0 host0 st1 net2 gr0 x3 ifalse rootfalse ok:bwrap 14c51b7041ae9c227e9d04cd6de8a73e5618bec10b831365cf7178b956d19971 +B preset1 pref0 host0 st1 net2 gr1 x4 itrue rootfalse ok:bwrap 6b155696f4424ab7e7f7871b2524a8c090c757aecbd418d49dce51ac096b539d +B preset1 pref0 host0 st1 net3 gr0 x6 ifalse rootfalse ok:bwrap d164e55045bf1ed7b0815854d5e5d45143aeea4aab1ab0c2f9bc4be75671d7c1 +B preset1 pref0 host0 st1 net3 gr1 x7 itrue rootfalse ok:bwrap 3b86abd5fd5ce5b62a0756cf8aefc874f1e19c3a332d69b687118774d9ddc827 +B preset1 pref0 host0 st2 net0 gr2 x2 ifalse rootfalse ok:bwrap 411fbc96b56760b44cbae40bf82bd35468571093ab10df901944ec8665d9d32b +B preset1 pref0 host0 st2 net0 gr0 x3 itrue rootfalse ok:bwrap f00012f697326235a79fd65c19ae7c28efd5cd48c9cd2d0fb8fd32a6a7978abb +B preset1 pref0 host0 st2 net1 gr2 x5 ifalse roottrue refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +B preset1 pref0 host0 st2 net1 gr0 x6 itrue rootfalse ok:bwrap c8382d97929fe90163992e421a30893446c77c1e7936d4f8c2c6cd6dd3f9e6fb +B preset1 pref0 host0 st2 net2 gr2 x8 ifalse rootfalse ok:bwrap 953d812db2bdb931dd2ff27181f729641fca7a8b47031937c0f6e2b0db1bbf55 +B preset1 pref0 host0 st2 net2 gr0 x9 itrue roottrue ok:bwrap ef4a3d22d9d563fc357bfa45316ff9421b615a7941eb6ad7835443147263cdb7 +B preset1 pref0 host0 st2 net3 gr2 x11 ifalse rootfalse refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +B preset1 pref0 host0 st2 net3 gr0 x12 itrue rootfalse ok:bwrap 46b19228eccecc2c00106f474d3f1f184232a9b2a46345d4b802d105921eaacd +B preset1 pref1 host0 st0 net0 gr2 x12 ifalse rootfalse ok:bwrap cd798d7a42c47f4a40c247d39980d4c1321d9cea9acc449fafc3ba69fa101a9f +B preset1 pref1 host0 st0 net0 gr0 x0 itrue rootfalse ok:bwrap c388e3c3607d47df90c7ccbd2fd7431622b1ff1d119ca87150fc91238e177258 +B preset1 pref1 host0 st0 net1 gr2 x2 ifalse rootfalse ok:bwrap 0708af6b68bfd79924bc309d7e3f0ff6e14e11723a257cd22d9ae84ac6430811 +B preset1 pref1 host0 st0 net1 gr0 x3 itrue rootfalse ok:bwrap 0b1d2fc3b360055b4ddbd096e21d52f5733808ba6022c3245f108bfc811534e8 +B preset1 pref1 host0 st0 net2 gr2 x5 ifalse roottrue refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +B preset1 pref1 host0 st0 net2 gr0 x6 itrue rootfalse ok:bwrap 53a16877f348f7840f42d2679c7ab68db95fac12b897ed3b6bd7cdd8a03af34c +B preset1 pref1 host0 st0 net3 gr2 x8 ifalse rootfalse ok:bwrap 7cbd97670e28369b4e55f5eb478d77e3085b0aa5844424f24e2929b60f7dbbe5 +B preset1 pref1 host0 st0 net3 gr0 x9 itrue roottrue ok:bwrap 94cafe6b882cb010da6fecbb0f2e81fa965d603502a455b493d62e3517ee4fb3 +B preset1 pref1 host0 st1 net0 gr1 x4 ifalse rootfalse ok:bwrap a1bee8f75088a892ae419b69691602af5dffda198e74c5ee93a30835dd88f8e6 +B preset1 pref1 host0 st1 net0 gr2 x5 itrue roottrue refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +B preset1 pref1 host0 st1 net1 gr1 x7 ifalse rootfalse ok:bwrap 2da4c47dbc85948c51af58b0a415ecfc75bec442fabc35eca562fa68c35994bf +B preset1 pref1 host0 st1 net1 gr2 x8 itrue rootfalse ok:bwrap 6de1242f89aace3f3fab8cf15a4c42c1bb3eede1fd30b1d87dc6283672d85097 +B preset1 pref1 host0 st1 net2 gr1 x10 ifalse rootfalse ok:bwrap 53c65e29d12aac5633fb000f90d1fc5a8955831d5816dda7c1217f3462b74d85 +B preset1 pref1 host0 st1 net2 gr2 x11 itrue rootfalse refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +B preset1 pref1 host0 st1 net3 gr1 x0 ifalse roottrue ok:bwrap b47a63da3653e0c55a1f89327766762e19502c456a6f13964a3167a8bb009685 +B preset1 pref1 host0 st1 net3 gr2 x1 itrue rootfalse ok:bwrap 940047a10eb3b98e40a1547661aaec8d537f1062940927822815356ad63d113b +B preset1 pref1 host0 st2 net0 gr0 x9 ifalse roottrue ok:bwrap e2b5ecc72187334d9d98af1fc266688db52837a120570d00334c043873cd229c +B preset1 pref1 host0 st2 net0 gr1 x10 itrue rootfalse ok:bwrap f98e2a446a39fe8a5b02877e19ccaf347a422dc2e7709bcd79902eabc5378275 +B preset1 pref1 host0 st2 net1 gr0 x12 ifalse rootfalse ok:bwrap 752dd0420da12da5893d8b2714f698ecb87f4a0f7fa41b9b87e365473fe557d6 +B preset1 pref1 host0 st2 net1 gr1 x0 itrue roottrue ok:bwrap 1f3317e300373c68e93aa29fe90493ee413da876352e1541fb670aa0fbad696a +B preset1 pref1 host0 st2 net2 gr0 x2 ifalse rootfalse ok:bwrap d2df8d21865c609d6f528352e2bcaff09c88919286dc7d9ac813f8a7fae2e290 +B preset1 pref1 host0 st2 net2 gr1 x3 itrue rootfalse ok:bwrap e750457e6a16ccef1870ce132af7f46d7ec3b960f90720744ff7f675a872d702 +B preset1 pref1 host0 st2 net3 gr0 x5 ifalse rootfalse refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +B preset1 pref1 host0 st2 net3 gr1 x6 itrue rootfalse ok:bwrap 9f7841a9095f9e4d1943a42780d4748f5dd8f74c7dec434cae4a0f83bacb4016 +B preset1 pref2 host0 st0 net0 gr0 x6 ifalse rootfalse ok:proot dec532564d0901c188135cd26121d314ef98af7a0f41239dac675125188094be +B preset1 pref2 host0 st0 net0 gr1 x7 itrue rootfalse ok:proot 1c40e89795da8bad584c851f2862698022a13cb3647f0b64291593c58c974617 +B preset1 pref2 host0 st0 net1 gr0 x9 ifalse rootfalse refused:net,fs,pid,ipc,terminal 9484adea3e4dadefbbd3cb94033792cc02ace53c16dba9c033fbfbbf3a7f2da2 +B preset1 pref2 host0 st0 net1 gr1 x10 itrue rootfalse ok:proot 1e392697f557cede7f772ba1489af57daab5aca556f76675310839ec1f690e7e +B preset1 pref2 host0 st0 net2 gr0 x12 ifalse rootfalse refused:net de6078de5f0322bae595172d264551989f9a5fc12640fe4643b79491f7e85ad7 +B preset1 pref2 host0 st0 net2 gr1 x0 itrue rootfalse refused:net de6078de5f0322bae595172d264551989f9a5fc12640fe4643b79491f7e85ad7 +B preset1 pref2 host0 st0 net3 gr0 x2 ifalse rootfalse refused:net de6078de5f0322bae595172d264551989f9a5fc12640fe4643b79491f7e85ad7 +B preset1 pref2 host0 st0 net3 gr1 x3 itrue rootfalse ok:proot 1de240b3a73a1659d65e24e37eb5d062853d6f00461dd721286fd27238292c40 +B preset1 pref2 host0 st1 net0 gr2 x11 ifalse rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +B preset1 pref2 host0 st1 net0 gr0 x12 itrue rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +B preset1 pref2 host0 st1 net1 gr2 x1 ifalse rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +B preset1 pref2 host0 st1 net1 gr0 x2 itrue rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +B preset1 pref2 host0 st1 net2 gr2 x4 ifalse rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +B preset1 pref2 host0 st1 net2 gr0 x5 itrue rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +B preset1 pref2 host0 st1 net3 gr2 x7 ifalse rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +B preset1 pref2 host0 st1 net3 gr0 x8 itrue rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +B preset1 pref2 host0 st2 net0 gr1 x3 ifalse rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +B preset1 pref2 host0 st2 net0 gr2 x4 itrue rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +B preset1 pref2 host0 st2 net1 gr1 x6 ifalse rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +B preset1 pref2 host0 st2 net1 gr2 x7 itrue rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +B preset1 pref2 host0 st2 net2 gr1 x9 ifalse rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +B preset1 pref2 host0 st2 net2 gr2 x10 itrue rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +B preset1 pref2 host0 st2 net3 gr1 x12 ifalse rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +B preset1 pref2 host0 st2 net3 gr2 x0 itrue rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +B preset1 pref3 host0 st0 net0 gr1 x0 ifalse rootfalse ok:landlock 78b6e40c72acb2204d0b66b2c8054a9502b414f7e3354c6a5bcc9f20ab42d0f6 +B preset1 pref3 host0 st0 net0 gr2 x1 itrue rootfalse refused:mount ce1263420473a863261655f412c6cc203ce5466be206cbdc5e6f057c590f5259 +B preset1 pref3 host0 st0 net1 gr1 x3 ifalse rootfalse ok:landlock 7023b9a694f2bc612b8909fa0ff643d4ed1ca29c978d95478a9a3b5a67fc0eab +B preset1 pref3 host0 st0 net1 gr2 x4 itrue rootfalse refused:net,publish,fs,sockets,pid,ipc,terminal,mount 9977268c54320c1330408d319b852896102ff73e2377efb9c76c8e7e748c9127 +B preset1 pref3 host0 st0 net2 gr1 x6 ifalse rootfalse refused:net 49ce79fb0a6bc74c4ff2730b5ba1e4f4d24161361d2098f1b2cabd62ee2b4ea2 +B preset1 pref3 host0 st0 net2 gr2 x7 itrue rootfalse refused:net,mount 023d7daace7060f780b4ebcc928a35d1d2b1f319ba691aeaa46e3d3bfdd357e0 +B preset1 pref3 host0 st0 net3 gr1 x9 ifalse rootfalse refused:net,fs,sockets,pid,ipc,terminal dfbb829e0bf1fbef92ad3bf473137b907c6bea375c6e37bbf3effbfd8bfa7488 +B preset1 pref3 host0 st0 net3 gr2 x10 itrue rootfalse refused:mount ce1263420473a863261655f412c6cc203ce5466be206cbdc5e6f057c590f5259 +B preset1 pref3 host0 st1 net0 gr0 x5 ifalse rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +B preset1 pref3 host0 st1 net0 gr1 x6 itrue rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +B preset1 pref3 host0 st1 net1 gr0 x8 ifalse rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +B preset1 pref3 host0 st1 net1 gr1 x9 itrue rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +B preset1 pref3 host0 st1 net2 gr0 x11 ifalse rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +B preset1 pref3 host0 st1 net2 gr1 x12 itrue rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +B preset1 pref3 host0 st1 net3 gr0 x1 ifalse rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +B preset1 pref3 host0 st1 net3 gr1 x2 itrue rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +B preset1 pref3 host0 st2 net0 gr2 x10 ifalse rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +B preset1 pref3 host0 st2 net0 gr0 x11 itrue rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +B preset1 pref3 host0 st2 net1 gr2 x0 ifalse rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +B preset1 pref3 host0 st2 net1 gr0 x1 itrue rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +B preset1 pref3 host0 st2 net2 gr2 x3 ifalse rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +B preset1 pref3 host0 st2 net2 gr0 x4 itrue rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +B preset1 pref3 host0 st2 net3 gr2 x6 ifalse rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +B preset1 pref3 host0 st2 net3 gr0 x7 itrue rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +B preset2 pref0 host0 st0 net0 gr2 x10 ifalse rootfalse ok:bwrap d5d971223078627a8e8090f397994e8d77a2a6d67ca0cd71992df8ce4034bb29 +B preset2 pref0 host0 st0 net0 gr0 x11 itrue rootfalse refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +B preset2 pref0 host0 st0 net1 gr2 x0 ifalse rootfalse ok:bwrap 6fc1fac46eab524380e677216de35c9d4bf6c64396376b089e257eb68c398192 +B preset2 pref0 host0 st0 net1 gr0 x1 itrue rootfalse ok:bwrap 9bd8097a29b9e31dcb56e7c85534f3ac86f28746357a859eef49320f4e93c2d1 +B preset2 pref0 host0 st0 net2 gr2 x3 ifalse roottrue ok:bwrap bdf528d4eeb58e838c43961637378fc47668425834ecb539ff091def1ccd754b +B preset2 pref0 host0 st0 net2 gr0 x4 itrue rootfalse ok:bwrap 94b1ab9e131eb3be74c5920961cd6889cc27b12b90447e845efa9ee1f3c34e68 +B preset2 pref0 host0 st0 net3 gr2 x6 ifalse rootfalse ok:bwrap 42678194cf87fe6d06e0f82925e3cac1a1db5742d546686435c4268e92687473 +B preset2 pref0 host0 st0 net3 gr0 x7 itrue roottrue ok:bwrap e67e732567f871f79a56474c293dd0be48dc9c827a3a2c805fca3014bdb0e031 +B preset2 pref0 host0 st1 net0 gr1 x2 ifalse rootfalse ok:bwrap 0792b25057439a32dd53fb5b44dc85fd2f2568b55cf1a9be0e551530b73b21b3 +B preset2 pref0 host0 st1 net0 gr2 x3 itrue roottrue ok:bwrap 6e192db776bb816806906f684008c38b7e7952cf6d2ce9a84edff3c5b450968e +B preset2 pref0 host0 st1 net1 gr1 x5 ifalse rootfalse refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +B preset2 pref0 host0 st1 net1 gr2 x6 itrue rootfalse ok:bwrap a3032d5747c2f46a5b9d275e4b1d3c94e421bad532b342f1af04cd656acc9387 +B preset2 pref0 host0 st1 net2 gr1 x8 ifalse rootfalse ok:bwrap e6ddf14f34ae482ae863f581e93c53f1fceff6dcc712406b2da1b867a675e719 +B preset2 pref0 host0 st1 net2 gr2 x9 itrue rootfalse refused:publish c61a3da4bee7febf63423606a86d686933099c6bb31acdf03150c1920054ef9b +B preset2 pref0 host0 st1 net3 gr1 x11 ifalse roottrue refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +B preset2 pref0 host0 st1 net3 gr2 x12 itrue rootfalse ok:bwrap abd96d33570dafe759eeca40622a97f4fdf84c3e087dcdf7ec80a84f109a6c1c +B preset2 pref0 host0 st2 net0 gr0 x7 ifalse roottrue ok:bwrap 29602a809c5fba83c9a4b54e6ffe7b35df13aac2b7074fcc67586386a2f0121b +B preset2 pref0 host0 st2 net0 gr1 x8 itrue rootfalse ok:bwrap 6fae6dd327084e000fa783109b8e77bfc50089529388b13f6d46828279f6a5eb +B preset2 pref0 host0 st2 net1 gr0 x10 ifalse rootfalse ok:bwrap fe9da212299d636fbb032173a404492bd9a8a7ae950f2d41a3fe38d9f3be82af +B preset2 pref0 host0 st2 net1 gr1 x11 itrue roottrue refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +B preset2 pref0 host0 st2 net2 gr0 x0 ifalse rootfalse ok:bwrap 446baa8d983bfcc6210963a6d7666a5057c6f1dd1bccf13aa347f5ff65800282 +B preset2 pref0 host0 st2 net2 gr1 x1 itrue rootfalse ok:bwrap ee2590474c982dc14449be182d73d2d463b35a0902f2788ed393fc05cc51baae +B preset2 pref0 host0 st2 net3 gr0 x3 ifalse rootfalse ok:bwrap bf1eaf5dde8c729fe67e0a989c2dcc8af995d3abe040a262b390d4f91ff78722 +B preset2 pref0 host0 st2 net3 gr1 x4 itrue rootfalse ok:bwrap 2f36130155f71557536953818fb34d3b649af9a60281df812cc8c838db48a6fd +B preset2 pref1 host0 st0 net0 gr0 x4 ifalse rootfalse ok:bwrap 415c64469f2f0c8b69ecfcb105f766aa6abd4665ea0164cd187bed226abaf2eb +B preset2 pref1 host0 st0 net0 gr1 x5 itrue rootfalse refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +B preset2 pref1 host0 st0 net1 gr0 x7 ifalse roottrue ok:bwrap 29602a809c5fba83c9a4b54e6ffe7b35df13aac2b7074fcc67586386a2f0121b +B preset2 pref1 host0 st0 net1 gr1 x8 itrue rootfalse ok:bwrap e9ec16412385006a582476ebf39c7a4fcec1b6aaedcb11662645105b36888c2b +B preset2 pref1 host0 st0 net2 gr0 x10 ifalse rootfalse ok:bwrap 6735b2d40018328d7390bf2b94869ebda8e66f3f55ad1f48780bbbae74b1ba89 +B preset2 pref1 host0 st0 net2 gr1 x11 itrue roottrue refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +B preset2 pref1 host0 st0 net3 gr0 x0 ifalse rootfalse ok:bwrap d34e1c19f51f8452fdfb787546455ab24954e12393945e69774365d7d3659c1b +B preset2 pref1 host0 st0 net3 gr1 x1 itrue rootfalse ok:bwrap 31f4e5f4682192e93def2b14f3538ac3993ca155703cc4e9cb55f684bfe68967 +B preset2 pref1 host0 st1 net0 gr2 x9 ifalse rootfalse ok:bwrap 5563d6b31a1331025f3413ee953dbddb45e770a9783765706bab47f0e4e9a9ad +B preset2 pref1 host0 st1 net0 gr0 x10 itrue rootfalse ok:bwrap 179f6452559128f1977e2c129c15353e70244f8da0a24dff0c1399fa35f1afab +B preset2 pref1 host0 st1 net1 gr2 x12 ifalse rootfalse ok:bwrap 5563d6b31a1331025f3413ee953dbddb45e770a9783765706bab47f0e4e9a9ad +B preset2 pref1 host0 st1 net1 gr0 x0 itrue rootfalse ok:bwrap bfcfbdbc0f02b1c5b261c5c8ccbd719ce01b491997e8ffa2c328dd5808f56065 +B preset2 pref1 host0 st1 net2 gr2 x2 ifalse roottrue ok:bwrap c74024b0d6e0899a5e945090589917a3ee542332d9df3325f9d91da7bf0773eb +B preset2 pref1 host0 st1 net2 gr0 x3 itrue rootfalse ok:bwrap 88828bd2b492b8e5c8a836d4efa0daca68d7abc5ae3ffb21378c4deef194566a +B preset2 pref1 host0 st1 net3 gr2 x5 ifalse rootfalse refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +B preset2 pref1 host0 st1 net3 gr0 x6 itrue roottrue ok:bwrap 3d94cdc2e10229928d48ff80fcd2875d0fbb82b03efb20a4584c284b33ae94e6 +B preset2 pref1 host0 st2 net0 gr1 x1 ifalse rootfalse ok:bwrap 31f18f86bd8d28d11786c30523034d084e7294aeef55537e2df92eceff2af38c +B preset2 pref1 host0 st2 net0 gr2 x2 itrue roottrue ok:bwrap 137037338d038f01a86840d9aa02fbade7ec426730c6c2c673366b359db90afb +B preset2 pref1 host0 st2 net1 gr1 x4 ifalse rootfalse ok:bwrap 31f18f86bd8d28d11786c30523034d084e7294aeef55537e2df92eceff2af38c +B preset2 pref1 host0 st2 net1 gr2 x5 itrue rootfalse refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +B preset2 pref1 host0 st2 net2 gr1 x7 ifalse rootfalse ok:bwrap e87a01e3625640e66c55e96bb1626b89f2ae8a5b1598b4d737a8b8e211795bda +B preset2 pref1 host0 st2 net2 gr2 x8 itrue rootfalse ok:bwrap 6dec2655cb26eec96c197c563bfeed87742d3e786519bedc934c49419c3533b7 +B preset2 pref1 host0 st2 net3 gr1 x10 ifalse roottrue ok:bwrap 428cc61356a21932aa2e56936acb040e4f01167d4ac13b180f5f88f88b112c78 +B preset2 pref1 host0 st2 net3 gr2 x11 itrue rootfalse refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +B preset2 pref2 host0 st0 net0 gr1 x11 ifalse rootfalse refused:net,fs,pid,mount,mount 64b81e51feaaee1eb2d254afe811550861cf5e08fb8ae929d0e0166bbe02594d +B preset2 pref2 host0 st0 net0 gr2 x12 itrue rootfalse refused:net,fs,pid ce7b1275459339a24a7a912252f6ec086ccc19f25e2db8ee11f1548906afdb1e +B preset2 pref2 host0 st0 net1 gr1 x1 ifalse rootfalse refused:net,fs,pid,mount 6bed5de033d25cec93df90e9ccaa926722e45d9a4a95383f21f6e85cb6980550 +B preset2 pref2 host0 st0 net1 gr2 x2 itrue rootfalse refused:net,fs,pid ce7b1275459339a24a7a912252f6ec086ccc19f25e2db8ee11f1548906afdb1e +B preset2 pref2 host0 st0 net2 gr1 x4 ifalse rootfalse refused:net,identity,fs,pid,ipc,terminal,mount daa950c7c2738301d694e3eec4df22c9c7620b238e1021dccb1af48d990f282e +B preset2 pref2 host0 st0 net2 gr2 x5 itrue rootfalse refused:net,fs,pid,mount,mount 64b81e51feaaee1eb2d254afe811550861cf5e08fb8ae929d0e0166bbe02594d +B preset2 pref2 host0 st0 net3 gr1 x7 ifalse rootfalse refused:net,fs,pid,mount 6bed5de033d25cec93df90e9ccaa926722e45d9a4a95383f21f6e85cb6980550 +B preset2 pref2 host0 st0 net3 gr2 x8 itrue rootfalse refused:net,fs,pid ce7b1275459339a24a7a912252f6ec086ccc19f25e2db8ee11f1548906afdb1e +B preset2 pref2 host0 st1 net0 gr0 x3 ifalse rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +B preset2 pref2 host0 st1 net0 gr1 x4 itrue rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +B preset2 pref2 host0 st1 net1 gr0 x6 ifalse rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +B preset2 pref2 host0 st1 net1 gr1 x7 itrue rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +B preset2 pref2 host0 st1 net2 gr0 x9 ifalse rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +B preset2 pref2 host0 st1 net2 gr1 x10 itrue rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +B preset2 pref2 host0 st1 net3 gr0 x12 ifalse rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +B preset2 pref2 host0 st1 net3 gr1 x0 itrue rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +B preset2 pref2 host0 st2 net0 gr2 x8 ifalse rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +B preset2 pref2 host0 st2 net0 gr0 x9 itrue rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +B preset2 pref2 host0 st2 net1 gr2 x11 ifalse rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +B preset2 pref2 host0 st2 net1 gr0 x12 itrue rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +B preset2 pref2 host0 st2 net2 gr2 x1 ifalse rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +B preset2 pref2 host0 st2 net2 gr0 x2 itrue rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +B preset2 pref2 host0 st2 net3 gr2 x4 ifalse rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +B preset2 pref2 host0 st2 net3 gr0 x5 itrue rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +B preset2 pref3 host0 st0 net0 gr2 x5 ifalse rootfalse refused:net,fs,pid,mount,mount 7a21373a9f440dfbb4ca7260c02f00355708df5a3595a43f02d7ee4c03c5cc1e +B preset2 pref3 host0 st0 net0 gr0 x6 itrue rootfalse refused:net,fs,pid 435ea718621a2ba23751c9112d730b4d9b311acf484aa33382feffb1d040b1c2 +B preset2 pref3 host0 st0 net1 gr2 x8 ifalse rootfalse refused:net,fs,pid 435ea718621a2ba23751c9112d730b4d9b311acf484aa33382feffb1d040b1c2 +B preset2 pref3 host0 st0 net1 gr0 x9 itrue rootfalse refused:net,identity,fs,sockets,pid,ipc,terminal 5ca344e8e44dc080d9376fb6170ed994d90d754c58f6e5e12b35a06d8486ec19 +B preset2 pref3 host0 st0 net2 gr2 x11 ifalse rootfalse refused:net,fs,pid,mount,mount 7a21373a9f440dfbb4ca7260c02f00355708df5a3595a43f02d7ee4c03c5cc1e +B preset2 pref3 host0 st0 net2 gr0 x12 itrue rootfalse refused:net,fs,pid 435ea718621a2ba23751c9112d730b4d9b311acf484aa33382feffb1d040b1c2 +B preset2 pref3 host0 st0 net3 gr2 x1 ifalse rootfalse refused:net,fs,pid,mount 6ec15a8bcb5b81627dbe532064d981d5babe0f4a7513c5344f2b8811bc9df83d +B preset2 pref3 host0 st0 net3 gr0 x2 itrue rootfalse refused:net,fs,pid 435ea718621a2ba23751c9112d730b4d9b311acf484aa33382feffb1d040b1c2 +B preset2 pref3 host0 st1 net0 gr1 x10 ifalse rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +B preset2 pref3 host0 st1 net0 gr2 x11 itrue rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +B preset2 pref3 host0 st1 net1 gr1 x0 ifalse rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +B preset2 pref3 host0 st1 net1 gr2 x1 itrue rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +B preset2 pref3 host0 st1 net2 gr1 x3 ifalse rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +B preset2 pref3 host0 st1 net2 gr2 x4 itrue rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +B preset2 pref3 host0 st1 net3 gr1 x6 ifalse rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +B preset2 pref3 host0 st1 net3 gr2 x7 itrue rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +B preset2 pref3 host0 st2 net0 gr0 x2 ifalse rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +B preset2 pref3 host0 st2 net0 gr1 x3 itrue rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +B preset2 pref3 host0 st2 net1 gr0 x5 ifalse rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +B preset2 pref3 host0 st2 net1 gr1 x6 itrue rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +B preset2 pref3 host0 st2 net2 gr0 x8 ifalse rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +B preset2 pref3 host0 st2 net2 gr1 x9 itrue rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +B preset2 pref3 host0 st2 net3 gr0 x11 ifalse rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +B preset2 pref3 host0 st2 net3 gr1 x12 itrue rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +B preset3 pref0 host0 st0 net0 gr0 x2 ifalse rootfalse ok:bwrap 1e893890dd6d7b0c742cf387e9761269d5ec49c07a6ca25914a5d0df6e37818f +B preset3 pref0 host0 st0 net0 gr1 x3 itrue rootfalse ok:bwrap 910b3fbb5b9693245bd395e616fa41bf32d520210f97fa7e0c1b54d7af699600 +B preset3 pref0 host0 st0 net1 gr0 x5 ifalse roottrue refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +B preset3 pref0 host0 st0 net1 gr1 x6 itrue rootfalse ok:bwrap 9e4c90b3e7dd0759bd20ce416acc40f0ac4805ea0867fc4cefa0cb5537575ad7 +B preset3 pref0 host0 st0 net2 gr0 x8 ifalse rootfalse ok:bwrap b143567975d3b6803855c9dc8e0327a4a003ce3c3c58b878cc1d5af68ec78270 +B preset3 pref0 host0 st0 net2 gr1 x9 itrue roottrue ok:bwrap a8e1c18dc23465eeea799edfc81067ca6fa7e6044eff6b24d0ab4df702ebf6fb +B preset3 pref0 host0 st0 net3 gr0 x11 ifalse rootfalse refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +B preset3 pref0 host0 st0 net3 gr1 x12 itrue rootfalse ok:bwrap 910b3fbb5b9693245bd395e616fa41bf32d520210f97fa7e0c1b54d7af699600 +B preset3 pref0 host0 st1 net0 gr2 x7 ifalse rootfalse ok:bwrap a66309aaad6f2ef40b4ab9aab359f5c693923bd421a04ac46121b4c8b8703c3e +B preset3 pref0 host0 st1 net0 gr0 x8 itrue rootfalse ok:bwrap b1806d7cf6502b95c225dd969d8bda9779dcd5fd7eb6b4bcd74223e470f29daa +B preset3 pref0 host0 st1 net1 gr2 x10 ifalse rootfalse ok:bwrap c1c6812de698742ff0a1c7e44541c57e91971c4c23430984731aa8d2cc7dbc6d +B preset3 pref0 host0 st1 net1 gr0 x11 itrue rootfalse refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +B preset3 pref0 host0 st1 net2 gr2 x0 ifalse roottrue ok:bwrap c74024b0d6e0899a5e945090589917a3ee542332d9df3325f9d91da7bf0773eb +B preset3 pref0 host0 st1 net2 gr0 x1 itrue rootfalse ok:bwrap 423663c761cf0cee970665e4625aaaded603fee49ab9a0d757075356aa421dd2 +B preset3 pref0 host0 st1 net3 gr2 x3 ifalse rootfalse ok:bwrap 8bdf7a8efbde909ffeb1e0d1c64ddeaa97c242fe0c65e8fa412068a506264a66 +B preset3 pref0 host0 st1 net3 gr0 x4 itrue roottrue ok:bwrap 505587f848cb08bca89913c93ac76beffcb48527a875deced59e84d69e021499 +B preset3 pref0 host0 st2 net0 gr1 x12 ifalse rootfalse ok:bwrap 2899d132c3ff39618657c618bf1a2aaaa7dace21923dde2acde8285af0604b3b +B preset3 pref0 host0 st2 net0 gr2 x0 itrue roottrue ok:bwrap ce2ce73d89a800b187a3822772b7a7f26b5262e7bd4351fc93fb5f1eb51a9f05 +B preset3 pref0 host0 st2 net1 gr1 x2 ifalse rootfalse ok:bwrap a4f6e3f7e14be17e614d770a4c2e0292caaba5b0d06c20d1afe91941a7919621 +B preset3 pref0 host0 st2 net1 gr2 x3 itrue rootfalse ok:bwrap 8739b1b80302a4e9b76d262760159d3af5e3ee82fcb6ee2aa30a51e0fe67250e +B preset3 pref0 host0 st2 net2 gr1 x5 ifalse rootfalse refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +B preset3 pref0 host0 st2 net2 gr2 x6 itrue rootfalse ok:bwrap 6dec2655cb26eec96c197c563bfeed87742d3e786519bedc934c49419c3533b7 +B preset3 pref0 host0 st2 net3 gr1 x8 ifalse roottrue ok:bwrap 4736ddbbfe17b42ead10e3aa94f499fa4cae5e55e60b14414b3c25b73b3a3e5f +B preset3 pref0 host0 st2 net3 gr2 x9 itrue rootfalse refused:publish c61a3da4bee7febf63423606a86d686933099c6bb31acdf03150c1920054ef9b +B preset3 pref1 host0 st0 net0 gr1 x9 ifalse roottrue ok:bwrap 4736ddbbfe17b42ead10e3aa94f499fa4cae5e55e60b14414b3c25b73b3a3e5f +B preset3 pref1 host0 st0 net0 gr2 x10 itrue rootfalse ok:bwrap 3f82ee79570b3954f8aae17e0b94d1c5d61ba1ad395e74f95c8b1366abe8f377 +B preset3 pref1 host0 st0 net1 gr1 x12 ifalse rootfalse ok:bwrap 1c5c0ef7ad34962601a0979325654884949cc34478bdb7e30c84924c81e0cb3c +B preset3 pref1 host0 st0 net1 gr2 x0 itrue roottrue ok:bwrap e529e3d9e0096840b2b7411d383c171a413fdb1e1020e03e5091f3d718607671 +B preset3 pref1 host0 st0 net2 gr1 x2 ifalse rootfalse ok:bwrap b0682fd111348578ac9d283e6e6f994d307fa0fbd82fc968987d671f459a57b4 +B preset3 pref1 host0 st0 net2 gr2 x3 itrue rootfalse ok:bwrap 6c444b728854edb820aa22f9023a12afe755146c11c7630e9fdc82e4ceb3bc23 +B preset3 pref1 host0 st0 net3 gr1 x5 ifalse rootfalse refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +B preset3 pref1 host0 st0 net3 gr2 x6 itrue rootfalse ok:bwrap 6f4091e661bba47c2d58485188a7ba075dd10ee1eee0dbe01f15145493864724 +B preset3 pref1 host0 st1 net0 gr0 x1 ifalse rootfalse ok:bwrap 62a38400bddb180112f4bba4ab0635cda73c231c30973d29e5c1599078d32f77 +B preset3 pref1 host0 st1 net0 gr1 x2 itrue rootfalse ok:bwrap efb18138a47310e5bd6df55e158395829185e5af42de234a230646d5e1a1746e +B preset3 pref1 host0 st1 net1 gr0 x4 ifalse roottrue ok:bwrap 17af5fbceb1c9e8d76bef639031d4de53664f19064ca28e6bb857dd5c27cee2c +B preset3 pref1 host0 st1 net1 gr1 x5 itrue rootfalse refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +B preset3 pref1 host0 st1 net2 gr0 x7 ifalse rootfalse ok:bwrap aa6554876c96d9863816bd9d9abb6f5fd7d383eaca727a3521d2522d05afff24 +B preset3 pref1 host0 st1 net2 gr1 x8 itrue roottrue ok:bwrap a8e1c18dc23465eeea799edfc81067ca6fa7e6044eff6b24d0ab4df702ebf6fb +B preset3 pref1 host0 st1 net3 gr0 x10 ifalse rootfalse ok:bwrap 62a38400bddb180112f4bba4ab0635cda73c231c30973d29e5c1599078d32f77 +B preset3 pref1 host0 st1 net3 gr1 x11 itrue rootfalse refused:mount,mount 1445fa9de066b4159cbb7b5b490b427441b83f6e38fa10d30d83c205d301b1ec +B preset3 pref1 host0 st2 net0 gr2 x6 ifalse rootfalse ok:bwrap 4c32490d25543a9d40292cf8dcf6f47177c0ff2dc7912f21637ebc3dd8fe2425 +B preset3 pref1 host0 st2 net0 gr0 x7 itrue rootfalse ok:bwrap 59d19cebd98313244d9a027f5788bbad80be887f66d23079b6faf1a8585d35c6 +B preset3 pref1 host0 st2 net1 gr2 x9 ifalse rootfalse ok:bwrap 20cddf927ebda3cc3bb27a9fd212e60ae1c24ae2291cc68ce0b0e02b3082c95c +B preset3 pref1 host0 st2 net1 gr0 x10 itrue rootfalse ok:bwrap d56c9f7eebe0409299465ede93863a6e28261c98685432aca87d1c9ca3cf11ab +B preset3 pref1 host0 st2 net2 gr2 x12 ifalse roottrue ok:bwrap c74024b0d6e0899a5e945090589917a3ee542332d9df3325f9d91da7bf0773eb +B preset3 pref1 host0 st2 net2 gr0 x0 itrue rootfalse ok:bwrap f39ae920266a299dcb4ee103f2e4770740dc4fa88c30d4686028e3d14966a070 +B preset3 pref1 host0 st2 net3 gr2 x2 ifalse rootfalse ok:bwrap 4c32490d25543a9d40292cf8dcf6f47177c0ff2dc7912f21637ebc3dd8fe2425 +B preset3 pref1 host0 st2 net3 gr0 x3 itrue roottrue ok:bwrap a98e23ffbfa7c2107da2ed29860f9e91c642e8e864501ec21e6873a8a46a6ad3 +B preset3 pref2 host0 st0 net0 gr2 x3 ifalse rootfalse refused:net,identity,fs,pid,terminal 0cb38313e680815177c071c0555c489439dcacf394a5bad9b68a4243f805cdf6 +B preset3 pref2 host0 st0 net0 gr0 x4 itrue rootfalse refused:net,identity,userns,fs,pid,ipc,terminal,mount 294ecb9da418c88d1658e9a8098b21181f2916908d6298e35c7f1c8f246066e5 +B preset3 pref2 host0 st0 net1 gr2 x6 ifalse rootfalse refused:net,identity,fs,pid,terminal 0cb38313e680815177c071c0555c489439dcacf394a5bad9b68a4243f805cdf6 +B preset3 pref2 host0 st0 net1 gr0 x7 itrue rootfalse refused:net,identity,fs,pid,terminal,mount 31a34e588e242954182847a88b754cdc475ad207b9c09346bbf8ae88b960b833 +B preset3 pref2 host0 st0 net2 gr2 x9 ifalse rootfalse refused:net,publish,identity,userns,fs,pid,ipc,terminal 7e91b7608c24ad1ec101eb072de0432bedd83aa0cbef7894fb65c74fe51cb702 +B preset3 pref2 host0 st0 net2 gr0 x10 itrue rootfalse refused:net,identity,fs,pid,terminal,mount 31a34e588e242954182847a88b754cdc475ad207b9c09346bbf8ae88b960b833 +B preset3 pref2 host0 st0 net3 gr2 x12 ifalse rootfalse refused:net,identity,fs,pid,terminal 0cb38313e680815177c071c0555c489439dcacf394a5bad9b68a4243f805cdf6 +B preset3 pref2 host0 st0 net3 gr0 x0 itrue rootfalse refused:net,identity,fs,pid,terminal 0cb38313e680815177c071c0555c489439dcacf394a5bad9b68a4243f805cdf6 +B preset3 pref2 host0 st1 net0 gr1 x8 ifalse rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +B preset3 pref2 host0 st1 net0 gr2 x9 itrue rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +B preset3 pref2 host0 st1 net1 gr1 x11 ifalse rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +B preset3 pref2 host0 st1 net1 gr2 x12 itrue rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +B preset3 pref2 host0 st1 net2 gr1 x1 ifalse rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +B preset3 pref2 host0 st1 net2 gr2 x2 itrue rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +B preset3 pref2 host0 st1 net3 gr1 x4 ifalse rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +B preset3 pref2 host0 st1 net3 gr2 x5 itrue rootfalse refused:storage a51385722642b3512fae05dcbd6777dd70a4aba83abb78d2100fdc9a7213ef0c +B preset3 pref2 host0 st2 net0 gr0 x0 ifalse rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +B preset3 pref2 host0 st2 net0 gr1 x1 itrue rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +B preset3 pref2 host0 st2 net1 gr0 x3 ifalse rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +B preset3 pref2 host0 st2 net1 gr1 x4 itrue rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +B preset3 pref2 host0 st2 net2 gr0 x6 ifalse rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +B preset3 pref2 host0 st2 net2 gr1 x7 itrue rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +B preset3 pref2 host0 st2 net3 gr0 x9 ifalse rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +B preset3 pref2 host0 st2 net3 gr1 x10 itrue rootfalse refused:storage be4fbd5250e4bfa8888c38b706a889a8e92b81cbf0b9cdb2f7a56ba31d13f0ee +B preset3 pref3 host0 st0 net0 gr0 x10 ifalse rootfalse refused:net,identity,fs,pid,terminal,mount f40e67168c46916796345bce5baf6e2883f03f009e663a3afc91514e5fd4cbb8 +B preset3 pref3 host0 st0 net0 gr1 x11 itrue rootfalse refused:net,identity,fs,pid,terminal,mount,mount d315feae25dc11ddc927435b0c77c9ca0794ab810d2fc21f7efa83f4052e1aa3 +B preset3 pref3 host0 st0 net1 gr0 x0 ifalse rootfalse refused:net,identity,fs,pid,terminal 26f40ad53fe65313477e1b20e993190d74316e9b6f2ce716d3af57c54231fa8a +B preset3 pref3 host0 st0 net1 gr1 x1 itrue rootfalse refused:net,identity,fs,pid,terminal,mount f40e67168c46916796345bce5baf6e2883f03f009e663a3afc91514e5fd4cbb8 +B preset3 pref3 host0 st0 net2 gr0 x3 ifalse rootfalse refused:net,identity,fs,pid,terminal 26f40ad53fe65313477e1b20e993190d74316e9b6f2ce716d3af57c54231fa8a +B preset3 pref3 host0 st0 net2 gr1 x4 itrue rootfalse refused:net,identity,userns,fs,sockets,pid,ipc,terminal,mount 91b988a7b8e9971ccab830e887c99d536478beab3d896b41c6867d86286ec761 +B preset3 pref3 host0 st0 net3 gr0 x6 ifalse rootfalse refused:net,identity,fs,pid,terminal 26f40ad53fe65313477e1b20e993190d74316e9b6f2ce716d3af57c54231fa8a +B preset3 pref3 host0 st0 net3 gr1 x7 itrue rootfalse refused:net,identity,fs,pid,terminal,mount f40e67168c46916796345bce5baf6e2883f03f009e663a3afc91514e5fd4cbb8 +B preset3 pref3 host0 st1 net0 gr2 x2 ifalse rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +B preset3 pref3 host0 st1 net0 gr0 x3 itrue rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +B preset3 pref3 host0 st1 net1 gr2 x5 ifalse rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +B preset3 pref3 host0 st1 net1 gr0 x6 itrue rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +B preset3 pref3 host0 st1 net2 gr2 x8 ifalse rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +B preset3 pref3 host0 st1 net2 gr0 x9 itrue rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +B preset3 pref3 host0 st1 net3 gr2 x11 ifalse rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +B preset3 pref3 host0 st1 net3 gr0 x12 itrue rootfalse refused:storage ed7774309f2de07ba3c42d282e24f81de133b1d00b2bc38b77fdc8e95901b107 +B preset3 pref3 host0 st2 net0 gr1 x7 ifalse rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +B preset3 pref3 host0 st2 net0 gr2 x8 itrue rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +B preset3 pref3 host0 st2 net1 gr1 x10 ifalse rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +B preset3 pref3 host0 st2 net1 gr2 x11 itrue rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +B preset3 pref3 host0 st2 net2 gr1 x0 ifalse rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +B preset3 pref3 host0 st2 net2 gr2 x1 itrue rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +B preset3 pref3 host0 st2 net3 gr1 x3 ifalse rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +B preset3 pref3 host0 st2 net3 gr2 x4 itrue rootfalse refused:storage a8560ca3e6d586f8ffef6945d547100db44f7e205487b8581ceebb224db3c57a +D preset0 pref2 host0 st0 net0 gr0 x0 ifalse rootfalse ok:proot dec532564d0901c188135cd26121d314ef98af7a0f41239dac675125188094be +D preset0 pref2 host0 st0 net0 gr1 x3 itrue rootfalse ok:proot 8775d3d1e96ad657c73b778bc75874b058c4f4f654bac3685ca44a28523eee41 +D preset0 pref2 host0 st0 net0 gr2 x1 ifalse rootfalse ok:proot a79b715159750173a2fd77b04c64abe5555752deef7978aec0faa4a1256bf3eb +D preset0 pref2 host0 st0 net1 gr0 x0 itrue rootfalse ok:proot d1d6634374e81f45e31f463696e1be0ad24f933f8ff88d2e73f0ed93f53a7392 +D preset0 pref2 host0 st0 net1 gr1 x3 ifalse rootfalse ok:proot 42365c0bdde232822be0ea31cd4392a205f3f48d43608a0a2668a0754c71541e +D preset0 pref2 host0 st0 net1 gr2 x0 itrue rootfalse ok:proot 9ae192cbc13708d62352612c99643fcc69152dd2ff23bdf9b1cda6332912ea4a +D preset0 pref2 host0 st0 net2 gr0 x0 ifalse rootfalse refused:net de6078de5f0322bae595172d264551989f9a5fc12640fe4643b79491f7e85ad7 +D preset0 pref2 host0 st0 net2 gr1 x3 itrue rootfalse refused:net de6078de5f0322bae595172d264551989f9a5fc12640fe4643b79491f7e85ad7 +D preset0 pref2 host0 st0 net2 gr2 x1 ifalse rootfalse refused:net de6078de5f0322bae595172d264551989f9a5fc12640fe4643b79491f7e85ad7 +D preset0 pref2 host0 st0 net3 gr0 x0 itrue rootfalse ok:proot d1d6634374e81f45e31f463696e1be0ad24f933f8ff88d2e73f0ed93f53a7392 +D preset0 pref2 host0 st0 net3 gr1 x3 ifalse rootfalse ok:proot 42365c0bdde232822be0ea31cd4392a205f3f48d43608a0a2668a0754c71541e +D preset0 pref2 host0 st0 net3 gr2 x1 itrue rootfalse ok:proot 401636d4f5e1e3ba42770fa27eedf1f75a2f16bc68bc498a8a725dd0f0fa99bc +D preset1 pref2 host0 st0 net0 gr0 x0 itrue rootfalse ok:proot 96270c7ca10c5d5b583b0d6f27a2871c3d1303eb6fc0040d9a7e5f9180c09b5e +D preset1 pref2 host0 st0 net0 gr1 x4 ifalse rootfalse refused:fs,pid,ipc,terminal,mount f436190cb8ed947fe608cbbd2472a5fbbc1e9e3527168d66647d65fcae18dba9 +D preset1 pref2 host0 st0 net0 gr2 x0 itrue rootfalse ok:proot cd0b801b6d8274a4fe5b6683b72d214636ec69664dddc494b180ab61b6317d46 +D preset1 pref2 host0 st0 net1 gr0 x1 ifalse rootfalse ok:proot 7a67238923269c5923597503f40202c3433945aea6182c359650a8319e4c36a2 +D preset1 pref2 host0 st0 net1 gr1 x4 itrue rootfalse refused:net,fs,pid,ipc,terminal,mount c5a9a540afc636bdba8935a6e7824475140b8c7367564d9dac6677d20fc5175f +D preset1 pref2 host0 st0 net1 gr2 x2 ifalse rootfalse refused:net de6078de5f0322bae595172d264551989f9a5fc12640fe4643b79491f7e85ad7 +D preset1 pref2 host0 st0 net2 gr0 x1 itrue rootfalse refused:net de6078de5f0322bae595172d264551989f9a5fc12640fe4643b79491f7e85ad7 +D preset1 pref2 host0 st0 net2 gr1 x4 ifalse rootfalse refused:net,fs,pid,ipc,terminal,mount c5a9a540afc636bdba8935a6e7824475140b8c7367564d9dac6677d20fc5175f +D preset1 pref2 host0 st0 net2 gr2 x2 itrue rootfalse refused:net de6078de5f0322bae595172d264551989f9a5fc12640fe4643b79491f7e85ad7 +D preset1 pref2 host0 st0 net3 gr0 x1 ifalse rootfalse ok:proot 7a67238923269c5923597503f40202c3433945aea6182c359650a8319e4c36a2 +D preset1 pref2 host0 st0 net3 gr1 x0 itrue rootfalse ok:proot 1de240b3a73a1659d65e24e37eb5d062853d6f00461dd721286fd27238292c40 +D preset1 pref2 host0 st0 net3 gr2 x2 ifalse rootfalse refused:net de6078de5f0322bae595172d264551989f9a5fc12640fe4643b79491f7e85ad7 +D preset2 pref2 host0 st0 net0 gr0 x2 ifalse rootfalse refused:net,fs,pid ce7b1275459339a24a7a912252f6ec086ccc19f25e2db8ee11f1548906afdb1e +D preset2 pref2 host0 st0 net0 gr1 x0 itrue rootfalse refused:net,fs,pid ce7b1275459339a24a7a912252f6ec086ccc19f25e2db8ee11f1548906afdb1e +D preset2 pref2 host0 st0 net0 gr2 x3 ifalse rootfalse refused:net,fs,pid ce7b1275459339a24a7a912252f6ec086ccc19f25e2db8ee11f1548906afdb1e +D preset2 pref2 host0 st0 net1 gr0 x2 itrue rootfalse refused:net,fs,pid ce7b1275459339a24a7a912252f6ec086ccc19f25e2db8ee11f1548906afdb1e +D preset2 pref2 host0 st0 net1 gr1 x0 ifalse rootfalse refused:net,fs,pid ce7b1275459339a24a7a912252f6ec086ccc19f25e2db8ee11f1548906afdb1e +D preset2 pref2 host0 st0 net1 gr2 x3 itrue rootfalse refused:net,fs,pid ce7b1275459339a24a7a912252f6ec086ccc19f25e2db8ee11f1548906afdb1e +D preset2 pref2 host0 st0 net2 gr0 x2 ifalse rootfalse refused:net,fs,pid ce7b1275459339a24a7a912252f6ec086ccc19f25e2db8ee11f1548906afdb1e +D preset2 pref2 host0 st0 net2 gr1 x0 itrue rootfalse refused:net,fs,pid ce7b1275459339a24a7a912252f6ec086ccc19f25e2db8ee11f1548906afdb1e +D preset2 pref2 host0 st0 net2 gr2 x3 ifalse rootfalse refused:net,fs,pid ce7b1275459339a24a7a912252f6ec086ccc19f25e2db8ee11f1548906afdb1e +D preset2 pref2 host0 st0 net3 gr0 x2 itrue rootfalse refused:net,fs,pid ce7b1275459339a24a7a912252f6ec086ccc19f25e2db8ee11f1548906afdb1e +D preset2 pref2 host0 st0 net3 gr1 x0 ifalse rootfalse refused:net,fs,pid ce7b1275459339a24a7a912252f6ec086ccc19f25e2db8ee11f1548906afdb1e +D preset2 pref2 host0 st0 net3 gr2 x3 itrue rootfalse refused:net,fs,pid ce7b1275459339a24a7a912252f6ec086ccc19f25e2db8ee11f1548906afdb1e +D preset3 pref2 host0 st0 net0 gr0 x3 itrue rootfalse refused:net,identity,fs,pid,terminal 0cb38313e680815177c071c0555c489439dcacf394a5bad9b68a4243f805cdf6 +D preset3 pref2 host0 st0 net0 gr1 x1 ifalse rootfalse refused:net,identity,fs,pid,terminal,mount 31a34e588e242954182847a88b754cdc475ad207b9c09346bbf8ae88b960b833 +D preset3 pref2 host0 st0 net0 gr2 x4 itrue rootfalse refused:net,publish,identity,userns,fs,pid,ipc,terminal,mount 43bac0d01c5572ad2175902175bb073c6c5a8bb5354d8ed92bd31441e8037d46 +D preset3 pref2 host0 st0 net1 gr0 x3 ifalse rootfalse refused:net,identity,fs,pid,terminal 0cb38313e680815177c071c0555c489439dcacf394a5bad9b68a4243f805cdf6 +D preset3 pref2 host0 st0 net1 gr1 x0 itrue rootfalse refused:net,identity,fs,pid,terminal 0cb38313e680815177c071c0555c489439dcacf394a5bad9b68a4243f805cdf6 +D preset3 pref2 host0 st0 net1 gr2 x4 ifalse rootfalse refused:net,publish,identity,userns,fs,pid,ipc,terminal,mount 43bac0d01c5572ad2175902175bb073c6c5a8bb5354d8ed92bd31441e8037d46 +D preset3 pref2 host0 st0 net2 gr0 x3 itrue rootfalse refused:net,identity,fs,pid,terminal 0cb38313e680815177c071c0555c489439dcacf394a5bad9b68a4243f805cdf6 +D preset3 pref2 host0 st0 net2 gr1 x1 ifalse rootfalse refused:net,identity,fs,pid,terminal,mount 31a34e588e242954182847a88b754cdc475ad207b9c09346bbf8ae88b960b833 +D preset3 pref2 host0 st0 net2 gr2 x4 itrue rootfalse refused:net,publish,identity,userns,fs,pid,ipc,terminal,mount 43bac0d01c5572ad2175902175bb073c6c5a8bb5354d8ed92bd31441e8037d46 +D preset3 pref2 host0 st0 net3 gr0 x3 ifalse rootfalse refused:net,identity,fs,pid,terminal 0cb38313e680815177c071c0555c489439dcacf394a5bad9b68a4243f805cdf6 +D preset3 pref2 host0 st0 net3 gr1 x1 itrue rootfalse refused:net,identity,fs,pid,terminal,mount 31a34e588e242954182847a88b754cdc475ad207b9c09346bbf8ae88b960b833 +D preset3 pref2 host0 st0 net3 gr2 x4 ifalse rootfalse refused:net,publish,identity,userns,fs,pid,ipc,terminal,mount 43bac0d01c5572ad2175902175bb073c6c5a8bb5354d8ed92bd31441e8037d46 +D preset0 pref2 host1 st0 net0 gr0 x1 itrue rootfalse ok:proot b123df546509e59e089db80e034315397a5b3aa4a507f7e0d239503222a96156 +D preset0 pref2 host1 st0 net0 gr1 x4 ifalse rootfalse refused:fs,pid,ipc,terminal,mount f436190cb8ed947fe608cbbd2472a5fbbc1e9e3527168d66647d65fcae18dba9 +D preset0 pref2 host1 st0 net0 gr2 x2 itrue rootfalse ok:proot 0686fcba6e769673e52c1b76e4bc63f38f8302ac12d627b0c493207ecaf2147c +D preset0 pref2 host1 st0 net1 gr0 x1 ifalse rootfalse ok:proot 7a67238923269c5923597503f40202c3433945aea6182c359650a8319e4c36a2 +D preset0 pref2 host1 st0 net1 gr1 x4 itrue rootfalse refused:net,fs,pid,ipc,terminal,mount c5a9a540afc636bdba8935a6e7824475140b8c7367564d9dac6677d20fc5175f +D preset0 pref2 host1 st0 net1 gr2 x2 ifalse rootfalse refused:net de6078de5f0322bae595172d264551989f9a5fc12640fe4643b79491f7e85ad7 +D preset0 pref2 host1 st0 net2 gr0 x0 itrue rootfalse refused:net de6078de5f0322bae595172d264551989f9a5fc12640fe4643b79491f7e85ad7 +D preset0 pref2 host1 st0 net2 gr1 x4 ifalse rootfalse refused:net,fs,pid,ipc,terminal,mount c5a9a540afc636bdba8935a6e7824475140b8c7367564d9dac6677d20fc5175f +D preset0 pref2 host1 st0 net2 gr2 x0 itrue rootfalse refused:net de6078de5f0322bae595172d264551989f9a5fc12640fe4643b79491f7e85ad7 +D preset0 pref2 host1 st0 net3 gr0 x1 ifalse rootfalse ok:proot 7a67238923269c5923597503f40202c3433945aea6182c359650a8319e4c36a2 +D preset0 pref2 host1 st0 net3 gr1 x4 itrue rootfalse refused:net,fs,pid,ipc,terminal,mount c5a9a540afc636bdba8935a6e7824475140b8c7367564d9dac6677d20fc5175f +D preset0 pref2 host1 st0 net3 gr2 x2 ifalse rootfalse refused:net de6078de5f0322bae595172d264551989f9a5fc12640fe4643b79491f7e85ad7 +D preset1 pref2 host1 st0 net0 gr0 x2 ifalse rootfalse ok:proot 6c267c2edc11075d8d4092c0ad3f19d9e42ce4c184dbd6c88cd61f4b8d3932ee +D preset1 pref2 host1 st0 net0 gr1 x0 itrue rootfalse ok:proot 8775d3d1e96ad657c73b778bc75874b058c4f4f654bac3685ca44a28523eee41 +D preset1 pref2 host1 st0 net0 gr2 x3 ifalse rootfalse ok:proot 3da695dc75d19a8ebb81ec9bc3ff759677cd23ffabad34a43fa7111d3c2daf3c +D preset1 pref2 host1 st0 net1 gr0 x0 itrue rootfalse ok:proot d1d6634374e81f45e31f463696e1be0ad24f933f8ff88d2e73f0ed93f53a7392 +D preset1 pref2 host1 st0 net1 gr1 x0 ifalse rootfalse ok:proot 42365c0bdde232822be0ea31cd4392a205f3f48d43608a0a2668a0754c71541e +D preset1 pref2 host1 st0 net1 gr2 x0 itrue rootfalse ok:proot 9ae192cbc13708d62352612c99643fcc69152dd2ff23bdf9b1cda6332912ea4a +D preset1 pref2 host1 st0 net2 gr0 x2 ifalse rootfalse refused:net de6078de5f0322bae595172d264551989f9a5fc12640fe4643b79491f7e85ad7 +D preset1 pref2 host1 st0 net2 gr1 x0 itrue rootfalse refused:net de6078de5f0322bae595172d264551989f9a5fc12640fe4643b79491f7e85ad7 +D preset1 pref2 host1 st0 net2 gr2 x3 ifalse rootfalse refused:net de6078de5f0322bae595172d264551989f9a5fc12640fe4643b79491f7e85ad7 +D preset1 pref2 host1 st0 net3 gr0 x2 itrue rootfalse refused:net de6078de5f0322bae595172d264551989f9a5fc12640fe4643b79491f7e85ad7 +D preset1 pref2 host1 st0 net3 gr1 x0 ifalse rootfalse ok:proot 42365c0bdde232822be0ea31cd4392a205f3f48d43608a0a2668a0754c71541e +D preset1 pref2 host1 st0 net3 gr2 x3 itrue rootfalse ok:proot 9ae192cbc13708d62352612c99643fcc69152dd2ff23bdf9b1cda6332912ea4a +D preset2 pref2 host1 st0 net0 gr0 x0 itrue rootfalse refused:net,fs,pid ce7b1275459339a24a7a912252f6ec086ccc19f25e2db8ee11f1548906afdb1e +D preset2 pref2 host1 st0 net0 gr1 x1 ifalse rootfalse refused:net,fs,pid,mount 6bed5de033d25cec93df90e9ccaa926722e45d9a4a95383f21f6e85cb6980550 +D preset2 pref2 host1 st0 net0 gr2 x0 itrue rootfalse refused:net,fs,pid ce7b1275459339a24a7a912252f6ec086ccc19f25e2db8ee11f1548906afdb1e +D preset2 pref2 host1 st0 net1 gr0 x3 ifalse rootfalse refused:net,fs,pid ce7b1275459339a24a7a912252f6ec086ccc19f25e2db8ee11f1548906afdb1e +D preset2 pref2 host1 st0 net1 gr1 x1 itrue rootfalse refused:net,fs,pid,mount 6bed5de033d25cec93df90e9ccaa926722e45d9a4a95383f21f6e85cb6980550 +D preset2 pref2 host1 st0 net1 gr2 x4 ifalse rootfalse refused:net,publish,identity,fs,pid,ipc,terminal,mount cad71a5c9b90d2bc62af34ca67468d9d659f7960d282c52e892185a3ac778883 +D preset2 pref2 host1 st0 net2 gr0 x3 itrue rootfalse refused:net,fs,pid ce7b1275459339a24a7a912252f6ec086ccc19f25e2db8ee11f1548906afdb1e +D preset2 pref2 host1 st0 net2 gr1 x1 ifalse rootfalse refused:net,fs,pid,mount 6bed5de033d25cec93df90e9ccaa926722e45d9a4a95383f21f6e85cb6980550 +D preset2 pref2 host1 st0 net2 gr2 x4 itrue rootfalse refused:net,publish,identity,fs,pid,ipc,terminal,mount cad71a5c9b90d2bc62af34ca67468d9d659f7960d282c52e892185a3ac778883 +D preset2 pref2 host1 st0 net3 gr0 x3 ifalse rootfalse refused:net,fs,pid ce7b1275459339a24a7a912252f6ec086ccc19f25e2db8ee11f1548906afdb1e +D preset2 pref2 host1 st0 net3 gr1 x0 itrue rootfalse refused:net,fs,pid ce7b1275459339a24a7a912252f6ec086ccc19f25e2db8ee11f1548906afdb1e +D preset2 pref2 host1 st0 net3 gr2 x4 ifalse rootfalse refused:net,publish,identity,fs,pid,ipc,terminal,mount cad71a5c9b90d2bc62af34ca67468d9d659f7960d282c52e892185a3ac778883 +D preset3 pref2 host1 st0 net0 gr0 x4 ifalse rootfalse refused:net,identity,userns,fs,pid,ipc,terminal,mount 294ecb9da418c88d1658e9a8098b21181f2916908d6298e35c7f1c8f246066e5 +D preset3 pref2 host1 st0 net0 gr1 x2 itrue rootfalse refused:net,identity,fs,pid,terminal 0cb38313e680815177c071c0555c489439dcacf394a5bad9b68a4243f805cdf6 +D preset3 pref2 host1 st0 net0 gr2 x0 ifalse rootfalse refused:net,identity,fs,pid,terminal 0cb38313e680815177c071c0555c489439dcacf394a5bad9b68a4243f805cdf6 +D preset3 pref2 host1 st0 net1 gr0 x4 itrue rootfalse refused:net,identity,userns,fs,pid,ipc,terminal,mount 294ecb9da418c88d1658e9a8098b21181f2916908d6298e35c7f1c8f246066e5 +D preset3 pref2 host1 st0 net1 gr1 x2 ifalse rootfalse refused:net,identity,fs,pid,terminal 0cb38313e680815177c071c0555c489439dcacf394a5bad9b68a4243f805cdf6 +D preset3 pref2 host1 st0 net1 gr2 x0 itrue rootfalse refused:net,identity,fs,pid,terminal 0cb38313e680815177c071c0555c489439dcacf394a5bad9b68a4243f805cdf6 +D preset3 pref2 host1 st0 net2 gr0 x4 ifalse rootfalse refused:net,identity,userns,fs,pid,ipc,terminal,mount 294ecb9da418c88d1658e9a8098b21181f2916908d6298e35c7f1c8f246066e5 +D preset3 pref2 host1 st0 net2 gr1 x0 itrue rootfalse refused:net,identity,fs,pid,terminal 0cb38313e680815177c071c0555c489439dcacf394a5bad9b68a4243f805cdf6 +D preset3 pref2 host1 st0 net2 gr2 x0 ifalse rootfalse refused:net,identity,fs,pid,terminal 0cb38313e680815177c071c0555c489439dcacf394a5bad9b68a4243f805cdf6 +D preset3 pref2 host1 st0 net3 gr0 x4 itrue rootfalse refused:net,identity,userns,fs,pid,ipc,terminal,mount 294ecb9da418c88d1658e9a8098b21181f2916908d6298e35c7f1c8f246066e5 +D preset3 pref2 host1 st0 net3 gr1 x2 ifalse rootfalse refused:net,identity,fs,pid,terminal 0cb38313e680815177c071c0555c489439dcacf394a5bad9b68a4243f805cdf6 +D preset3 pref2 host1 st0 net3 gr2 x0 itrue rootfalse refused:net,identity,fs,pid,terminal 0cb38313e680815177c071c0555c489439dcacf394a5bad9b68a4243f805cdf6 +D preset0 pref3 host0 st0 net0 gr0 x0 ifalse rootfalse ok:landlock f8710932d62308fec5ca9248ce43d2f260f379f80d2c5bfeffb0cbf8d7a50f7c +D preset0 pref3 host0 st0 net0 gr1 x3 itrue rootfalse ok:landlock a27501216e34adbcf2b2a7139eec26ba937c7776948713059ebb47f231089de0 +D preset0 pref3 host0 st0 net0 gr2 x1 ifalse rootfalse refused:mount ce1263420473a863261655f412c6cc203ce5466be206cbdc5e6f057c590f5259 +D preset0 pref3 host0 st0 net1 gr0 x0 itrue rootfalse ok:landlock 174cef4d8027424538951c5cb5acedac156db7e6fe09edaab0c77185f6d2634d +D preset0 pref3 host0 st0 net1 gr1 x3 ifalse rootfalse ok:landlock 7023b9a694f2bc612b8909fa0ff643d4ed1ca29c978d95478a9a3b5a67fc0eab +D preset0 pref3 host0 st0 net1 gr2 x0 itrue rootfalse ok:landlock 8c1eaee5d0ddc2673c8aef94574051eb2c693fe665150d56646a61dccfc8aa07 +D preset0 pref3 host0 st0 net2 gr0 x0 ifalse rootfalse refused:net 49ce79fb0a6bc74c4ff2730b5ba1e4f4d24161361d2098f1b2cabd62ee2b4ea2 +D preset0 pref3 host0 st0 net2 gr1 x3 itrue rootfalse refused:net 49ce79fb0a6bc74c4ff2730b5ba1e4f4d24161361d2098f1b2cabd62ee2b4ea2 +D preset0 pref3 host0 st0 net2 gr2 x1 ifalse rootfalse refused:net,mount 023d7daace7060f780b4ebcc928a35d1d2b1f319ba691aeaa46e3d3bfdd357e0 +D preset0 pref3 host0 st0 net3 gr0 x0 itrue rootfalse ok:landlock 174cef4d8027424538951c5cb5acedac156db7e6fe09edaab0c77185f6d2634d +D preset0 pref3 host0 st0 net3 gr1 x3 ifalse rootfalse ok:landlock 7023b9a694f2bc612b8909fa0ff643d4ed1ca29c978d95478a9a3b5a67fc0eab +D preset0 pref3 host0 st0 net3 gr2 x1 itrue rootfalse refused:mount ce1263420473a863261655f412c6cc203ce5466be206cbdc5e6f057c590f5259 +D preset1 pref3 host0 st0 net0 gr0 x0 itrue rootfalse ok:landlock af3a0fc885aed798532fb4eb4decc1e7bfde6fa50a3f8426ad7752940366933a +D preset1 pref3 host0 st0 net0 gr1 x4 ifalse rootfalse refused:fs,sockets,pid,ipc,terminal,mount 554f19ea1eed4854a5b7aa2e0f5d1901d3d11d0781743ce3e3955c6ac76c7eca +D preset1 pref3 host0 st0 net0 gr2 x0 itrue rootfalse ok:landlock e62252a17243eae0a55eb1252dd5c438c9348d28c764cfdbef4250bb28ff5b6c +D preset1 pref3 host0 st0 net1 gr0 x1 ifalse rootfalse refused:mount ce1263420473a863261655f412c6cc203ce5466be206cbdc5e6f057c590f5259 +D preset1 pref3 host0 st0 net1 gr1 x4 itrue rootfalse refused:net,fs,sockets,pid,ipc,terminal,mount cd3a1f8f1f8c753df1df032f823cf8491512b09e3b36b29aaa64a886cf38eb58 +D preset1 pref3 host0 st0 net1 gr2 x2 ifalse rootfalse refused:net 49ce79fb0a6bc74c4ff2730b5ba1e4f4d24161361d2098f1b2cabd62ee2b4ea2 +D preset1 pref3 host0 st0 net2 gr0 x1 itrue rootfalse refused:net,mount 023d7daace7060f780b4ebcc928a35d1d2b1f319ba691aeaa46e3d3bfdd357e0 +D preset1 pref3 host0 st0 net2 gr1 x4 ifalse rootfalse refused:net,fs,sockets,pid,ipc,terminal,mount cd3a1f8f1f8c753df1df032f823cf8491512b09e3b36b29aaa64a886cf38eb58 +D preset1 pref3 host0 st0 net2 gr2 x2 itrue rootfalse refused:net 49ce79fb0a6bc74c4ff2730b5ba1e4f4d24161361d2098f1b2cabd62ee2b4ea2 +D preset1 pref3 host0 st0 net3 gr0 x1 ifalse rootfalse refused:mount ce1263420473a863261655f412c6cc203ce5466be206cbdc5e6f057c590f5259 +D preset1 pref3 host0 st0 net3 gr1 x0 itrue rootfalse ok:landlock fdb608dc335a4ada5f59c35551b64a1d42074b09f17040f04bd492398d63f7dd +D preset1 pref3 host0 st0 net3 gr2 x2 ifalse rootfalse refused:net 49ce79fb0a6bc74c4ff2730b5ba1e4f4d24161361d2098f1b2cabd62ee2b4ea2 +D preset2 pref3 host0 st0 net0 gr0 x2 ifalse rootfalse refused:net,fs,pid 435ea718621a2ba23751c9112d730b4d9b311acf484aa33382feffb1d040b1c2 +D preset2 pref3 host0 st0 net0 gr1 x0 itrue rootfalse refused:net,fs,pid 435ea718621a2ba23751c9112d730b4d9b311acf484aa33382feffb1d040b1c2 +D preset2 pref3 host0 st0 net0 gr2 x3 ifalse rootfalse refused:net,fs,pid 435ea718621a2ba23751c9112d730b4d9b311acf484aa33382feffb1d040b1c2 +D preset2 pref3 host0 st0 net1 gr0 x2 itrue rootfalse refused:net,fs,pid 435ea718621a2ba23751c9112d730b4d9b311acf484aa33382feffb1d040b1c2 +D preset2 pref3 host0 st0 net1 gr1 x0 ifalse rootfalse refused:net,fs,pid 435ea718621a2ba23751c9112d730b4d9b311acf484aa33382feffb1d040b1c2 +D preset2 pref3 host0 st0 net1 gr2 x3 itrue rootfalse refused:net,fs,pid 435ea718621a2ba23751c9112d730b4d9b311acf484aa33382feffb1d040b1c2 +D preset2 pref3 host0 st0 net2 gr0 x2 ifalse rootfalse refused:net,fs,pid 435ea718621a2ba23751c9112d730b4d9b311acf484aa33382feffb1d040b1c2 +D preset2 pref3 host0 st0 net2 gr1 x0 itrue rootfalse refused:net,fs,pid 435ea718621a2ba23751c9112d730b4d9b311acf484aa33382feffb1d040b1c2 +D preset2 pref3 host0 st0 net2 gr2 x3 ifalse rootfalse refused:net,fs,pid 435ea718621a2ba23751c9112d730b4d9b311acf484aa33382feffb1d040b1c2 +D preset2 pref3 host0 st0 net3 gr0 x2 itrue rootfalse refused:net,fs,pid 435ea718621a2ba23751c9112d730b4d9b311acf484aa33382feffb1d040b1c2 +D preset2 pref3 host0 st0 net3 gr1 x0 ifalse rootfalse refused:net,fs,pid 435ea718621a2ba23751c9112d730b4d9b311acf484aa33382feffb1d040b1c2 +D preset2 pref3 host0 st0 net3 gr2 x3 itrue rootfalse refused:net,fs,pid 435ea718621a2ba23751c9112d730b4d9b311acf484aa33382feffb1d040b1c2 +D preset3 pref3 host0 st0 net0 gr0 x3 itrue rootfalse refused:net,identity,fs,pid,terminal 26f40ad53fe65313477e1b20e993190d74316e9b6f2ce716d3af57c54231fa8a +D preset3 pref3 host0 st0 net0 gr1 x1 ifalse rootfalse refused:net,identity,fs,pid,terminal,mount f40e67168c46916796345bce5baf6e2883f03f009e663a3afc91514e5fd4cbb8 +D preset3 pref3 host0 st0 net0 gr2 x4 itrue rootfalse refused:net,publish,identity,userns,fs,sockets,pid,ipc,terminal,mount 869e699817cdf850f1fcfacf7eb4a81be4514c8456024243fd26cfcee90c2777 +D preset3 pref3 host0 st0 net1 gr0 x3 ifalse rootfalse refused:net,identity,fs,pid,terminal 26f40ad53fe65313477e1b20e993190d74316e9b6f2ce716d3af57c54231fa8a +D preset3 pref3 host0 st0 net1 gr1 x0 itrue rootfalse refused:net,identity,fs,pid,terminal 26f40ad53fe65313477e1b20e993190d74316e9b6f2ce716d3af57c54231fa8a +D preset3 pref3 host0 st0 net1 gr2 x4 ifalse rootfalse refused:net,publish,identity,userns,fs,sockets,pid,ipc,terminal,mount 869e699817cdf850f1fcfacf7eb4a81be4514c8456024243fd26cfcee90c2777 +D preset3 pref3 host0 st0 net2 gr0 x3 itrue rootfalse refused:net,identity,fs,pid,terminal 26f40ad53fe65313477e1b20e993190d74316e9b6f2ce716d3af57c54231fa8a +D preset3 pref3 host0 st0 net2 gr1 x1 ifalse rootfalse refused:net,identity,fs,pid,terminal,mount f40e67168c46916796345bce5baf6e2883f03f009e663a3afc91514e5fd4cbb8 +D preset3 pref3 host0 st0 net2 gr2 x4 itrue rootfalse refused:net,publish,identity,userns,fs,sockets,pid,ipc,terminal,mount 869e699817cdf850f1fcfacf7eb4a81be4514c8456024243fd26cfcee90c2777 +D preset3 pref3 host0 st0 net3 gr0 x3 ifalse rootfalse refused:net,identity,fs,pid,terminal 26f40ad53fe65313477e1b20e993190d74316e9b6f2ce716d3af57c54231fa8a +D preset3 pref3 host0 st0 net3 gr1 x1 itrue rootfalse refused:net,identity,fs,pid,terminal,mount f40e67168c46916796345bce5baf6e2883f03f009e663a3afc91514e5fd4cbb8 +D preset3 pref3 host0 st0 net3 gr2 x4 ifalse rootfalse refused:net,publish,identity,userns,fs,sockets,pid,ipc,terminal,mount 869e699817cdf850f1fcfacf7eb4a81be4514c8456024243fd26cfcee90c2777 +D preset0 pref3 host1 st0 net0 gr0 x1 itrue rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset0 pref3 host1 st0 net0 gr1 x4 ifalse rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset0 pref3 host1 st0 net0 gr2 x2 itrue rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset0 pref3 host1 st0 net1 gr0 x1 ifalse rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset0 pref3 host1 st0 net1 gr1 x4 itrue rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset0 pref3 host1 st0 net1 gr2 x2 ifalse rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset0 pref3 host1 st0 net2 gr0 x0 itrue rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset0 pref3 host1 st0 net2 gr1 x4 ifalse rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset0 pref3 host1 st0 net2 gr2 x0 itrue rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset0 pref3 host1 st0 net3 gr0 x1 ifalse rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset0 pref3 host1 st0 net3 gr1 x4 itrue rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset0 pref3 host1 st0 net3 gr2 x2 ifalse rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset1 pref3 host1 st0 net0 gr0 x2 ifalse rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset1 pref3 host1 st0 net0 gr1 x0 itrue rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset1 pref3 host1 st0 net0 gr2 x3 ifalse rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset1 pref3 host1 st0 net1 gr0 x0 itrue rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset1 pref3 host1 st0 net1 gr1 x0 ifalse rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset1 pref3 host1 st0 net1 gr2 x0 itrue rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset1 pref3 host1 st0 net2 gr0 x2 ifalse rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset1 pref3 host1 st0 net2 gr1 x0 itrue rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset1 pref3 host1 st0 net2 gr2 x3 ifalse rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset1 pref3 host1 st0 net3 gr0 x2 itrue rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset1 pref3 host1 st0 net3 gr1 x0 ifalse rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset1 pref3 host1 st0 net3 gr2 x3 itrue rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset2 pref3 host1 st0 net0 gr0 x0 itrue rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset2 pref3 host1 st0 net0 gr1 x1 ifalse rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset2 pref3 host1 st0 net0 gr2 x0 itrue rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset2 pref3 host1 st0 net1 gr0 x3 ifalse rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset2 pref3 host1 st0 net1 gr1 x1 itrue rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset2 pref3 host1 st0 net1 gr2 x4 ifalse rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset2 pref3 host1 st0 net2 gr0 x3 itrue rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset2 pref3 host1 st0 net2 gr1 x1 ifalse rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset2 pref3 host1 st0 net2 gr2 x4 itrue rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset2 pref3 host1 st0 net3 gr0 x3 ifalse rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset2 pref3 host1 st0 net3 gr1 x0 itrue rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset2 pref3 host1 st0 net3 gr2 x4 ifalse rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset3 pref3 host1 st0 net0 gr0 x4 ifalse rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset3 pref3 host1 st0 net0 gr1 x2 itrue rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset3 pref3 host1 st0 net0 gr2 x0 ifalse rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset3 pref3 host1 st0 net1 gr0 x4 itrue rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset3 pref3 host1 st0 net1 gr1 x2 ifalse rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset3 pref3 host1 st0 net1 gr2 x0 itrue rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset3 pref3 host1 st0 net2 gr0 x4 ifalse rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset3 pref3 host1 st0 net2 gr1 x0 itrue rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset3 pref3 host1 st0 net2 gr2 x0 ifalse rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset3 pref3 host1 st0 net3 gr0 x4 itrue rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset3 pref3 host1 st0 net3 gr1 x2 ifalse rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +D preset3 pref3 host1 st0 net3 gr2 x0 itrue rootfalse refused:backend ab174262b728db374c70c7435f30ca7345ef3cf2dc251f7c011ff43d4f0ce697 +C preset0 pref0 host3 st0 net0 gr0 x0 ifalse rootfalse ok:home-redirect 7650a5dd0857066bcc8cf12981a4257539e7dd13df64885ea8d9d464fe0f4fce +C preset0 pref0 host3 st0 net0 gr1 x3 itrue rootfalse ok:home-redirect 21c01d4542c8b72f020f06c6f89c7604e0efcc02f52e43de10e8c57169f200cf +C preset0 pref0 host3 st0 net0 gr2 x6 ifalse rootfalse ok:home-redirect 91442495f425dfd69273032f9abc9f3cfe909bf2aceb2ae3ca0ca27e7d0fbc8e +C preset0 pref0 host3 st0 net1 gr0 x0 ifalse rootfalse ok:home-redirect 741fa4530469b9e8cd8582fb52e3df2eb4263e60f0f62b3068f6e51bc4a4a40e +C preset0 pref0 host3 st0 net1 gr1 x3 itrue rootfalse ok:home-redirect 5282dc8c65d0da07c176bec551b203b2060815c7642aaeb72dc119d5abe458b0 +C preset0 pref0 host3 st0 net1 gr2 x6 ifalse rootfalse ok:home-redirect 621c001082fba11b50e3283966986a6605951c3e64e342d26cc8fdd36f5332f2 +C preset0 pref0 host3 st0 net2 gr0 x0 ifalse rootfalse refused:net 7d3a3727db31098b3774d3e70bbec91eacd88d87b77ea503aeeee8fcdbc0b71d +C preset0 pref0 host3 st0 net2 gr1 x3 itrue rootfalse refused:net 7d3a3727db31098b3774d3e70bbec91eacd88d87b77ea503aeeee8fcdbc0b71d +C preset0 pref0 host3 st0 net2 gr2 x6 ifalse rootfalse refused:net 7d3a3727db31098b3774d3e70bbec91eacd88d87b77ea503aeeee8fcdbc0b71d +C preset0 pref0 host3 st0 net3 gr0 x0 ifalse rootfalse ok:home-redirect 741fa4530469b9e8cd8582fb52e3df2eb4263e60f0f62b3068f6e51bc4a4a40e +C preset0 pref0 host3 st0 net3 gr1 x3 itrue rootfalse ok:home-redirect 5282dc8c65d0da07c176bec551b203b2060815c7642aaeb72dc119d5abe458b0 +C preset0 pref0 host3 st0 net3 gr2 x6 ifalse rootfalse ok:home-redirect 621c001082fba11b50e3283966986a6605951c3e64e342d26cc8fdd36f5332f2 +C preset1 pref0 host3 st0 net0 gr0 x0 ifalse rootfalse ok:home-redirect 7650a5dd0857066bcc8cf12981a4257539e7dd13df64885ea8d9d464fe0f4fce +C preset1 pref0 host3 st0 net0 gr1 x3 itrue rootfalse ok:home-redirect 21c01d4542c8b72f020f06c6f89c7604e0efcc02f52e43de10e8c57169f200cf +C preset1 pref0 host3 st0 net0 gr2 x6 ifalse rootfalse ok:home-redirect 91442495f425dfd69273032f9abc9f3cfe909bf2aceb2ae3ca0ca27e7d0fbc8e +C preset1 pref0 host3 st0 net1 gr0 x0 ifalse rootfalse ok:home-redirect 741fa4530469b9e8cd8582fb52e3df2eb4263e60f0f62b3068f6e51bc4a4a40e +C preset1 pref0 host3 st0 net1 gr1 x3 itrue rootfalse ok:home-redirect 5282dc8c65d0da07c176bec551b203b2060815c7642aaeb72dc119d5abe458b0 +C preset1 pref0 host3 st0 net1 gr2 x6 ifalse rootfalse ok:home-redirect 621c001082fba11b50e3283966986a6605951c3e64e342d26cc8fdd36f5332f2 +C preset1 pref0 host3 st0 net2 gr0 x0 ifalse rootfalse refused:net 7d3a3727db31098b3774d3e70bbec91eacd88d87b77ea503aeeee8fcdbc0b71d +C preset1 pref0 host3 st0 net2 gr1 x3 itrue rootfalse refused:net 7d3a3727db31098b3774d3e70bbec91eacd88d87b77ea503aeeee8fcdbc0b71d +C preset1 pref0 host3 st0 net2 gr2 x6 ifalse rootfalse refused:net 7d3a3727db31098b3774d3e70bbec91eacd88d87b77ea503aeeee8fcdbc0b71d +C preset1 pref0 host3 st0 net3 gr0 x0 ifalse rootfalse ok:home-redirect 741fa4530469b9e8cd8582fb52e3df2eb4263e60f0f62b3068f6e51bc4a4a40e +C preset1 pref0 host3 st0 net3 gr1 x3 itrue rootfalse ok:home-redirect 5282dc8c65d0da07c176bec551b203b2060815c7642aaeb72dc119d5abe458b0 +C preset1 pref0 host3 st0 net3 gr2 x6 ifalse rootfalse ok:home-redirect 621c001082fba11b50e3283966986a6605951c3e64e342d26cc8fdd36f5332f2 +C preset2 pref0 host3 st0 net0 gr0 x0 ifalse rootfalse refused:net,fs 3638ceea5a51eb70ab43f7569b7dd3972efe24cbb25b4a37d54b359b923d47c7 +C preset2 pref0 host3 st0 net0 gr1 x3 itrue rootfalse refused:net,fs 3638ceea5a51eb70ab43f7569b7dd3972efe24cbb25b4a37d54b359b923d47c7 +C preset2 pref0 host3 st0 net0 gr2 x6 ifalse rootfalse refused:net,fs 3638ceea5a51eb70ab43f7569b7dd3972efe24cbb25b4a37d54b359b923d47c7 +C preset2 pref0 host3 st0 net1 gr0 x0 ifalse rootfalse refused:net,fs 3638ceea5a51eb70ab43f7569b7dd3972efe24cbb25b4a37d54b359b923d47c7 +C preset2 pref0 host3 st0 net1 gr1 x3 itrue rootfalse refused:net,fs 3638ceea5a51eb70ab43f7569b7dd3972efe24cbb25b4a37d54b359b923d47c7 +C preset2 pref0 host3 st0 net1 gr2 x6 ifalse rootfalse refused:net,fs 3638ceea5a51eb70ab43f7569b7dd3972efe24cbb25b4a37d54b359b923d47c7 +C preset2 pref0 host3 st0 net2 gr0 x0 ifalse rootfalse refused:net,fs 3638ceea5a51eb70ab43f7569b7dd3972efe24cbb25b4a37d54b359b923d47c7 +C preset2 pref0 host3 st0 net2 gr1 x3 itrue rootfalse refused:net,fs 3638ceea5a51eb70ab43f7569b7dd3972efe24cbb25b4a37d54b359b923d47c7 +C preset2 pref0 host3 st0 net2 gr2 x6 ifalse rootfalse refused:net,fs 3638ceea5a51eb70ab43f7569b7dd3972efe24cbb25b4a37d54b359b923d47c7 +C preset2 pref0 host3 st0 net3 gr0 x0 ifalse rootfalse refused:net,fs 3638ceea5a51eb70ab43f7569b7dd3972efe24cbb25b4a37d54b359b923d47c7 +C preset2 pref0 host3 st0 net3 gr1 x3 itrue rootfalse refused:net,fs 3638ceea5a51eb70ab43f7569b7dd3972efe24cbb25b4a37d54b359b923d47c7 +C preset2 pref0 host3 st0 net3 gr2 x6 ifalse rootfalse refused:net,fs 3638ceea5a51eb70ab43f7569b7dd3972efe24cbb25b4a37d54b359b923d47c7 +C preset3 pref0 host3 st0 net0 gr0 x0 ifalse rootfalse refused:net,identity,fs 01f4011eeb0148fe010ac7e2de9f02a4415ee1af20404207f5daf950846a007c +C preset3 pref0 host3 st0 net0 gr1 x3 itrue rootfalse refused:net,identity,fs 01f4011eeb0148fe010ac7e2de9f02a4415ee1af20404207f5daf950846a007c +C preset3 pref0 host3 st0 net0 gr2 x6 ifalse rootfalse refused:net,identity,fs 01f4011eeb0148fe010ac7e2de9f02a4415ee1af20404207f5daf950846a007c +C preset3 pref0 host3 st0 net1 gr0 x0 ifalse rootfalse refused:net,identity,fs 01f4011eeb0148fe010ac7e2de9f02a4415ee1af20404207f5daf950846a007c +C preset3 pref0 host3 st0 net1 gr1 x3 itrue rootfalse refused:net,identity,fs 01f4011eeb0148fe010ac7e2de9f02a4415ee1af20404207f5daf950846a007c +C preset3 pref0 host3 st0 net1 gr2 x6 ifalse rootfalse refused:net,identity,fs 01f4011eeb0148fe010ac7e2de9f02a4415ee1af20404207f5daf950846a007c +C preset3 pref0 host3 st0 net2 gr0 x0 ifalse rootfalse refused:net,identity,fs 01f4011eeb0148fe010ac7e2de9f02a4415ee1af20404207f5daf950846a007c +C preset3 pref0 host3 st0 net2 gr1 x3 itrue rootfalse refused:net,identity,fs 01f4011eeb0148fe010ac7e2de9f02a4415ee1af20404207f5daf950846a007c +C preset3 pref0 host3 st0 net2 gr2 x6 ifalse rootfalse refused:net,identity,fs 01f4011eeb0148fe010ac7e2de9f02a4415ee1af20404207f5daf950846a007c +C preset3 pref0 host3 st0 net3 gr0 x0 ifalse rootfalse refused:net,identity,fs 01f4011eeb0148fe010ac7e2de9f02a4415ee1af20404207f5daf950846a007c +C preset3 pref0 host3 st0 net3 gr1 x3 itrue rootfalse refused:net,identity,fs 01f4011eeb0148fe010ac7e2de9f02a4415ee1af20404207f5daf950846a007c +C preset3 pref0 host3 st0 net3 gr2 x6 ifalse rootfalse refused:net,identity,fs 01f4011eeb0148fe010ac7e2de9f02a4415ee1af20404207f5daf950846a007c +C preset0 pref0 host4 st0 net0 gr0 x0 ifalse rootfalse ok:home-redirect 0b35165174de0b16e4d391edf4adbf6ed47895137f9c309584f821163e824809 +C preset0 pref0 host4 st0 net0 gr1 x3 itrue rootfalse ok:home-redirect c18389d46a57582ec89bcd4731e9da1c7650c7d7a03423faa311b4cb5e148059 +C preset0 pref0 host4 st0 net0 gr2 x6 ifalse rootfalse ok:home-redirect e30b8a072511f69b007c6a1f2df32031db861684b0f9ae463a087d5286933895 +C preset0 pref0 host4 st0 net1 gr0 x0 ifalse rootfalse ok:home-redirect 4f3be14db8d166b562c4c5c530bb34ccaf73502975fb96a4903a7d4338e5243d +C preset0 pref0 host4 st0 net1 gr1 x3 itrue rootfalse ok:home-redirect 4fa53dba7f2902658f9a0b08fed7e5e1e9d93b446ceac692e71ff51eee05e6d5 +C preset0 pref0 host4 st0 net1 gr2 x6 ifalse rootfalse ok:home-redirect 69c5a961addc9b40d7bff5f746767b5fe89945485eb52fe77a190d40167ef4c6 +C preset0 pref0 host4 st0 net2 gr0 x0 ifalse rootfalse refused:net 7d3a3727db31098b3774d3e70bbec91eacd88d87b77ea503aeeee8fcdbc0b71d +C preset0 pref0 host4 st0 net2 gr1 x3 itrue rootfalse refused:net 7d3a3727db31098b3774d3e70bbec91eacd88d87b77ea503aeeee8fcdbc0b71d +C preset0 pref0 host4 st0 net2 gr2 x6 ifalse rootfalse refused:net 7d3a3727db31098b3774d3e70bbec91eacd88d87b77ea503aeeee8fcdbc0b71d +C preset0 pref0 host4 st0 net3 gr0 x0 ifalse rootfalse ok:home-redirect 4f3be14db8d166b562c4c5c530bb34ccaf73502975fb96a4903a7d4338e5243d +C preset0 pref0 host4 st0 net3 gr1 x3 itrue rootfalse ok:home-redirect 4fa53dba7f2902658f9a0b08fed7e5e1e9d93b446ceac692e71ff51eee05e6d5 +C preset0 pref0 host4 st0 net3 gr2 x6 ifalse rootfalse ok:home-redirect 69c5a961addc9b40d7bff5f746767b5fe89945485eb52fe77a190d40167ef4c6 +C preset1 pref0 host4 st0 net0 gr0 x0 ifalse rootfalse ok:home-redirect 0b35165174de0b16e4d391edf4adbf6ed47895137f9c309584f821163e824809 +C preset1 pref0 host4 st0 net0 gr1 x3 itrue rootfalse ok:home-redirect c18389d46a57582ec89bcd4731e9da1c7650c7d7a03423faa311b4cb5e148059 +C preset1 pref0 host4 st0 net0 gr2 x6 ifalse rootfalse ok:home-redirect e30b8a072511f69b007c6a1f2df32031db861684b0f9ae463a087d5286933895 +C preset1 pref0 host4 st0 net1 gr0 x0 ifalse rootfalse ok:home-redirect 4f3be14db8d166b562c4c5c530bb34ccaf73502975fb96a4903a7d4338e5243d +C preset1 pref0 host4 st0 net1 gr1 x3 itrue rootfalse ok:home-redirect 4fa53dba7f2902658f9a0b08fed7e5e1e9d93b446ceac692e71ff51eee05e6d5 +C preset1 pref0 host4 st0 net1 gr2 x6 ifalse rootfalse ok:home-redirect 69c5a961addc9b40d7bff5f746767b5fe89945485eb52fe77a190d40167ef4c6 +C preset1 pref0 host4 st0 net2 gr0 x0 ifalse rootfalse refused:net 7d3a3727db31098b3774d3e70bbec91eacd88d87b77ea503aeeee8fcdbc0b71d +C preset1 pref0 host4 st0 net2 gr1 x3 itrue rootfalse refused:net 7d3a3727db31098b3774d3e70bbec91eacd88d87b77ea503aeeee8fcdbc0b71d +C preset1 pref0 host4 st0 net2 gr2 x6 ifalse rootfalse refused:net 7d3a3727db31098b3774d3e70bbec91eacd88d87b77ea503aeeee8fcdbc0b71d +C preset1 pref0 host4 st0 net3 gr0 x0 ifalse rootfalse ok:home-redirect 4f3be14db8d166b562c4c5c530bb34ccaf73502975fb96a4903a7d4338e5243d +C preset1 pref0 host4 st0 net3 gr1 x3 itrue rootfalse ok:home-redirect 4fa53dba7f2902658f9a0b08fed7e5e1e9d93b446ceac692e71ff51eee05e6d5 +C preset1 pref0 host4 st0 net3 gr2 x6 ifalse rootfalse ok:home-redirect 69c5a961addc9b40d7bff5f746767b5fe89945485eb52fe77a190d40167ef4c6 +C preset2 pref0 host4 st0 net0 gr0 x0 ifalse rootfalse refused:net,fs 3638ceea5a51eb70ab43f7569b7dd3972efe24cbb25b4a37d54b359b923d47c7 +C preset2 pref0 host4 st0 net0 gr1 x3 itrue rootfalse refused:net,fs 3638ceea5a51eb70ab43f7569b7dd3972efe24cbb25b4a37d54b359b923d47c7 +C preset2 pref0 host4 st0 net0 gr2 x6 ifalse rootfalse refused:net,fs 3638ceea5a51eb70ab43f7569b7dd3972efe24cbb25b4a37d54b359b923d47c7 +C preset2 pref0 host4 st0 net1 gr0 x0 ifalse rootfalse refused:net,fs 3638ceea5a51eb70ab43f7569b7dd3972efe24cbb25b4a37d54b359b923d47c7 +C preset2 pref0 host4 st0 net1 gr1 x3 itrue rootfalse refused:net,fs 3638ceea5a51eb70ab43f7569b7dd3972efe24cbb25b4a37d54b359b923d47c7 +C preset2 pref0 host4 st0 net1 gr2 x6 ifalse rootfalse refused:net,fs 3638ceea5a51eb70ab43f7569b7dd3972efe24cbb25b4a37d54b359b923d47c7 +C preset2 pref0 host4 st0 net2 gr0 x0 ifalse rootfalse refused:net,fs 3638ceea5a51eb70ab43f7569b7dd3972efe24cbb25b4a37d54b359b923d47c7 +C preset2 pref0 host4 st0 net2 gr1 x3 itrue rootfalse refused:net,fs 3638ceea5a51eb70ab43f7569b7dd3972efe24cbb25b4a37d54b359b923d47c7 +C preset2 pref0 host4 st0 net2 gr2 x6 ifalse rootfalse refused:net,fs 3638ceea5a51eb70ab43f7569b7dd3972efe24cbb25b4a37d54b359b923d47c7 +C preset2 pref0 host4 st0 net3 gr0 x0 ifalse rootfalse refused:net,fs 3638ceea5a51eb70ab43f7569b7dd3972efe24cbb25b4a37d54b359b923d47c7 +C preset2 pref0 host4 st0 net3 gr1 x3 itrue rootfalse refused:net,fs 3638ceea5a51eb70ab43f7569b7dd3972efe24cbb25b4a37d54b359b923d47c7 +C preset2 pref0 host4 st0 net3 gr2 x6 ifalse rootfalse refused:net,fs 3638ceea5a51eb70ab43f7569b7dd3972efe24cbb25b4a37d54b359b923d47c7 +C preset3 pref0 host4 st0 net0 gr0 x0 ifalse rootfalse refused:net,identity,fs 01f4011eeb0148fe010ac7e2de9f02a4415ee1af20404207f5daf950846a007c +C preset3 pref0 host4 st0 net0 gr1 x3 itrue rootfalse refused:net,identity,fs 01f4011eeb0148fe010ac7e2de9f02a4415ee1af20404207f5daf950846a007c +C preset3 pref0 host4 st0 net0 gr2 x6 ifalse rootfalse refused:net,identity,fs 01f4011eeb0148fe010ac7e2de9f02a4415ee1af20404207f5daf950846a007c +C preset3 pref0 host4 st0 net1 gr0 x0 ifalse rootfalse refused:net,identity,fs 01f4011eeb0148fe010ac7e2de9f02a4415ee1af20404207f5daf950846a007c +C preset3 pref0 host4 st0 net1 gr1 x3 itrue rootfalse refused:net,identity,fs 01f4011eeb0148fe010ac7e2de9f02a4415ee1af20404207f5daf950846a007c +C preset3 pref0 host4 st0 net1 gr2 x6 ifalse rootfalse refused:net,identity,fs 01f4011eeb0148fe010ac7e2de9f02a4415ee1af20404207f5daf950846a007c +C preset3 pref0 host4 st0 net2 gr0 x0 ifalse rootfalse refused:net,identity,fs 01f4011eeb0148fe010ac7e2de9f02a4415ee1af20404207f5daf950846a007c +C preset3 pref0 host4 st0 net2 gr1 x3 itrue rootfalse refused:net,identity,fs 01f4011eeb0148fe010ac7e2de9f02a4415ee1af20404207f5daf950846a007c +C preset3 pref0 host4 st0 net2 gr2 x6 ifalse rootfalse refused:net,identity,fs 01f4011eeb0148fe010ac7e2de9f02a4415ee1af20404207f5daf950846a007c +C preset3 pref0 host4 st0 net3 gr0 x0 ifalse rootfalse refused:net,identity,fs 01f4011eeb0148fe010ac7e2de9f02a4415ee1af20404207f5daf950846a007c +C preset3 pref0 host4 st0 net3 gr1 x3 itrue rootfalse refused:net,identity,fs 01f4011eeb0148fe010ac7e2de9f02a4415ee1af20404207f5daf950846a007c +C preset3 pref0 host4 st0 net3 gr2 x6 ifalse rootfalse refused:net,identity,fs 01f4011eeb0148fe010ac7e2de9f02a4415ee1af20404207f5daf950846a007c diff --git a/tests/requirements.toml b/tests/requirements.toml new file mode 100644 index 000000000..f1eedbfa4 --- /dev/null +++ b/tests/requirements.toml @@ -0,0 +1,734 @@ +# Requirement IDs and their status (design §24.4). +# +# Every ID here names a behaviour the SubOS design (or a finding it fixes) +# promises. Tests claim IDs with `covers = {...}` in their XTEST metadata; +# `xdev report --requirements tests/requirements.toml --fail-uncovered` +# fails when +# - a `required` ID has no test, or +# - a test names an ID this file does not declare (a typo would otherwise +# cover nothing and look like coverage). +# +# kind = "flow" any test covers it +# = "isolation" only a test with `proves = "isolation"` (one that runs +# a real sandbox) covers it; the fake provider proves +# that the path is wired, not that anything is isolated +# status = "required" built; must be covered +# = "planned" designed, its checkpoint has not landed yet; the +# commit that lands it flips the status +# = "deferred" not in this PR; `why` says so + +# ── testkit (T1) ──────────────────────────────────────────────────── + +[TK-RUN] +text = "the runner reports exit code, stdout and stderr" +status = "required" + +[TK-HERMETIC] +text = "a run starts from the environment the test names, nothing inherited" +status = "required" + +[TK-TIMEOUT] +text = "a timed-out run is killed with its process group" +status = "required" + +[TK-PTY] +text = "a pty with nothing typed exposes a program that waits for input" +status = "required" + +[TK-HOME] +text = "an isolated home lives outside the user's home and is removed" +status = "required" + +[TK-CAPS] +text = "an unknown capability fails; network is opt-in" +status = "required" + +[TK-META] +text = "XTEST metadata is registered before tests run" +status = "required" + +# ── findings (design §1) ──────────────────────────────────────────── + +[F1] +text = "inside a sandbox the xlings home is read-only except the instance's own tree" +kind = "isolation" +status = "required" + +[F2] +text = "X11 is unreachable unless granted (no host abstract sockets)" +kind = "isolation" +status = "required" + +[F3] +text = "only allow-listed environment variables enter the sandbox" +kind = "isolation" +status = "required" + +[F4] +text = "host processes are invisible (pid namespace)" +kind = "isolation" +status = "required" + +[F5] +text = "net=nat/none hides the host's network identity (ARP, interfaces)" +kind = "isolation" +status = "required" + +[F6] +text = "other instances' trees are invisible" +kind = "isolation" +status = "required" + +[F7] +text = "hostname, timezone and locale are neutral under private/locked" +kind = "isolation" +status = "required" + +[F8] +text = "terminal injection (TIOCSTI) is blocked" +kind = "isolation" +status = "required" + +[F10] +text = "backend lookup: root-owned bwrap, then a system bwrap that probes, then the payload; no setuid" +status = "required" + +[F11] +text = "--keep / --ttl mean session idle time (the keeper is replaced by the session)" +status = "required" + +[F12] +text = "a failed probe reports the raw error and least-privilege fixes, never a global sysctl" +status = "required" + +[F13] +text = "a lane that declares a capability fails when it is missing" +status = "required" + +[F14] +text = "a system install's shared config is not world-writable" +status = "required" + +[F15] +text = "a supervisor outside the sandbox writes the audit; the sandbox cannot reach it" +kind = "isolation" +status = "required" + +[F16] +text = "subos exec runs argv without a shell, from outside the instance" +status = "required" + +# ── subos use --cmd (existing contract, T1) ──────────────────────── + +[SUBOS-CMD-EXIT] +text = "--cmd returns the command's exit code" +status = "required" + +[SUBOS-CMD-HOME] +text = "--cmd runs with the sandbox's home" +status = "required" + +[SUBOS-CMD-WIN-QUOTING] +text = "Windows: cmd.exe quoting is not rewritten by the PowerShell path" +status = "required" + +# ── exit codes (design §12.2, §13.3) ─────────────────────────────── + +[EXIT-CMD] +text = "exec returns the command's own exit code" +status = "required" + +[EXIT-125] +text = "a failure before the command starts exits 125 with phase=setup" +status = "required" + +[EXIT-126] +text = "a command that cannot be executed exits 126" +status = "required" + +[EXIT-127] +text = "a command that is not found exits 127" +status = "required" + +[EXIT-124] +text = "--timeout exits 124" +status = "required" + +[EXIT-SIGNAL] +text = "a command killed by signal n exits 128+n" +status = "required" + +[EXIT-13] +text = "a denied permission exits 13 (E_PERMISSION)" +status = "required" + +[EXIT-75] +text = "a request waiting for approval exits 75" +status = "required" + +[EXIT-2-CONFIRM] +text = "agent mode without -y refuses a confirmation with exit 2" +status = "required" + +# ── agent contract (design §13) ──────────────────────────────────── + +[AGENT-NO-PROMPT] +text = "in agent mode no command waits for input" +status = "required" + +[AGENT-CANDIDATES] +text = "a choice without a default is an error with structured candidates" +status = "required" + +[AGENT-DECLARED] +text = "the audience is declared (--agent, XLINGS_AGENT_MODE=1/0), never inferred" +status = "required" + +[AGENT-ENV-PROPAGATES] +text = "XLINGS_AGENT_MODE reaches the xlings running inside a sandbox" +status = "required" + +# ── policy (design §7, §9) ───────────────────────────────────────── + +[POL-PRESETS] +text = "presets dev / private / locked compile to their documented specs" +status = "required" + +[POL-SANDBOX-ALIAS] +text = "--sandbox alone means the dev preset; --sandbox= selects one" +status = "required" + +[POL-UNKNOWN-REFUSED] +text = "an unknown security-relevant policy field refuses entry" +status = "required" + +[POL-OUTSIDE-INSTANCE] +text = "the policy file lives outside the instance and is read-only inside" +kind = "isolation" +status = "required" + +[POL-DECIDE] +text = "one decide() answers for the client and the broker" +status = "required" + +[POL-RULES] +text = "ordered rules: first match wins" +status = "required" + +[POL-PACK] +text = "a policy package supplies a policy by reference" +status = "required" + +[POL-CONFIG-STATUS] +text = "subos config edits the policy; subos status shows requested vs effective" +status = "required" + +# ── isolation spec and capabilities (design §16–§18) ─────────────── + +[ISO-SPEC-GOLDEN] +text = "Policy + Caps compile to a SandboxSpec that has golden tests" +status = "required" + +[ISO-PROBE-SAME-SPEC] +text = "the probe runs the same spec with /bin/true" +status = "required" + +[ISO-CAPS-CACHE] +text = "capabilities are cached per kernel/boot/provider/version" +status = "required" + +[ISO-MUST-SHOULD] +text = "a missing must-item refuses (125); a missing should-item degrades and says so" +status = "required" + +[ISO-NO-DEGRADE] +text = "--no-degrade turns any missing item into a refusal" +status = "required" + +[ISO-LANDLOCK] +text = "the Landlock provider restricts writes without namespaces" +kind = "isolation" +status = "required" + +[ISO-MOUNT] +text = "--mount src:dst[:ro|rw] maps a host path into the instance" +kind = "isolation" +status = "required" + +[ISO-GRANTS] +text = "--allow opens exactly one named capability" +status = "required" + +[ISO-NET-NONE] +text = "net=none leaves only loopback" +kind = "isolation" +status = "required" + +[ISO-NET-NAT] +text = "net=nat gives egress through pasta with a private namespace" +kind = "isolation" +status = "required" + +[ISO-NET-PROXY] +text = "net=proxy: egress only through the declared proxy" +kind = "isolation" +status = "required" + +[OBS-NET] +text = "nat and proxy network attempts are journaled before the supervisor permits them" +status = "required" + +[ISO-DISABLE-USERNS] +text = "private / locked forbid nested user namespaces" +kind = "isolation" +status = "required" + +[ISO-ROOTFS] +text = "an instance can run on its own rootfs" +kind = "isolation" +status = "required" + +# ── sessions (design §12, §16) ───────────────────────────────────── + +[SES-SUPERVISOR] +text = "entering forks and supervises instead of exec-replacing xlings" +status = "required" + +[SES-JOIN] +text = "use / exec join a running session of the same instance" +status = "required" + +[SES-START-STOP] +text = "subos start / stop manage a detached session with an idle TTL" +status = "required" + +[SES-TEMP] +text = "exec --temp runs in a throwaway instance whose audit survives it" +status = "required" + +[SES-CP] +text = "subos cp copies files in and out" +status = "required" + +[SES-SIGNALS] +text = "SIGTERM/SIGHUP reach the session; the supervisor ignores SIGINT while waiting" +status = "required" + +# ── broker and permissions (design §8, §9) ───────────────────────── + +[PERM-FETCH-AUTO] +text = "fetch=auto installs a missing package from inside the sandbox" +status = "required" + +[PERM-FETCH-ASK] +text = "fetch=ask queues a request; approve runs it" +status = "required" + +[PERM-FETCH-DENY] +text = "fetch=deny refuses with E_PERMISSION" +status = "required" + +[PERM-INSTALL-SUBOS] +text = "install --subos installs into that instance from outside" +status = "required" + +# ── observability (design §22) ───────────────────────────────────── + +[OBS-LIFECYCLE] +text = "session start/end events with the isolation snapshot" +status = "required" + +[OBS-OPS] +text = "every xlings command run for an instance is recorded" +status = "required" + +[OBS-EXEC] +text = "every program executed in a locked sandbox is recorded" +kind = "isolation" +status = "required" + +[OBS-PERM] +text = "permission decisions are recorded" +status = "required" + +[OBS-FS] +text = "files changed in rw mounts are listed at session end" +status = "required" + +[OBS-REDACT] +text = "audit lines carry variable names, never values" +status = "required" + +[OBS-LOG] +text = "subos log / ps / report read the audit" +status = "required" + +[OBS-IFACE-EVENTS] +text = "the interface exposes subos_events" +status = "required" + +[IFACE-EXEC-STREAM] +text = "subos_exec streams distinct stdout/stderr chunks before exit and reaps the command on cancellation" +status = "required" + +[OBS-TRACE] +text = "XLINGS_TRACE= prints the decision trace" +status = "required" + +# ── home and deployment (design §3–§6) ───────────────────────────── + +[HOME-CONTEXT] +text = "one HomeContext answers which home and which mode" +status = "required" + +[HOME-MODE-DECLARED] +text = ".xlings-home declares mode and layout" +status = "required" + +[HOME-LAYOUT-READONLY] +text = "a higher layout than this client knows is read, never written" +status = "required" + +[HOME-KEEP-UNKNOWN-KEYS] +text = "writers keep keys they do not know" +status = "required" + +[HOME-SYSTEM-MODE] +text = "a system install (mode S): the entry left to its package manager, /etc/xlings/config.json as defaults, the system layer found and reported" +status = "required" + +[PERM-FETCH-LAYER] +text = "fetch=layer: what the xlings inside installs goes into the root's own scope; a view refuses it" +status = "required" + +[HOME-LAYER-RESOLVE] +text = "packages in the system layer (M) resolve in a user's shell without a copy, after the user's own" +status = "required" + +# ── compatibility and platforms ──────────────────────────────────── + +[COMPAT-ALIASES] +text = "--gpu, --cmd, --keep, --ttl keep working" +status = "required" + +[COMPAT-UNDECLARED] +text = "an instance with no policy keeps its behaviour apart from the S0 fixes" +status = "required" + +[PLAT-HINT] +text = "an unsupported item prints the shared hint format on every platform" +status = "required" + +[PLAT-STATUS-MATRIX] +text = "subos status prints the probed capability matrix" +status = "required" + +[DOC-ISOLATION] +text = "self doctor --isolation diagnoses; --fix installs the root-owned backend" +status = "required" + +[PERF-SHIM] +text = "static shim dispatch inside a sandbox avoids the broker and adds less than 10 ms" +status = "required" + +[PERF-EXEC-HOT] +text = "static hot-session exec adds at most 5 ms over direct exec measured with the same runner" +status = "required" + +[PERF-EXEC-COLD] +text = "static cold exec adds at most 10 ms over the equivalent direct bwrap provider invocation" +status = "required" + +[PERF-ROOT-ENTRY] +text = "entering an actual rootfs with its checked closure adds at most 100 ms over view cold exec" +status = "required" + +[POL-DECLARE-AT-NEW] +text = "subos new --sandbox declares the isolation once; entries need no flag; a fork inherits its source's" +status = "required" + +[SUBOS-DOCTOR] +text = "subos doctor: per instance, the policy, entry on this host, the policy package, sessions" +status = "required" + +# ── part 2: the host is a SubOS (design part 2 §14) ────────────────── +# `planned` until its checkpoint lands with a test, then `required`. + +[DEPLOY-S-ENTRY] +text = "a system entry: a fresh home links its entry to it, so what it installs runs; self update still refuses" +status = "required" + +[DEPLOY-S-AUR] +text = "the AUR package of the current build: two users each install and run a package" +status = "required" + +[DEPLOY-M-SYSTEM] +text = "install --system into /xlings; users use it without a copy, override it, cannot write it" +status = "required" + +[DOM-PREFIX] +text = "searched locations (interp, rpath/runpath, .pc, .la, shebangs) name only the prefix domain" +status = "required" + +[DOM-BUILD-INSIDE] +text = "a root's payloads are installed with its home at the path the root sees it at; no recipe changes (a multi image is built in /xlings and runs there)" +status = "required" + +[DOM-BOOTSTRAP] +text = "inside a tiny root (no host), xlings installs packages with the root's own tools" +status = "required" + +[DOM-ELFPATCH-FAILCLOSED] +text = "no patchelf: xim:patchelf is installed first; still none: the package fails, never 'installed' unpatched" +status = "required" + +[DOM-LAYOUTS] +text = "deployment R in both layouts: multi (/xlings) and single (/home//.xlings)" +status = "required" + +[ECO-HOST-INDEPENDENT] +text = "every form-X executable of tiny/core resolves its closure in an empty root; programs run with no mapping outside it" +status = "required" + +[SHIM-NO-SHELL] +text = "a shim (aliases included) dispatches in a root with no /bin/sh" +status = "required" + +[ROOT-ROLE-TABLE] +text = "every subos command x every kind/role is accepted or refused by the one table" +status = "required" + +[ROOT-PROJECT] +text = "root projection: merged-usr links, /usr at the current generation, direct links" +status = "required" + +[ROOT-SAME-TREE] +text = "one declaration gives the same tree as a bwrap instance, a container and a boot" +status = "required" + +[ROOT-GEN-ATOMIC] +text = "a generation switch is one rename; the pointer is old or new, never neither" +status = "required" + +[ROOT-ROLLBACK] +text = "rollback moves the pointer; it does not re-run use" +status = "required" + +[ROOT-ETC-FACTORY] +text = "factory /etc fills only what is missing; sysusers only appends" +status = "required" + +[ROOT-LIBSEARCH] +text = "a host-built program (PT_INTERP /lib64/ld-linux) runs in a root, finding its libraries through /lib64 = the root's /usr/lib" +status = "required" + +[ROOT-NO-HOST] +text = "a process in a root maps nothing from outside it" +status = "required" + +[ROOT-SURVIVES-GLIBC] +text = "with glibc broken, the static xlings rolls the root back" +status = "required" + +[ROOT-SELF-UPDATE] +text = "in deployment R, self update is a new generation of the host SubOS and can be rolled back" +status = "required" + +[INST-ROOTFS] +text = "--rootfs instance: uid 0 inside, no host /usr, policy, broker and audit as for any instance" +status = "required" + +[EXPORT-OCI] +text = "subos export --tar is a root docker imports and runs" +status = "required" + +[EXPORT-WSL] +text = "subos export --tar imports with wsl --import (WSL1 on CI: reported, not blocking)" +status = "required" + +[EXPORT-DISK] +text = "subos export --disk boots under qemu" +status = "required" + +[BOOT-STAGE0] +text = "stage-0 picks the host from boot.json, points /usr at it and execs its init" +status = "required" + +[BOOT-ONCE-FALLBACK] +text = "a trial boot not marked good falls back; marked good it becomes the default" +status = "required" + +[BOOT-NOW] +text = "subos boot --now switches user space where the init supports it, refuses elsewhere" +status = "required" + +[LUBAN-TINY] +text = "luban-tiny: created from its xpkg, exported, booted, installs inside" +status = "required" + +[LUBAN-CORE] +text = "luban-core: compiles and runs a program inside" +status = "required" + +[LUBAN-DESKTOP-RENDER] +text = "luban-desktop: a GL program compiled inside renders a frame offscreen, every GL object from the payloads" +status = "required" + +[LUBAN-FROM-CHAIN] +text = "an xpkg's from chain merges workspaces, the upper one winning" +status = "required" + +[ROOT-LDCACHE] +text = "a root's own /etc/ld.so.cache (ldconfig) is read by programs that enter through /lib64" +status = "required" + +[OUTPUT-PRESERVE] +text = "pack/export refuse existing outputs; cleanup only removes exclusively owned staging" +status = "required" + +[STATE-STRICT] +text = "missing legacy metadata defaults; invalid or unreadable present SubOS/boot metadata is refused" +status = "required" + +[STATE-KEEP-UNKNOWN] +text = "SubOS/boot metadata writes preserve unknown fields" +status = "required" + +[OBS-AUDIT-FAIL] +text = "locked refuses startup and terminates execution when its audit journal cannot be written" +status = "required" + +[ROOT-REFRESH-FAIL] +text = "workspace write commands fail when their root projection cannot be updated" +status = "required" + +[POLICY-MIN-CLIENT] +text = "policy declares a minimum client; unsupported clients refuse and doctor explains compatibility" +status = "required" + +[REPORT-EXECUTION-EVIDENCE] +text = "report distinguishes declaration coverage from passed evidence; skipped tests cannot verify requirements" +status = "required" + +[CI-TEST-SELECTION] +text = "ci plan and test share real discovery, transitive change impact, capability selection and deterministic duration-aware shards without duplicates or omissions" +status = "required" + +[CI-RESOURCE-LOCKS] +text = "test resources are locked across concurrent runners with deterministic acquisition and automatic release" +status = "required" + +[TEST-HTTP-FIXTURE] +text = "local HTTP fixtures serve owned test data on loopback without external network access" +status = "required" + +[CI-TRENDS] +text = "Reports retain per-platform execution trends, flag intermittent results, and export measured shard weights" +kind = "flow" +status = "required" + +[ROOT-STORE-CLOSURE] +text = "Root sessions expose only checked runtime payloads and immutable generations, with synchronous broker refresh" +kind = "isolation" +status = "required" + +[DOM-SOURCE] +text = "A private logical-prefix domain borrows only proved read-only system payloads and exports its checked closure as image-owned data" +kind = "isolation" +status = "required" + +[PERF-GEN-BUILD] +text = "Building a root generation from 300 package payloads takes at most one second" +kind = "flow" +status = "required" + +[PERF-GEN-SWITCH] +text = "Switching an existing root generation takes at most ten milliseconds" +kind = "flow" +status = "required" + +[PERF-STAGE0] +text = "Real PID 1 startup through preparing exec init takes at most 200 milliseconds, including fallback and boot --now" +kind = "flow" +status = "required" + +# ── SubOS design part 3 (C43-C53) ─────────────────────────────────── + +[LAYOUT-DEPS] +text = "packages import downward only: luban -> modules, modules -/-> luban or the frontend; platform selection is if constexpr" +status = "required" + +[ROOT-GC-ROOT] +text = "a payload a retained generation links into is not deleted by remove; rolling back to that generation finds every target" +status = "required" + +[ROOT-GEN-RETAIN] +text = "a SubOS keeps kKeepGenerations besides its current one; what only pruned ones held is released; a pointer naming no generation prunes nothing" +status = "required" + +[ROOT-GEN-DURABLE] +text = "a generation and its pointer reach stable storage before the switch is reported (fsync of files and directories)" +status = "required" + +[ROOT-SWITCH-SCALE] +text = "a checked switch reads a generation's change stamps, not every link: 300 payloads within 10 ms, 3000 within 20 ms" +status = "required" + +[TOOL-RESOLVE] +text = "every external tool is resolved in one place (in-process > xlings payload > declared host tool) and reported with its source" +status = "required" + +[INTENT-EQ] +text = "the Intent IR and the confine registry produce byte-identical bwrap argv to the code they replaced" +status = "required" + +[GATE-CONFORM] +text = "the platform matrix is the implementations' probes, not a hand-kept table" +status = "required" + +[CARRIER-LOCAL] +text = "where a SubOS runs is chosen by what it is (part 3 §5.6), recorded and listed; a carrier is reached through a launcher prefix and the NDJSON interface" +status = "required" + +[CARRIER-WSL-LIFECYCLE] +text = "a wsl2 carrier is created per home on first need, reached through wsl.exe, stopped when idle, unregistered only by a confirmed removal" +status = "required" + +[CARRIER-WSL-INTEROP-OFF] +text = "inside a wsl2 carrier, Windows executables cannot run and /mnt/c is not mounted" +status = "required" + +[CARRIER-WSL-GRANT] +text = "only granted host directories are visible in a wsl2 carrier, and only for the session" +status = "required" + +[CARRIER-UNAVAILABLE] +text = "without a usable carrier a Must refuses with the route and a native SubOS is unaffected" +status = "required" + +[VIEW-CARRIER-EQ] +text = "one content declaration yields the same root view on every carrier" +status = "deferred" +why = "needs a real guest kernel (WSL2 or a VZ VM): hosted runners have no nested virtualization, and the stand-in wsl.exe runs on the host kernel -- it proves routing, not the guest root; tests/e2e/windows_carrier_wsl2_test.ps1 runs it wherever WSL2 exists" + +[CARRIER-VZ-LIFECYCLE] +text = "a vz carrier VM is created per home on first need through its helper's contract, started when stopped, reached through it, and grants are the helper's virtiofs shares" +status = "required" + +[CARRIER-VZ-HELPER] +text = "xlings-vm, the signed Virtualization.framework helper that answers the vz carrier's contract, is published as a payload" +status = "deferred" +why = "a separate Swift package signed with com.apple.security.virtualization; hosted macOS runners cannot nest a VM to test it. Until it is published the vz carrier is refused with `xlings install xlings-vm` as its route (CARRIER-UNAVAILABLE)" + +[SESSION-NATIVE-SUPERVISED] +text = "a native SubOS on macOS runs under the supervisor (start, join, stop, --timeout, the exit-code table); on Windows its command runs in a Job Object (tree ended at --timeout, the command's exit code); joining a running session on Windows needs a CreateProcess supervisor and is part 3 §6.3's next step" +status = "required" + +[OPENKAL-COEXIST] +text = "openkal-linux links and runs beside xlings' C and C++ runtimes (glibc and the static musl release target)" +status = "deferred" +why = "evidence, not a product behaviour: the xlings-ci-linux step 'openkal coexistence' builds and runs tools/openkal-coexistence for both targets on every PR; nothing of xlings links openkal yet (part 3 §19, C53)" + +[LUBAN-INIT] +text = "stage-0 is its own static binary and reads no Config" +status = "required" diff --git a/tests/scripts/test_docs_examples.py b/tests/scripts/test_docs_examples.py index dde238399..67805e4cc 100644 --- a/tests/scripts/test_docs_examples.py +++ b/tests/scripts/test_docs_examples.py @@ -43,7 +43,7 @@ def aliases(option): if not line.startswith("xlings "): continue try: - tokens = shlex.split(line) + tokens = shlex.split(line, comments=True) except ValueError as error: raise SystemExit(f"unparseable documented command: {line}: {error}") if len(tokens) < 2: @@ -62,6 +62,9 @@ def aliases(option): allowed = root_options | set().union( *(aliases(o) for o in node["options"])) for token in tokens[2:]: + # `--` ends the options: what follows is the command's own argv. + if token == "--": + break if not token.startswith("-"): continue option = token.split("=", 1)[0] diff --git a/tests/scripts/test_i18n_coverage.py b/tests/scripts/test_i18n_coverage.py index 069cb283b..2ced95c11 100644 --- a/tests/scripts/test_i18n_coverage.py +++ b/tests/scripts/test_i18n_coverage.py @@ -85,7 +85,7 @@ def run(args, lang): # Counted from the catalogue rather than the output: the output only shows one # command at a time, and the point is that the whole surface is covered. zh = (pathlib.Path(__file__).resolve().parents[2] - / "modules/i18n/src/i18n/zh.cppm").read_text() + / "modules/ui/src/i18n/zh.cppm").read_text() entries = len(re.findall(r'^\s*\{\s*"', zh, re.M)) FLOOR = 100 if entries < FLOOR: diff --git a/tests/scripts/test_selector_contrast.py b/tests/scripts/test_selector_contrast.py index 80ae8a3e0..20a66bd9f 100644 --- a/tests/scripts/test_selector_contrast.py +++ b/tests/scripts/test_selector_contrast.py @@ -34,7 +34,7 @@ def contrast(fg, bg): return (hi + 0.05) / (lo + 0.05) -# The default dark theme's surface, from modules/theme. Read rather than +# The default dark theme's surface, from modules/ui/src/theme.cppm. Read rather than # hardcoded so a theme change moves this test with it. def dark_slot(name): src = (ROOT / "src" / "core" / "palette.cppm").read_text() diff --git a/tests/suites.toml b/tests/suites.toml new file mode 100644 index 000000000..3d7149c6e --- /dev/null +++ b/tests/suites.toml @@ -0,0 +1,74 @@ +# Legacy test suites, run by `xdev test --suite ` (design §24.3). +# +# These are the shell and Python scripts that predate the C++ test library. +# They run unchanged through xdev's adapter: one record per command, its exit +# code, its duration and the tail of its log, in the same report as the C++ +# tests. New tests are written in C++ (tests/**/test_*.cpp with XTEST); a +# script leaves this file when it is ported. +# +# Placeholders: {xlings} is the binary under test, {tarball} is --tarball. + +# Contracts that need only a built binary: no home, no network. +[contract-scripts] +platforms = ["linux"] +commands = [ + "bash tests/scripts/test_quick_install.sh", + "python3 tests/scripts/test_release_candidate_gate.py", + "python3 tests/scripts/test_index_pointer_history.py", + "python3 tests/scripts/test_version_consistency.py", + "python3 tests/scripts/test_windows_header_hygiene.py", + "python3 tests/scripts/test_selector_contrast.py", + "python3 tests/scripts/test_docs_examples.py --xlings {xlings}", + "python3 tests/scripts/test_generated_command_reference.py --xlings {xlings}", + "python3 tests/scripts/test_cli_spec_parity.py --xlings {xlings}", + "XLINGS_BIN={xlings} python3 tests/scripts/test_i18n_coverage.py", +] + +# Repository rules checked without a build. +[lint] +commands = [ + "bash tests/fresh-install/no_xlings_version_pin_check.sh", + "bash tools/lint_subos_remove_all.sh", + "bash tools/lint_platform_headers.sh", + "bash tools/lint_platform_branches.sh", + "bash tools/lint_layer_deps.sh", + "bash tools/lint_tool_resolve.sh", +] + +# The end-to-end shell suite against a release tarball. +[e2e-shell] +platforms = ["linux"] +commands = [ + "bash tests/e2e/run_all.sh {tarball}", + "bash tests/e2e/luban_init_test.sh {tarball}", +] + +# What needs root on a stock Ubuntu 24.04 (CI-only: it uses sudo): the +# isolation repair, and a system package's entry (deployment S). +[isolation-fix] +platforms = ["linux"] +commands = [ + "bash tests/e2e/isolation_doctor_fix_test.sh", + "bash tests/e2e/system_install_test.sh", + "bash tests/e2e/system_layer_test.sh", +] + +# The root projection (SubOS design part 2): Luban roots made from the +# published editions, entered in bwrap, exported to docker, booted by qemu. +# CI-only: the `distro` job (bwrap, docker, qemu/kvm, sudo). +[distro] +platforms = ["linux"] +commands = [ + "bash tests/e2e/rootfs_instance_test.sh", + "bash tests/e2e/rootfs_image_test.sh", + "bash tests/e2e/rootfs_boot_test.sh", +] + +# Deployment S as a distribution packages it: the AUR recipe built from the +# binary under test and used by two users (CI-only: root in an archlinux +# container, the `arch-package` job). +[aur] +platforms = ["linux"] +commands = [ + "bash tests/e2e/aur_package_test.sh {tarball}", +] diff --git a/tests/unit/test_confine.cpp b/tests/unit/test_confine.cpp new file mode 100644 index 000000000..90052a770 --- /dev/null +++ b/tests/unit/test_confine.cpp @@ -0,0 +1,95 @@ +// The confine registry (SubOS design part 3 §6.2): implementations, the +// selector, and a platform matrix made of their claims. +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.subos.caps; +import xlings.subos.policy; +import xlings.subos.spec; +import xlings.subos.home_view; +import xlings.confine; +import xlings.confine.gates; + +namespace sp = xlings::subos::spec; +namespace caps = xlings::subos::caps; +namespace cf = xlings::confine; +namespace gates = xlings::confine::gates; + +namespace { + +caps::Caps linux_host(bool bwrap, bool proot, int landlock) { + caps::Caps c; + c.platform = "linux"; + if (bwrap) c.bwrap = caps::Backend{.name = "bwrap", .bin = "/opt/bwrap", .source = "payload", .usable = true}; + if (proot) c.proot = caps::Backend{.name = "proot", .bin = "/opt/proot", .source = "payload", .usable = true}; + c.landlock_abi = landlock; + c.seccomp = true; + return c; +} + +const gates::Status& row(const std::vector& m, std::string_view g) { + return *std::ranges::find(m, g, &gates::Status::gate); +} + +} // namespace + +XTEST(Confine, EveryBackendHasOneImplementationAndEveryClaimNamesAnInterface, + .area = "subos", .covers = {"GATE-CONFORM"}) { + std::set seen; + for (const auto& impl : cf::implementations()) { + EXPECT_TRUE(seen.insert(impl.backend).second) << impl.name; + EXPECT_EQ(cf::find(impl.backend), &impl); + ASSERT_TRUE(impl.available && impl.view && impl.processes && impl.finish) << impl.name; + } + const std::set known{"FsGate", "ProcessScope", "NetGate", "DeviceGate", + "IdentityShim", "ExecTracer", "SessionHost", "RootfsRuntime"}; + caps::Caps mac, win; + mac.platform = "macos"; + win.platform = "windows"; + for (const auto& host : {linux_host(true, true, 3), linux_host(false, true, 0), mac, win}) { + for (const auto& impl : cf::implementations()) + if (impl.gates) + for (const auto& claim : impl.gates(host)) EXPECT_TRUE(known.contains(claim.gate)) << claim.gate; + const auto m = gates::probe(host); + ASSERT_EQ(m.size(), known.size()) << host.platform; + for (const auto& s : m) EXPECT_TRUE(known.contains(s.gate)); + } +} + +XTEST(Confine, TheMatrixIsTheStrongestClaimAndNamesTheRouteWhenNothingClaims, + .area = "subos", .covers = {"GATE-CONFORM"}) { + // Landlock's kernel fence outranks proot's advisory view. + const auto fenced = gates::probe(linux_host(false, true, 3)); + EXPECT_EQ(row(fenced, "FsGate").enforced, gates::Enforced::Kernel); + EXPECT_NE(row(fenced, "FsGate").reason.find("Landlock"), std::string::npos); + const auto view = gates::probe(linux_host(false, true, 0)); + EXPECT_EQ(row(view, "FsGate").enforced, gates::Enforced::Advisory); + const auto none = gates::probe(linux_host(false, false, 0)); + EXPECT_FALSE(row(none, "FsGate").supported); + EXPECT_FALSE(row(none, "RootfsRuntime").route.empty()); + EXPECT_TRUE(row(none, "SessionHost").supported) << "the session host is not a backend's"; +} + +XTEST(Confine, TheSelectorHonoursANamedBackendOrSaysWhyNotAndLandlockStartsAsThisBinary, + .area = "subos", .covers = {"GATE-CONFORM", "INTENT-EQ"}) { + sp::Request r; + r.instance = "box"; + r.instance_dir = "/h/subos/box"; + r.user = "u"; + r.argv = {"make"}; + r.host_exists = [](std::string_view) { return true; }; + const xlings::subos::HomeView home{"/h"}; + r.preferred = sp::Backend::Landlock; + auto refused = cf::compile(xlings::subos::policy::legacy(), home, linux_host(true, false, 0), r); + ASSERT_FALSE(refused); + EXPECT_EQ(refused.error().missing.front().dimension, "backend"); + auto fenced = cf::compile(xlings::subos::policy::legacy(), home, linux_host(true, false, 2), r); + ASSERT_TRUE(fenced); + EXPECT_EQ(fenced->backend, sp::Backend::Landlock); + auto launched = *fenced; + launched.argv = {"/run/xlings/xlings", "__session-init", "--", "make"}; + EXPECT_EQ(cf::launch_argv(launched, std::nullopt, "/self/xlings"), + (std::vector{"/self/xlings", "__session-init", "--", "make"})); +} diff --git a/tests/unit/test_core_basics.cpp b/tests/unit/test_core_basics.cpp index d4d5bad10..fe1f6b0f1 100644 --- a/tests/unit/test_core_basics.cpp +++ b/tests/unit/test_core_basics.cpp @@ -44,7 +44,7 @@ import xlings.platform; import xlings.libs.json; import xlings.core.xself; import xlings.core.profile; -import xlings.core.subos.gpu; +import xlings.subos.gpu; import xlings.core.xim.downloader; import xlings.runtime; import xlings.capabilities; diff --git a/tests/unit/test_domain_entry.cpp b/tests/unit/test_domain_entry.cpp new file mode 100644 index 000000000..2d06b1083 --- /dev/null +++ b/tests/unit/test_domain_entry.cpp @@ -0,0 +1,186 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" +import std; +import xlings.core.home.prefix_domain; +import xlings.core.home.domain_producer; +import xlings.core.entry_binary; +import xlings.core.xvm.shim_identity; +import xlings.libs.json; +import xlings.libs.sha256; +import xlings.core.xvm.lock; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; +namespace domain = xlings::home::prefix_domain; +namespace producer = xlings::home::domain_producer; +using Json = nlohmann::json; + +namespace { +fs::path client_(const fs::path& path, std::string_view version) { + tk::write_file(path, "#!/bin/sh\nprintf 'xlings " + std::string(version) + "\\n'\n# " + + std::string(xlings::xvm::kMulticallMarker) + "\n"); + fs::permissions(path, fs::perms::owner_all, fs::perm_options::replace); + return path; +} +Json record_(const domain::Domain& selected) { + return Json::parse(tk::read_file(selected.physicalHome / ".xlings-domain-entry.json")); +} +void write_record_(const domain::Domain& selected, const Json& record) { + tk::write_file(selected.physicalHome / ".xlings-domain-entry.json", record.dump()); +} +} + +XTEST(DomainEntry, FollowsTheCurrentCallerAcrossPathsAndSamePathUpgrades, .area = "home", + .covers = {"DOM-BOOTSTRAP", "DOM-BUILD-INSIDE"}, .requires_ = {"posix"}) { + if constexpr (tk::is_windows) GTEST_SKIP() << "private domains use POSIX namespaces"; + auto home = tk::Home::isolated("domain-entry-upgrade"); + auto selected = domain::resolve(home.dir(), "/xlings", home.root() / "absent-system"); + ASSERT_TRUE(selected) << selected.error(); + const auto first = client_(home.root() / "old-build/xlings", "2026.10.7.1"); + const auto next = client_(home.root() / "different-build/xlings", "2026.10.8.1"); + ASSERT_TRUE(producer::prepare(*selected, first)); + const auto entry = selected->physicalHome / "bin/xlings"; + EXPECT_EQ(record_(*selected)["schema"], 2); + EXPECT_EQ(record_(*selected)["sha256"], *xlings::sha256::hex_file(first)); + tk::write_file(selected->physicalHome / "data/keep", "scope data remains"); + ASSERT_TRUE(producer::refresh_entry(*selected, next)); + EXPECT_EQ(tk::read_file(entry), tk::read_file(next)); + EXPECT_EQ(record_(*selected)["source"], fs::canonical(next).generic_string()); + client_(next, "2026.10.8.2"); + ASSERT_TRUE(producer::refresh_entry(*selected, next)); + EXPECT_EQ(tk::read_file(entry), tk::read_file(next)); + EXPECT_EQ(record_(*selected)["state"], "ready"); + EXPECT_EQ(record_(*selected)["sha256"], *xlings::sha256::hex_file(next)); + EXPECT_EQ(tk::read_file(selected->physicalHome / "data/keep"), "scope data remains"); +} + +XTEST(DomainEntry, RefusesUnknownOrModifiedEntriesAndUnmarkedSources, .area = "home", + .covers = {"DOM-BOOTSTRAP"}, .requires_ = {"posix"}) { + if constexpr (tk::is_windows) GTEST_SKIP() << "private domains use POSIX namespaces"; + auto home = tk::Home::isolated("domain-entry-foreign"); + auto selected = domain::resolve(home.dir(), "/xlings", home.root() / "absent-system"); + ASSERT_TRUE(selected); + const auto first = client_(home.root() / "client-one", "2026.10.7.1"); + const auto next = client_(home.root() / "client-two", "2026.10.8.1"); + ASSERT_TRUE(producer::prepare(*selected, first)); + const auto entry = selected->physicalHome / "bin/xlings"; + const auto proof = selected->physicalHome / ".xlings-domain-entry.json"; + const auto before = tk::read_file(proof); + tk::write_file(entry, "user custom client"); + EXPECT_FALSE(producer::refresh_entry(*selected, next)); + EXPECT_EQ(tk::read_file(entry), "user custom client"); + EXPECT_EQ(tk::read_file(proof), before); + fs::remove(proof); + EXPECT_FALSE(producer::refresh_entry(*selected, next)); + EXPECT_EQ(tk::read_file(entry), "user custom client"); + tk::write_file(proof, before); + tk::write_file(entry, tk::read_file(first)); + tk::write_file(next, "foreign executable"); + EXPECT_FALSE(producer::refresh_entry(*selected, next)); + EXPECT_EQ(tk::read_file(entry), tk::read_file(first)); + EXPECT_EQ(tk::read_file(proof), before); + tk::write_file(proof, "{invalid"); + EXPECT_FALSE(producer::refresh_entry(*selected, first)); + EXPECT_EQ(tk::read_file(proof), "{invalid"); +} + +XTEST(DomainEntry, RecoversOnlyTheOldOrNewDigestAuthorizedByTheJournal, .area = "home", + .covers = {"DOM-BOOTSTRAP"}, .requires_ = {"posix"}) { + if constexpr (tk::is_windows) GTEST_SKIP() << "private domains use POSIX namespaces"; + auto home = tk::Home::isolated("domain-entry-journal"); + auto selected = domain::resolve(home.dir(), "/xlings", home.root() / "absent-system"); + ASSERT_TRUE(selected); + const auto first = client_(home.root() / "client-one", "2026.10.7.1"); + const auto next = client_(home.root() / "client-two", "2026.10.8.1"); + ASSERT_TRUE(producer::prepare(*selected, first)); + const auto entry = selected->physicalHome / "bin/xlings"; + const auto initial = record_(*selected); + auto pending = initial; + pending["state"] = "pending"; + pending["pending_sha256"] = *xlings::sha256::hex_file(next); + const auto interruptedStage = fs::path(entry.string() + ".xlings.new"); + pending["pending_staging"] = interruptedStage.generic_string(); + tk::write_file(interruptedStage, tk::read_file(next)); + write_record_(*selected, pending); + ASSERT_TRUE(producer::refresh_entry(*selected, next)); + EXPECT_EQ(record_(*selected)["state"], "ready"); + EXPECT_FALSE(fs::exists(interruptedStage)); + EXPECT_EQ(tk::read_file(entry), tk::read_file(next)); + // Model a crash after the executable swap but before the proof commit. + write_record_(*selected, pending); + ASSERT_TRUE(producer::refresh_entry(*selected, next)); + EXPECT_EQ(record_(*selected)["sha256"], *xlings::sha256::hex_file(next)); + EXPECT_FALSE(record_(*selected).contains("pending_sha256")); + write_record_(*selected, pending); + tk::write_file(entry, "an unauthorized third client"); + EXPECT_FALSE(producer::refresh_entry(*selected, next)); + EXPECT_EQ(tk::read_file(entry), "an unauthorized third client"); + EXPECT_EQ(record_(*selected), pending); +} + +XTEST(DomainEntry, MigratesLegacyOnlyWhenItsOriginalBytesAreStillProved, .area = "home", + .covers = {"DOM-BOOTSTRAP"}, .requires_ = {"posix"}) { + if constexpr (tk::is_windows) GTEST_SKIP() << "private domains use POSIX namespaces"; + auto home = tk::Home::isolated("domain-entry-legacy"); + auto selected = domain::resolve(home.dir(), "/xlings", home.root() / "absent-system"); + ASSERT_TRUE(selected); + const auto first = client_(home.root() / "original-build", "2026.10.7.1"); + const auto next = client_(home.root() / "new-build", "2026.10.8.1"); + ASSERT_TRUE(producer::prepare(*selected, first)); + write_record_(*selected, Json{{"schema", 1}, {"source", first.generic_string()}}); + ASSERT_TRUE(producer::refresh_entry(*selected, next)); + EXPECT_EQ(record_(*selected)["schema"], 2); + const auto entry = selected->physicalHome / "bin/xlings"; + const auto before = tk::read_file(entry); + write_record_(*selected, Json{{"schema", 1}, {"source", first.generic_string()}}); + EXPECT_FALSE(producer::refresh_entry(*selected, next)); + EXPECT_EQ(tk::read_file(entry), before); + EXPECT_EQ(record_(*selected)["schema"], 1); +} + +XTEST(DomainEntry, PreservesUnknownStagingAndRejectsChangedAuthority, .area = "home", + .covers = {"DOM-BOOTSTRAP"}, .requires_ = {"posix"}) { + if constexpr (tk::is_windows) GTEST_SKIP() << "private domains use POSIX namespaces"; + auto home = tk::Home::isolated("domain-entry-authority"); + auto selected = domain::resolve(home.dir(), "/xlings", home.root() / "absent-system"); + ASSERT_TRUE(selected); + const auto first = client_(home.root() / "client-one", "2026.10.7.1"); + const auto next = client_(home.root() / "client-two", "2026.10.8.1"); + ASSERT_TRUE(producer::prepare(*selected, first)); + const auto entry = selected->physicalHome / "bin/xlings"; + const auto before = record_(*selected); + const auto staging = fs::path(entry.string() + ".xlings.new"); + tk::write_file(staging, "user-owned staging name"); + EXPECT_FALSE(producer::refresh_entry(*selected, next)); + EXPECT_EQ(tk::read_file(staging), "user-owned staging name"); + EXPECT_EQ(record_(*selected), before); + fs::remove(staging); + auto marker = Json::parse(tk::read_file(selected->physicalHome / ".xlings-domain.json")); + marker["extension"] = "changed domain authority"; + tk::write_file(selected->physicalHome / ".xlings-domain.json", marker.dump()); + EXPECT_FALSE(producer::refresh_entry(*selected, next)); + EXPECT_EQ(tk::read_file(entry), tk::read_file(first)); + EXPECT_EQ(record_(*selected), before); +} + +XTEST(DomainEntry, LockRefusalCannotPublishTheEntryOrJournal, .area = "home", + .covers = {"DOM-BOOTSTRAP"}, .requires_ = {"posix"}) { + if constexpr (tk::is_windows) GTEST_SKIP() << "private domains use POSIX namespaces"; + auto home = tk::Home::isolated("domain-entry-locked"); + auto selected = domain::resolve(home.dir(), "/xlings", home.root() / "absent-system"); + ASSERT_TRUE(selected); + const auto first = client_(home.root() / "client-one", "2026.10.7.1"); + const auto next = client_(home.root() / "client-two", "2026.10.8.1"); + ASSERT_TRUE(producer::prepare(*selected, first)); + const auto entry = selected->physicalHome / "bin/xlings"; + const auto before = record_(*selected); + const auto lockPath = xlings::xvm::state_lock_path(selected->physicalHome); + fs::remove(lockPath); + fs::create_directory(lockPath); + tk::write_file(lockPath / "sentinel", "unwritable lock path remains"); + EXPECT_FALSE(producer::refresh_entry(*selected, next)); + EXPECT_EQ(record_(*selected), before); + EXPECT_EQ(tk::read_file(entry), tk::read_file(first)); + EXPECT_EQ(tk::read_file(lockPath / "sentinel"), "unwritable lock path remains"); +} diff --git a/tests/unit/test_domain_source.cpp b/tests/unit/test_domain_source.cpp new file mode 100644 index 000000000..96112fdc5 --- /dev/null +++ b/tests/unit/test_domain_source.cpp @@ -0,0 +1,105 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" +import std; +import xlings.core.home.prefix_domain; +import xlings.core.home.domain_producer_source; +import xlings.libs.json; +import xlings.platform; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; +namespace domain = xlings::home::prefix_domain; +namespace source = xlings::home::domain_producer_source; +using Json = nlohmann::json; +namespace { +struct Fixture { + fs::path home, root; + explicit Fixture(const fs::path& directory) { + fs::create_directories(directory); + home = fs::canonical(directory); + root = home / "data/xpkgs/fixture-x-tool/1.0.0"; + tk::write_file(home / ".xlings-home", R"({"mode":"root","layout":"multi","future":42})"); + tk::write_file(root / "bin/tool", "immutable package bytes"); + tk::write_file(root / ".xlings-resolution.json", Json{{"package", "fixture:tool@1.0.0"}, + {"deps", Json::array()}, {"future", "retain record"}}.dump()); + Json versions{{"tool", {{"type", "program"}, {"future", true}, {"versions", { + {"fixture:1.0.0", {{"path", (root / "bin").generic_string()}, + {"alias", Json::array({"/xlings/data/xpkgs/fixture-x-tool/1.0.0/bin/tool"})}, + {"future", "keep literal /xlings/data"}}}}}}}}; + tk::write_file(home / ".xlings.json", Json{{"versions", versions}, {"future", 77}}.dump()); + tk::write_file(home / "subos/default/.xlings.json", Json{{"workspace", { + {"tool", {{"active", "fixture:1.0.0"}, {"installed", Json::array({"fixture:1.0.0"})}}}}}}.dump()); + } +}; +} + +XTEST(DomainSource, FacadeUsesCheckedPathsAndPreservesPackageBytesAndUnknownFields, + .area = "home", .covers = {"DOM-PREFIX", "HOME-LAYER-RESOLVE"}, .requires_ = {"posix"}) { + auto owner = tk::Home::isolated("domain-source-facade"); + Fixture original(owner.root() / "source"); + const auto originalConfig = tk::read_file(original.home / ".xlings.json"); + const auto originalRecord = tk::read_file(original.root / ".xlings-resolution.json"); + tk::write_file(original.home / "data/versions.json", "{}"); + auto selected = domain::resolve(owner.dir(), "/xlings", original.home); + ASSERT_TRUE(selected) << selected.error(); + ASSERT_TRUE(domain::prepare_private(*selected)); + const auto stage = owner.root() / "facade"; + fs::create_directory(stage); + auto facade = source::prepare(*selected, stage); + ASSERT_TRUE(facade) << facade.error(); + ASSERT_EQ(facade->payloads.size(), 1); + EXPECT_EQ(facade->payloads[0].source, original.root); + EXPECT_EQ(facade->payloads[0].destination, fs::path("/xlings/data/xpkgs/fixture-x-tool/1.0.0")); + const auto mapped = Json::parse(tk::read_file(stage / "primary.json")); + const auto& version = mapped["versions"]["tool"]["versions"]["fixture:1.0.0"]; + EXPECT_EQ(version["path"], "/run/xlings-system-source/data/xpkgs/fixture-x-tool/1.0.0/bin"); + EXPECT_EQ(version["alias"][0], "/xlings/data/xpkgs/fixture-x-tool/1.0.0/bin/tool"); + EXPECT_EQ(version["future"], "keep literal /xlings/data"); + EXPECT_EQ(mapped["future"], 77); + EXPECT_EQ(tk::read_file(original.home / ".xlings.json"), originalConfig); + EXPECT_EQ(tk::read_file(original.root / ".xlings-resolution.json"), originalRecord); + EXPECT_EQ(tk::read_file(original.root / "bin/tool"), "immutable package bytes"); + EXPECT_EQ(Json::parse(tk::read_file(stage / "versions-cache.json")), Json::object()); + EXPECT_TRUE(fs::is_empty(selected->physicalHome / "data/xpkgs/fixture-x-tool/1.0.0")); + const auto nextStage = owner.root() / "facade-again"; + fs::create_directory(nextStage); + auto next = source::prepare(*selected, nextStage); + ASSERT_TRUE(next) << next.error(); + EXPECT_EQ(next->payloads.size(), 1) << "only inode-proved empty source slots are reusable"; +} + +XTEST(DomainSource, UserOwnedPrivatePayloadIsNeverHiddenByAReadOnlySourceMount, + .area = "home", .covers = {"DOM-LAYOUTS", "HOME-LAYER-RESOLVE"}, .requires_ = {"posix"}) { + auto owner = tk::Home::isolated("domain-source-conflict"); + Fixture original(owner.root() / "source"); + auto selected = domain::resolve(owner.dir(), "/xlings", original.home); + ASSERT_TRUE(selected); ASSERT_TRUE(domain::prepare_private(*selected)); + const auto local = selected->physicalHome / "data/xpkgs/fixture-x-tool/1.0.0"; + tk::write_file(local / "sentinel", "private user data"); + const auto stage = owner.root() / "facade"; fs::create_directory(stage); + auto facade = source::prepare(*selected, stage); + ASSERT_TRUE(facade) << facade.error(); + EXPECT_TRUE(facade->payloads.empty()); + ASSERT_EQ(facade->mapping.payloads.size(), 1); + EXPECT_FALSE(facade->mapping.payloads[0].logicalBound); + EXPECT_EQ(tk::read_file(local / "sentinel"), "private user data"); +} + +XTEST(DomainSource, MissingEvidenceAndCorruptSlotAuthorityRefuseInsteadOfGuessing, + .area = "home", .covers = {"HOME-LAYER-RESOLVE", "DOM-PREFIX"}, .requires_ = {"posix"}) { + auto owner = tk::Home::isolated("domain-source-strict"); + Fixture original(owner.root() / "source"); + auto selected = domain::resolve(owner.dir(), "/xlings", original.home); + ASSERT_TRUE(selected); ASSERT_TRUE(domain::prepare_private(*selected)); + const auto stage = owner.root() / "facade"; fs::create_directory(stage); + const auto record = tk::read_file(original.root / ".xlings-resolution.json"); + fs::remove(original.root / ".xlings-resolution.json"); + EXPECT_FALSE(source::prepare(*selected, stage)); + tk::write_file(original.root / ".xlings-resolution.json", record); + const auto slots = selected->physicalHome.parent_path() / "source-slots.json"; + tk::write_file(slots, R"({"schema":"bad","physical_home":1,"slots":{}})"); + EXPECT_FALSE(source::prepare(*selected, stage)); + EXPECT_EQ(tk::read_file(slots), R"({"schema":"bad","physical_home":1,"slots":{}})"); + EXPECT_EQ(tk::read_file(original.root / "bin/tool"), "immutable package bytes"); +} diff --git a/tests/unit/test_entry_activation.cpp b/tests/unit/test_entry_activation.cpp new file mode 100644 index 000000000..133c84efc --- /dev/null +++ b/tests/unit/test_entry_activation.cpp @@ -0,0 +1,128 @@ +#include + +import std; +import xlings.testkit; +import xlings.platform; +import xlings.core.config; +import xlings.core.xself; +import xlings.core.subos.root; +import mcpplibs.xpkg.executor; +import xlings.core.xim.installer; +import xlings.core.xim.libxpkg.types.type; + +TEST(EntryActivation, AnotherPackageCannotClaimTheSharedClientEvenWithoutAnExistingOwner) { + xlings::xim::PlanNode node; + node.name = "fixture"; + node.namespaceName = "xim"; + node.canonicalName = "xim:fixture"; + node.version = "1.0.0"; + mcpplibs::xpkg::XvmOp operation; + operation.op = "add"; + operation.name = "xlings"; + operation.type = "group"; + operation.version = "1.0.0"; + auto forged = + xlings::xim::normalize_xpkg_registration_plan(node, {operation}, "", "/fixture", true); + ASSERT_FALSE(forged); + EXPECT_NE(forged.error().message.find("only the xlings package"), std::string::npos); + + node.name = "xlings"; + node.namespaceName = "thirdparty"; + node.canonicalName = "thirdparty:xlings"; + auto other_provider = xlings::xim::normalize_xpkg_registration_plan( + node, {operation}, "thirdparty", "/fixture", true); + EXPECT_FALSE(other_provider); + + node.namespaceName = "xim"; + node.canonicalName = "xim:xlings"; + auto designated = + xlings::xim::normalize_xpkg_registration_plan(node, {operation}, "", "/fixture", true); + ASSERT_TRUE(designated) << designated.error().message; + EXPECT_EQ(designated->batch.provider, "xim:xlings"); +} + +namespace { +namespace fs = std::filesystem; +namespace tk = xlings::testkit; +namespace platform = xlings::platform; +constexpr auto childMode = "XLINGS_TEST_BORROWED_ENTRY_CHILD"; + +int check_borrowed_entry(std::string_view mode) { + const auto home = fs::canonical(std::getenv("XLINGS_HOME")); + xlings::Config::override_home(home); + xlings::Config::set_force_global_scope(true); + if (xlings::subos_root::running_host(home) || + xlings::Config::workspace_config_path().lexically_normal() != + (xlings::Config::global_subos_dir() / ".xlings.json").lexically_normal()) + return 2; + const auto policy = home / "config/subos" / xlings::Config::subos_scope().name / "policy.json"; + std::error_code ec; + if (fs::symlink_status(policy, ec).type() != fs::file_type::not_found) + return 3; + const auto filename = tk::is_windows ? "xlings.exe" : "xlings"; + const auto entry = home / "bin" / filename; + const auto payload = home / "data/xpkgs/xim-x-xlings/2.0.0/bin" / filename; + const auto before = tk::read_file(entry); + const auto identity = platform::file_identity(entry); + if (!identity) + return 4; + const auto sourceHome = mode == "same" ? home : home / "missing-source-home"; + if (mode != "same" && fs::exists(sourceHome)) + return 5; + if (!xlings::xself::replace_entry_binary(payload, entry, "xim:xlings@2.0.0", "2.0.0", + {"xim:xlings", sourceHome})) + return 6; + const auto after = platform::file_identity(entry); + if (!after || *after != *identity || tk::read_file(entry) != before) + return 7; + return 0; +} + +struct BorrowedEntryChild { + BorrowedEntryChild() { + const auto* mode = std::getenv(childMode); + if (!mode || !*mode) + return; + const auto selected = std::string(mode); + platform::unset_env_variable(childMode); + try { + std::_Exit(check_borrowed_entry(selected)); + } catch (const std::exception& error) { + std::cerr << "borrowed entry child: " << error.what() << '\n'; + std::_Exit(125); + } + } +} borrowedEntryChild; + +void expect_borrowed_entry_preserved(std::string_view mode) { + auto isolated = tk::Home::isolated("borrowed-entry"); + const auto home = isolated.dir(); + const auto filename = tk::is_windows ? "xlings.exe" : "xlings"; + tk::write_file(home / ".xlings.json", R"({"activeSubos":"default","versions":{}})"); + tk::write_file(home / "subos/default/.xlings.json", R"({"workspace":{}})"); + tk::write_file(home / "bin" / filename, "original shared entry bytes"); + tk::write_file(home / "data/xpkgs/xim-x-xlings/2.0.0/bin" / filename, + "different candidate entry bytes"); + auto environment = tk::inherited_env(); + environment["XLINGS_HOME"] = home.string(); + environment["HOME"] = isolated.root().string(); + environment["USERPROFILE"] = isolated.root().string(); + environment["XLINGS_ACTIVE_SUBOS"] = "default"; + environment[childMode] = std::string(mode); + environment.erase("XLINGS_SUBOS_MODE"); + environment.erase("XLINGS_PROJECT_DIR"); + const auto result = tk::run({.argv = {platform::get_executable_path().string()}, + .env = std::move(environment), + .cwd = isolated.root()}); + EXPECT_EQ(result.exit_code, 0) << result.transcript(); + EXPECT_EQ(tk::read_file(home / "bin" / filename), "original shared entry bytes"); +} +} // namespace + +TEST(EntryActivation, SameHomeBorrowedProvenancePreservesTheSharedEntry) { + expect_borrowed_entry_preserved("same"); +} + +TEST(EntryActivation, MissingBorrowedSourcePreservesTheSharedEntryWithoutResolvingIt) { + expect_borrowed_entry_preserved("missing"); +} diff --git a/tests/unit/test_guard_observe.cpp b/tests/unit/test_guard_observe.cpp new file mode 100644 index 000000000..0ba19c606 --- /dev/null +++ b/tests/unit/test_guard_observe.cpp @@ -0,0 +1,138 @@ +// xlings.guard and xlings.observe (modules/runtime, C1): the confirmation token, the Asker +// port, the journal and redaction. Both were lifted out of src/core with +// their behaviour unchanged; these pin that behaviour at the new seam. +#include + +import std; +import xlings.guard; +import xlings.observe; +import xlings.libs.json; + +namespace g = xlings::guard; +namespace o = xlings::observe; +namespace fs = std::filesystem; + +namespace { + +struct ScriptedAsker : g::Asker { + g::Reply reply; + int asked = 0; + g::Reply ask(const g::Question&) override { ++asked; return reply; } +}; + +fs::path temp_dir(std::string_view name) { + auto d = fs::temp_directory_path() / std::format("xlings-guard-observe-{}-{}", name, + std::chrono::steady_clock::now().time_since_epoch().count()); + fs::create_directories(d); + return d; +} + +} // namespace + +TEST(Guard, AutoYesConfirmsWithoutAskingAndRecordsTheSpelling) { + ScriptedAsker asker; + auto a = g::ask(asker, {.id = "x", .question = "?"}, true, "-y"); + EXPECT_EQ(a.outcome, g::Outcome::Confirmed); + ASSERT_TRUE(a.token); + EXPECT_EQ(a.token->how(), "-y"); + EXPECT_EQ(asker.asked, 0); +} + +TEST(Guard, OnlyAnExplicitYesConfirms) { + ScriptedAsker yes; + yes.reply = {.kind = g::Reply::Kind::Chosen, .value = "y"}; + auto a = g::ask(yes, {.id = "x", .question = "?"}, false, "-y"); + EXPECT_EQ(a.outcome, g::Outcome::Confirmed); + EXPECT_EQ(a.token->how(), "terminal"); + + ScriptedAsker no; + no.reply = {.kind = g::Reply::Kind::Chosen, .value = "n"}; + EXPECT_EQ(g::ask(no, {.id = "x"}, false, "-y").outcome, g::Outcome::Declined); + + ScriptedAsker cancelled; + cancelled.reply = {.kind = g::Reply::Kind::Cancelled}; + EXPECT_EQ(g::ask(cancelled, {.id = "x"}, false, "-y").outcome, g::Outcome::Declined); + + ScriptedAsker nobody; + auto n = g::ask(nobody, {.id = "x"}, false, "-y"); + EXPECT_EQ(n.outcome, g::Outcome::NobodyToAsk); + EXPECT_FALSE(n.token); +} + +TEST(Guard, IsWithinIsLexical) { + EXPECT_TRUE(g::is_within("/h/subos/a/home", "/h/subos/a")); + EXPECT_TRUE(g::is_within("/h/subos/a", "/h/subos/a/")); + EXPECT_FALSE(g::is_within("/h/subos/ab", "/h/subos/a")); + EXPECT_FALSE(g::is_within("/h/subos/a/../b", "/h/subos/a")); +} + +TEST(Observe, JournalAppendsReadsAndRotates) { + auto dir = temp_dir("journal"); + auto file = dir / "logs" / "events.ndjson"; + o::JournalLimits limits{.max_bytes = 200, .keep = 2}; + for (int i = 0; i < 20; ++i) + ASSERT_TRUE(o::append_checked(file, {.kind = o::Kind::Lifecycle, .fields = {{"n", i}}}, limits)); + EXPECT_TRUE(fs::exists(file)); + EXPECT_TRUE(fs::exists(fs::path(file.string() + ".1"))); + EXPECT_FALSE(fs::exists(fs::path(file.string() + ".3"))); + auto lines = o::read(file); + ASSERT_FALSE(lines.empty()); + EXPECT_EQ(lines.back()["n"], 19); + EXPECT_EQ(lines.back()["kind"], "lifecycle"); + EXPECT_TRUE(lines.back().contains("ts")); + fs::remove_all(dir); +} + +TEST(Observe, AppendNeverThrowsOnAnUnwritablePath) { + // A path whose parent is a regular file cannot be created. + auto dir = temp_dir("unwritable"); + std::ofstream(dir / "file") << "x"; + const auto file = dir / "file" / "sub" / "events.ndjson"; + EXPECT_FALSE(o::append_checked(file, {.kind = o::Kind::Ops})); + EXPECT_FALSE(o::append_json_checked(file, {{"probe", true}})); + EXPECT_NO_THROW(o::append(file, {.kind = o::Kind::Ops})); + fs::remove_all(dir); +} + +TEST(Observe, RedactionKeepsNamesOnly) { + auto names = o::redact_env({{"GITHUB_TOKEN", "secret-value"}, {"PATH", "/bin"}}); + EXPECT_EQ(names, (std::vector{"GITHUB_TOKEN", "PATH"})); + EXPECT_TRUE(o::looks_secret("GITHUB_TOKEN")); + EXPECT_TRUE(o::looks_secret("aws_secret_access_key")); + EXPECT_FALSE(o::looks_secret("PATH")); + EXPECT_FALSE(o::looks_secret("LANG")); +} + +TEST(Observe, DestructiveRecordIsNeverRotatedAndCarriesIdentity) { + auto home = temp_dir("destructive"); + o::destructive::set_identity("9.9.9", "xlings subos remove x -y"); + o::destructive::record(home, {.op = "subos-remove", .path = home / "subos" / "x", + .bytes = 10, .files = 1, .confirmedBy = "-y"}); + auto lines = o::read(o::destructive::log_path(home)); + ASSERT_EQ(lines.size(), 1u); + EXPECT_EQ(lines[0]["op"], "subos-remove"); + EXPECT_EQ(lines[0]["xlings"], "9.9.9"); + EXPECT_EQ(lines[0]["confirmedBy"], "-y"); + EXPECT_EQ(o::destructive::human_bytes(2048), "2.0 KB"); + fs::remove_all(home); +} + +TEST(Observe, FailedRotationDoesNotPretendToStoreAnEvent) { + auto dir = temp_dir("rotation-error"); + auto file = dir / "events.ndjson"; + ASSERT_TRUE(o::append_checked(file, {.fields = {{"original", true}}})); + fs::create_directories(dir / "events.ndjson.1"); + std::ofstream(dir / "events.ndjson.1" / "keep") << "existing evidence"; + EXPECT_FALSE(o::append_checked(file, {.fields = {{"next", true}}}, + {.max_bytes = 1, .keep = 1})); + const auto lines = o::read(file, false); + ASSERT_EQ(lines.size(), 1u); + EXPECT_TRUE(lines.front().contains("original")); + fs::remove_all(dir); +} + +TEST(Observe, ADeviceThatOpensButCannotFlushReportsFailure) { + if (!fs::exists("/dev/full")) GTEST_SKIP() << "requires a full-device fixture"; + EXPECT_FALSE(o::append_json_checked("/dev/full", {{"probe", true}}, {.max_bytes = 0})); + EXPECT_NO_THROW(o::append_json("/dev/full", {{"probe", true}}, {.max_bytes = 0})); +} diff --git a/tests/unit/test_home_context.cpp b/tests/unit/test_home_context.cpp new file mode 100644 index 000000000..91c63529c --- /dev/null +++ b/tests/unit/test_home_context.cpp @@ -0,0 +1,225 @@ +// xlings.core.home (C5): which home, in which mode, at which layout. +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.core.home; +import xlings.core.home_identity; +import xlings.libs.json; +import xlings.platform; + +namespace h = xlings::home; +namespace fs = std::filesystem; +namespace tk = xlings::testkit; + +namespace { +fs::path fresh_home(std::string_view name) { + auto d = fs::temp_directory_path() / std::format("xlings-home-ctx-{}-{}", name, + std::chrono::steady_clock::now().time_since_epoch().count()); + fs::create_directories(d); + return d; +} +} // namespace + +XTEST(HomeContext, DeclaredModeAndLayoutComeFromTheMarker, + .area = "home", .covers = {"HOME-CONTEXT", "HOME-MODE-DECLARED"}) { + auto home = fresh_home("declared"); + tk::write_file(home / ".xlings-home", + R"({"schema":1,"id":"abc","mode":"system","layout":2,"future":{"k":1}})"); + auto ctx = h::describe(home, h::Source::Env); + EXPECT_EQ(ctx.mode, h::Mode::System); + EXPECT_TRUE(ctx.modeDeclared); + EXPECT_EQ(ctx.layout, 2); + EXPECT_EQ(ctx.id, "abc"); + EXPECT_TRUE(ctx.writable()); + fs::remove_all(home); +} + +XTEST(HomeContext, AMarkerWithoutAModeIsInferredAndSaysSo, + .area = "home", .covers = {"HOME-CONTEXT"}) { + auto home = fresh_home("inferred"); + tk::write_file(home / ".xlings-home", R"({"schema":1,"id":"abc"})"); + auto env = h::describe(home, h::Source::Env); + EXPECT_EQ(env.mode, h::Mode::Custom); + EXPECT_FALSE(env.modeDeclared); + EXPECT_EQ(env.layout, 1); + EXPECT_EQ(h::describe(home, h::Source::SelfContained).mode, h::Mode::Portable); + fs::remove_all(home); +} + +XTEST(HomeContext, DeclareKeepsUnknownKeysAndOnlyRaisesTheLayout, + .area = "home", .covers = {"HOME-KEEP-UNKNOWN-KEYS", "HOME-MODE-DECLARED"}) { + auto home = fresh_home("declare"); + tk::write_file(home / ".xlings-home", R"({"schema":1,"id":"abc","layout":5,"future":true})"); + ASSERT_TRUE(h::declare(home, h::Mode::User, 2).has_value()); + auto j = nlohmann::json::parse(tk::read_file(home / ".xlings-home")); + EXPECT_EQ(j["mode"], "user"); + EXPECT_EQ(j["layout"], 5); // never lowered + EXPECT_EQ(j["future"], true); // kept + EXPECT_EQ(j["id"], "abc"); + fs::remove_all(home); +} + +XTEST(HomeContext, AHigherLayoutAllowsOnlyReadOnlyCommands, + .area = "home", .covers = {"HOME-LAYOUT-READONLY"}) { + using v = std::vector; + auto ro = [](v a) { return h::is_read_only_command(a); }; + EXPECT_TRUE(ro({"list"})); + EXPECT_TRUE(ro({"--version"})); + EXPECT_TRUE(ro({"subos", "list"})); + EXPECT_TRUE(ro({"subos", "status", "x"})); + EXPECT_TRUE(ro({"self", "doctor"})); + EXPECT_FALSE(ro({"self", "doctor", "--fix"})); + EXPECT_FALSE(ro({"install", "gcc"})); + EXPECT_FALSE(ro({"subos", "new", "x"})); + EXPECT_FALSE(ro({"self", "update"})); + + h::HomeContext newer{ .layout = h::kLayout + 1 }; + EXPECT_FALSE(newer.writable()); +} + +XTEST(HomeContext, ACommandDeclaresTheModeAndLayoutOnce, + .area = "home", .cost = tk::Cost::Medium, + .covers = {"HOME-MODE-DECLARED"}, .requires_ = {"xlings-bin"}) { + // A home from before the marker: the first command adopts it (writes the + // marker) and declares what it is. No network, no `self init`. + auto home = tk::Home::isolated("home-ctx"); + fs::create_directories(home.dir() / "subos" / "default"); + auto r = home.xlings({"subos", "list"}); + ASSERT_EQ(r.exit_code, 0) << r.transcript(); + auto j = nlohmann::json::parse(tk::read_file(home.dir() / ".xlings-home")); + // Home::isolated is HOME=, XLINGS_HOME=/.xlings: a user's + // default home, so "user" -- declared now, no longer inferred. + EXPECT_EQ(j.value("mode", ""), "user"); + EXPECT_EQ(j.value("layout", 0), h::kLayout); +} + +XTEST(HomeContext, ANewerLayoutIsReadNeverWritten, + .area = "home", .cost = tk::Cost::Medium, + .covers = {"HOME-LAYOUT-READONLY"}, .requires_ = {"xlings-bin"}) { + auto home = tk::Home::isolated("home-newer"); + fs::create_directories(home.dir() / "subos" / "default"); + ASSERT_EQ(home.xlings({"subos", "list"}).exit_code, 0); + auto marker = home.dir() / ".xlings-home"; + auto j = nlohmann::json::parse(tk::read_file(marker)); + j["layout"] = h::kLayout + 7; + tk::write_file(marker, j.dump()); + + auto write = home.xlings({"subos", "new", "box"}); + EXPECT_EQ(write.exit_code, 1) << write.transcript(); + EXPECT_NE(write.transcript().find("only reads it"), std::string::npos) << write.transcript(); + EXPECT_FALSE(fs::exists(home.dir() / "subos" / "box")); + + auto read = home.xlings({"subos", "list"}); + EXPECT_EQ(read.exit_code, 0) << read.transcript(); + // and the marker was not lowered by anyone + EXPECT_EQ(nlohmann::json::parse(tk::read_file(marker))["layout"], h::kLayout + 7); +} + +XTEST(WriterRules, AnUnparseableDocumentIsAnErrorNotAnEmptyOne, + .area = "home", .covers = {"HOME-KEEP-UNKNOWN-KEYS"}) { + auto home = fresh_home("writer"); + auto missing = h::read_json_for_update(home / "absent.json"); + ASSERT_TRUE(missing.has_value()); + EXPECT_TRUE(missing->empty()); + + tk::write_file(home / "kept.json", R"({"known":1,"unknown":{"x":true}})"); + auto kept = h::read_json_for_update(home / "kept.json"); + ASSERT_TRUE(kept.has_value()); + EXPECT_TRUE(kept->contains("unknown")); + + tk::write_file(home / "broken.json", R"({"workspace": )"); + EXPECT_FALSE(h::read_json_for_update(home / "broken.json").has_value()); + tk::write_file(home / "array.json", "[1,2]"); + EXPECT_FALSE(h::read_json_for_update(home / "array.json").has_value()); + fs::remove_all(home); +} + +XTEST(WriterRules, SubosNewDoesNotBlankAnUnparseableManifest, + .area = "home", .cost = tk::Cost::Medium, + .covers = {"HOME-KEEP-UNKNOWN-KEYS"}, .requires_ = {"xlings-bin"}) { + auto home = tk::Home::isolated("writer-subos"); + fs::create_directories(home.dir() / "subos" / "default"); + ASSERT_EQ(home.xlings({"subos", "new", "box"}).exit_code, 0); + auto manifest = home.dir() / "subos" / "box" / ".xlings.json"; + const std::string broken = R"({"workspace": {"gcc": "16.1.0"}, )"; + tk::write_file(manifest, broken); + + // Adopting the existing directory again must not rebuild it from {}. + auto r = home.xlings({"subos", "new", "box", "-y"}); + EXPECT_NE(r.exit_code, 0) << r.transcript(); + EXPECT_EQ(tk::read_file(manifest), broken); +} + +// ── Deployment S and M (design §4) ─────────────────────────────────── + +XTEST(Deployment, AUserCannotCreateOrMutateASystemLayerEvenWhenItsPathIsWritable, + .area = "home", .covers = {"DEPLOY-M-SYSTEM"}, .requires_ = {"posix", "xlings-bin"}) { + if (xlings::platform::is_root()) GTEST_SKIP() << "requires an unprivileged caller"; + auto home = tk::Home::isolated("system-root-authority"); + const auto layer = home.root() / "system-layer"; + const auto absent = home.xlings({"install", "--system", "-y", "xz"}, + {{"XLINGS_SYSTEM_LAYER", layer.string()}}); + EXPECT_EQ(absent.exit_code, 13) << absent.transcript(); + EXPECT_FALSE(fs::exists(layer)); + tk::write_file(layer / ".xlings-write-probe", "user-owned file"); + const auto existing = home.xlings({"install", "--system", "-y", "xz"}, + {{"XLINGS_SYSTEM_LAYER", layer.string()}}); + EXPECT_EQ(existing.exit_code, 13) << existing.transcript(); + EXPECT_EQ(tk::read_file(layer / ".xlings-write-probe"), "user-owned file"); + EXPECT_FALSE(fs::exists(layer / "bin")); +} + +XTEST(Deployment, AnEntryIsASystemPackagesOnlyWhereRootInstallsThem, + .area = "home", .covers = {"HOME-SYSTEM-MODE"}) { + auto home = tk::Home::isolated("entry"); + EXPECT_FALSE(xlings::home::describe_entry(home.dir() / "bin" / "xlings", home.dir()).system) + << "the home's own entry"; + EXPECT_FALSE(xlings::home::describe_entry(home.root() / "work" / "xlings", home.dir()).system) + << "a binary of the user's, outside the home"; + if constexpr (tk::is_linux) { + // Root's, under /usr: what a system package installs. (A container + // can map /usr to another owner; then the premise does not hold.) + auto owner = tk::run({.argv = {"/usr/bin/stat", "-c", "%u", "/usr/bin/env"}}); + if (owner.exit_code == 0 && owner.out.starts_with("0\n")) + EXPECT_TRUE(xlings::home::describe_entry("/usr/bin/env", home.dir()).system); + } +} + +XTEST(Deployment, TheSystemConfigIsADefaultTheHomeOverrides, + .area = "home", .cost = tk::Cost::Medium, .covers = {"HOME-SYSTEM-MODE"}, + .requires_ = {"xlings-bin"}) { + auto home = tk::Home::isolated("sysconfig"); + const auto sys = home.root() / "etc" / "config.json"; + tk::write_file(sys, R"({"lang":"zh","mirror":"CN"})"); + auto layer = home.root() / "layer"; + tk::write_file(layer / ".xlings-home", R"({"layout":2,"mode":"multi"})"); + const std::map env{ + {"XLINGS_SYSTEM_CONFIG", sys.string()}, {"XLINGS_SYSTEM_LAYER", layer.string()}, + {"XLINGS_TEST_MIRROR", ""}, {"XLINGS_RELEASE_MIRROR", ""}}; + // Fix this fixture's home mirror independently of the lane's CN override. + tk::write_file(home.dir() / ".xlings.json", R"({"mirror":"GLOBAL"})"); + auto r = home.xlings({"config"}, env); + ASSERT_EQ(r.exit_code, 0) << r.transcript(); + EXPECT_NE(r.out.find("zh"), std::string::npos) << "the system's language\n" << r.out; + EXPECT_EQ(r.out.find("CN"), std::string::npos) << "the home's mirror wins\n" << r.out; + + auto doctor = home.xlings({"self", "doctor"}, env); + EXPECT_NE(doctor.transcript().find("system config: " + sys.string()), std::string::npos) + << doctor.transcript(); + EXPECT_NE(doctor.transcript().find("system layer: " + fs::canonical(layer).string()), std::string::npos) + << doctor.transcript(); +} + +// #640 F14: the AUR package made its shared config world-writable (766). +XTEST(Deployment, ThePackageRecipesInstallNothingWorldWritable, + .area = "home", .covers = {"F14"}) { + const auto recipe = fs::current_path() / "config" / "aur" / "PKGBUILD"; + if (!fs::exists(recipe)) GTEST_SKIP() << "not run from the repository root"; + const auto text = tk::read_file(recipe); + // chmod with an octal mode whose last digit grants others write (2, 3, 6, 7). + const std::regex world_writable(R"(chmod\s+(-\w+\s+)*0?[0-7]?[0-7][0-7][2367]\b|chmod\s+(-\w+\s+)*[ao]?\+w|-m\s*0?[0-7]?[0-7][0-7][2367]\b)"); + std::smatch m; + EXPECT_FALSE(std::regex_search(text, m, world_writable)) << m.str(); +} diff --git a/tests/unit/test_home_layers.cpp b/tests/unit/test_home_layers.cpp new file mode 100644 index 000000000..348b16beb --- /dev/null +++ b/tests/unit/test_home_layers.cpp @@ -0,0 +1,305 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.platform; +import xlings.libs.json; +import xlings.core.home; +import xlings.core.config; +import xlings.core.home.layers; +import xlings.core.profile; +import xlings.core.xvm.types; +import xlings.core.xvm.db; +import xlings.core.xim.payload; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; +namespace h = xlings::home; +namespace layers = xlings::home::layers; +namespace xvm = xlings::xvm; +using Json = nlohmann::json; + +namespace { +struct Env { + std::string key, previous; + Env(std::string name, std::string value) : key(std::move(name)) { + if (auto* value = std::getenv(key.c_str())) previous = value; + xlings::platform::set_env_variable(key, value); + } + ~Env() { xlings::platform::set_env_variable(key, previous); } +}; + +struct Layer { + fs::path home; + fs::path tool; + fs::path dependency; + explicit Layer(const fs::path& path) : home(path), tool(path / "data/xpkgs/fixture-x-layer-tool/1.0.0"), + dependency(path / "data/xpkgs/fixture-x-layer-dep/2.0.0") { + tk::write_file(home / ".xlings-home", R"({"schema":1,"id":"layer","mode":"root","layout":"multi"})"); + tk::write_file(tool / "bin/layer-tool", "tool sentinel"); + tk::write_file(dependency / "include/layer.h", "header sentinel"); + tk::write_file(dependency / "lib/liblayer.a", "library sentinel"); + Json versions = { + {"layer-tool", {{"type", "program"}, {"versions", {{"fixture:1.0.0", { + {"path", "${XLINGS_HOME}/data/xpkgs/fixture-x-layer-tool/1.0.0/bin"}, + {"alias", {"${XLINGS_HOME}/data/xpkgs/fixture-x-layer-tool/1.0.0/bin/layer-tool"}}}}}}}}, + {"layer-dep", {{"type", "lib"}, {"versions", {{"fixture:2.0.0", { + {"path", "${XLINGS_HOME}/data/xpkgs/fixture-x-layer-dep/2.0.0/lib"}, + {"sourceName", "liblayer.a"}, {"destinationName", "liblayer.a"}, + {"includedir", "${XLINGS_HOME}/data/xpkgs/fixture-x-layer-dep/2.0.0/include"}}}}}}} + }; + tk::write_file(home / ".xlings.json", Json{{"versions", versions}, {"future", true}}.dump()); + Json workspace = { + {"layer-tool", {{"active", "fixture:1.0.0"}, {"installed", {"fixture:1.0.0"}}}}, + {"layer-dep", {{"active", "fixture:2.0.0"}, {"installed", {"fixture:2.0.0"}}}} + }; + tk::write_file(home / "subos/default/.xlings.json", Json{ + {"workspace", workspace}, {"configured", {{"fixture:layer-tool@1.0.0", 9}}}}.dump()); + tk::write_file(tool / ".xlings-resolution.json", Json{ + {"package", "fixture:layer-tool@1.0.0"}, {"deps", Json::array({{ + {"spec", "layer-dep@2.0.0"}, {"name", "fixture:layer-dep"}, {"version", "2.0.0"}, + {"install_dir", dependency.string()}, {"source", "fixture"}, {"libdirs", {"lib"}}}})}}.dump()); + tk::write_file(dependency / ".xlings-resolution.json", Json{ + {"package", "fixture:layer-dep@2.0.0"}, {"deps", Json::array()}}.dump()); + } +}; +} + +XTEST(HomeLayers, GarbageCollectionDoesNotFollowAStoreIntoAnotherHome, + .area = "home", .covers = {"HOME-LAYER-RESOLVE"}) { + if constexpr (!tk::is_posix) GTEST_SKIP() << "requires directory symlinks"; + auto isolated = tk::Home::isolated("layer-gc-ownership"); + const auto user = isolated.root() / "user"; + Layer system(isolated.root() / "system"); + tk::write_file(user / ".xlings.json", R"({"versions":{}})"); + fs::create_directories(user / "data"); + fs::create_directory_symlink(system.home / "data/xpkgs", user / "data/xpkgs"); + EXPECT_EQ(xlings::profile::gc(user, false), 1); + EXPECT_EQ(tk::read_file(system.tool / "bin/layer-tool"), "tool sentinel"); + EXPECT_EQ(tk::read_file(system.dependency / "include/layer.h"), "header sentinel"); +} + +XTEST(HomeLayers, RootMultiMarkerAndStrictUnreadableState, .area = "home", + .covers = {"HOME-LAYER-RESOLVE"}) { + auto isolated = tk::Home::isolated("strict-layer"); + Layer layer(isolated.root() / "system"); + Env selected("XLINGS_SYSTEM_LAYER", layer.home.string()); + auto declared = h::read_system_layer(); + ASSERT_TRUE(declared); + ASSERT_TRUE(*declared); + EXPECT_EQ(**declared, fs::canonical(layer.home)); + const auto context = h::describe(layer.home, h::Source::Env); + EXPECT_EQ(context.mode, h::Mode::Root); + EXPECT_EQ(context.rootLayout, "multi"); + ASSERT_TRUE(h::declare(layer.home, std::nullopt, 2)); + EXPECT_EQ(Json::parse(tk::read_file(layer.home / ".xlings-home"))["layout"], "multi"); + tk::write_file(layer.home / ".xlings-home", "{corrupt"); + EXPECT_FALSE(h::read_system_layer()); + tk::write_file(layer.home / ".xlings-home", R"({"mode":"root","layout":"single"})"); + declared = h::read_system_layer(); + ASSERT_TRUE(declared); + EXPECT_FALSE(*declared); + tk::write_file(layer.home / ".xlings.json", "{corrupt"); + EXPECT_FALSE(layers::read_snapshot(layer.home)); + fs::remove(layer.home / ".xlings.json"); + tk::write_file(layer.home / ".xlings.json", R"({"versions":{"tool":{"versions":[]}}})"); + EXPECT_FALSE(layers::read_snapshot(layer.home)); + if (!tk::is_windows) { + fs::remove(layer.home / ".xlings.json"); + fs::create_symlink(layer.home / "does-not-exist", layer.home / ".xlings.json"); + EXPECT_FALSE(h::read_json_for_update(layer.home / ".xlings.json")); + EXPECT_FALSE(layers::read_snapshot(layer.home)); + } + EXPECT_TRUE(h::read_json_for_update(layer.home / "actually-absent.json")); +} + +XTEST(HomeLayers, SharedStoreCollectionRetainsPayloadsWithoutLocalReferences, + .area = "home", .covers = {"HOME-LAYER-RESOLVE"}) { + auto isolated = tk::Home::isolated("shared-layer-gc"); + Layer layer(isolated.root() / "system"); + tk::write_file(layer.home / ".xlings.json", R"({"versions":{}})"); + fs::remove(layer.home / "subos/default/.xlings.json"); + EXPECT_EQ(xlings::profile::gc(layer.home, false), 0); + EXPECT_EQ(tk::read_file(layer.tool / "bin/layer-tool"), "tool sentinel"); + EXPECT_EQ(tk::read_file(layer.dependency / "include/layer.h"), "header sentinel"); + tk::write_file(layer.home / ".xlings-home", R"({"mode":"root","layout":"future"})"); + EXPECT_EQ(xlings::profile::gc(layer.home, false), 1); + EXPECT_EQ(tk::read_file(layer.tool / "bin/layer-tool"), "tool sentinel"); +} + +XTEST(HomeLayers, BorrowIncludesRuntimeDependenciesAndPinsForeignHome, .area = "home", + .covers = {"HOME-LAYER-RESOLVE"}) { + auto isolated = tk::Home::isolated("closure-layer"); + Layer layer(isolated.root() / "system"); + auto snapshot = layers::read_snapshot(layer.home); + ASSERT_TRUE(snapshot) << snapshot.error(); + auto& root = snapshot->versions.at("layer-tool").versions.at("fixture:1.0.0"); + root.kind = "program"; + root.bindingGroup = xvm::BindingGroupRef{ + .provider = "fixture:layer-tool", .providerVersion = "1.0.0", .group = "release", + .rootTarget = "layer-tool", .rootVersion = "fixture:1.0.0"}; + root.bindingMembers = {{"layer-tool", "fixture:1.0.0"}, {"layer-helper", "fixture:1.0.0"}}; + root.bindingMembersDeclared = true; + auto helper = root; + helper.bindingMembers.clear(); + helper.bindingMembersDeclared = false; + snapshot->versions["layer-helper"].type = "program"; + snapshot->versions["layer-helper"].versions["fixture:1.0.0"] = helper; + snapshot->workspace.installed["layer-helper"] = {"fixture:1.0.0"}; + auto plan = layers::plan_borrow(*snapshot, {}, "layer-helper", "fixture:1.0.0"); + ASSERT_TRUE(plan) << plan.error(); + EXPECT_EQ(plan->members.size(), 3); + EXPECT_EQ(plan->members.at("layer-tool"), "fixture:1.0.0"); + EXPECT_EQ(plan->members.at("layer-dep"), "fixture:2.0.0"); + EXPECT_EQ(plan->payloads.size(), 2); + const auto& data = plan->registrations.at("layer-tool").versions.at("fixture:1.0.0"); + EXPECT_EQ(data.sourceHome, fs::canonical(layer.home).string()); + EXPECT_EQ(data.sourceScope, "default"); + EXPECT_EQ(fs::path(data.path).lexically_normal(), (fs::canonical(layer.tool) / "bin").lexically_normal()); + EXPECT_EQ(fs::path(data.alias.front()).lexically_normal(), (fs::canonical(layer.tool) / "bin/layer-tool").lexically_normal()); + const auto roundtrip = xvm::vdata_from_json(xvm::vdata_to_json(data)); + EXPECT_EQ(roundtrip.sourceHome, data.sourceHome); + EXPECT_EQ(roundtrip.sourceScope, data.sourceScope); + auto future = xvm::vdata_to_json(data); + future["layer"]["future"] = {{"flag", true}}; + EXPECT_EQ(xvm::vdata_to_json(xvm::vdata_from_json(future))["layer"]["future"], future["layer"]["future"]); + EXPECT_TRUE(layers::owns_payload(layer.home, layer.tool)); + EXPECT_FALSE(layers::owns_payload(isolated.dir(), layer.tool)); + EXPECT_FALSE(fs::exists(isolated.dir() / "data/xpkgs")); +} + +XTEST(HomeLayers, KnownIncompleteAndForeignPayloadsCannotBeBorrowed, .area = "home", + .covers = {"HOME-LAYER-RESOLVE"}) { + auto isolated = tk::Home::isolated("failed-layer"); + Layer layer(isolated.root() / "system"); + auto snapshot = layers::read_snapshot(layer.home); + ASSERT_TRUE(snapshot); + xlings::xim::write_payload_failure_marker(layer.dependency, "2.0.0", "failed config"); + auto failed = layers::plan_borrow(*snapshot, {}, "layer-tool", "fixture:1.0.0"); + ASSERT_FALSE(failed); + EXPECT_NE(failed.error().find("incomplete"), std::string::npos); + tk::write_file(layer.dependency / ".xpkg-install.json", R"({"os":"foreign-test-platform"})"); + auto foreign = layers::plan_borrow(*snapshot, {}, "layer-tool", "fixture:1.0.0"); + ASSERT_FALSE(foreign); + EXPECT_NE(foreign.error().find("another platform"), std::string::npos); +} + +XTEST(HomeLayers, PackageLookupUsesRecordedProviderInsteadOfTargetSpelling, .area = "home", + .covers = {"HOME-LAYER-RESOLVE"}) { + auto isolated = tk::Home::isolated("provider-layer"); + Layer layer(isolated.root() / "system"); + auto snapshot = layers::read_snapshot(layer.home); + ASSERT_TRUE(snapshot); + snapshot->versions["different-tool-name"] = snapshot->versions.at("layer-tool"); + snapshot->versions.erase("layer-tool"); + snapshot->workspace.installed["different-tool-name"] = {"fixture:1.0.0"}; + snapshot->workspace.installed.erase("layer-tool"); + auto selected = layers::plan_borrow_package(*snapshot, {}, "fixture:layer-tool", "1.0.0"); + ASSERT_TRUE(selected) << selected.error(); + ASSERT_TRUE(*selected); + EXPECT_EQ((**selected).requestedPayload, fs::canonical(layer.tool)); + EXPECT_TRUE((**selected).requestedMembers.contains("different-tool-name")); + EXPECT_FALSE((**selected).requestedMembers.contains("layer-dep")); + EXPECT_EQ((**selected).payloadCoordinates.at("fixture:layer-dep@2.0.0"), fs::canonical(layer.dependency)); + auto absent = layers::plan_borrow_package(*snapshot, {}, "other:layer-tool", "1.0.0"); + ASSERT_TRUE(absent); + EXPECT_FALSE(*absent); + fs::remove(layer.tool / ".xlings-resolution.json"); + EXPECT_FALSE(layers::plan_borrow_package(*snapshot, {}, "fixture:layer-tool", "1.0.0")); +} + +XTEST(HomeLayers, MissingCorruptAndContradictoryEvidenceCannotInventEmptyClosure, .area = "home", + .covers = {"HOME-LAYER-RESOLVE"}) { + auto isolated = tk::Home::isolated("missing-layer"); + Layer layer(isolated.root() / "system"); + auto snapshot = layers::read_snapshot(layer.home); + ASSERT_TRUE(snapshot); + fs::remove(layer.dependency / ".xlings-resolution.json"); + auto rejected = layers::plan_borrow(*snapshot, {}, "layer-tool", "fixture:1.0.0"); + ASSERT_FALSE(rejected); + EXPECT_NE(rejected.error().find("--reconfig"), std::string::npos); + tk::write_file(layer.dependency / ".xlings-resolution.json", "{bad"); + EXPECT_FALSE(layers::plan_borrow(*snapshot, {}, "layer-tool", "fixture:1.0.0")); + tk::write_file(layer.dependency / ".xlings-resolution.json", R"({"package":"fixture:other@2.0.0","deps":[]})"); + EXPECT_FALSE(layers::plan_borrow(*snapshot, {}, "layer-tool", "fixture:1.0.0")); + tk::write_file(layer.dependency / ".xlings-resolution.json", R"({"package":"layer-dep@2.0.0","deps":[]})"); + EXPECT_TRUE(layers::plan_borrow(*snapshot, {}, "layer-tool", "fixture:1.0.0")); + snapshot->workspace.installed.erase("layer-dep"); + EXPECT_FALSE(layers::plan_borrow(*snapshot, {}, "layer-tool", "fixture:1.0.0")); +} + +XTEST(HomeLayers, UserOwnershipAndOutsideStoreAssetsRefuseUnsafeBorrowing, .area = "home", + .covers = {"HOME-LAYER-RESOLVE"}) { + auto isolated = tk::Home::isolated("priority-layer"); + Layer layer(isolated.root() / "system"); + auto snapshot = layers::read_snapshot(layer.home); + ASSERT_TRUE(snapshot); + auto own = snapshot->versions; + EXPECT_FALSE(layers::plan_borrow(*snapshot, own, "layer-tool", "fixture:1.0.0")); + tk::write_file(isolated.root() / "foreign/header", "private"); + snapshot->versions.at("layer-dep").versions.at("fixture:2.0.0").includedir = (isolated.root() / "foreign").string(); + EXPECT_FALSE(layers::plan_borrow(*snapshot, {}, "layer-tool", "fixture:1.0.0")); + if (!tk::is_windows) { + fs::create_directory_symlink(isolated.root() / "foreign", layer.home / "data/xpkgs/foreign"); + EXPECT_FALSE(layers::owns_payload(layer.home, layer.home / "data/xpkgs/foreign/header")); + } + auto invalid = xvm::vdata_from_json(Json{{"path", "x"}, {"layer", {{"home", 7}, {"scope", "default"}}}}); + EXPECT_FALSE(invalid.bindingIntegrityIssues.empty()); + EXPECT_EQ(xvm::vdata_to_json(invalid)["layer"]["home"], 7); +} + +XTEST(HomeLayers, UseMaterializesTheFullBorrowedClosureWithoutCopyingConfiguredState, + .area = "home", .covers = {"HOME-LAYER-RESOLVE"}, .requires_ = {"xlings-bin"}) { + auto isolated = tk::Home::isolated("use-layer"); + Layer layer(isolated.root() / "system"); + const auto sourceConfig = tk::read_file(layer.home / ".xlings.json"); + fs::create_directories(isolated.dir() / "bin"); + fs::copy_file(tk::xlings_binary(), isolated.dir() / "bin" / (tk::is_windows ? "xlings.exe" : "xlings")); + const auto primary = isolated.root() / "empty-index"; + tk::write_file(primary / "xim-indexrepos.lua", "xim_indexrepos = {}\n"); + fs::create_directories(primary / "pkgs"); + auto config = Json::parse(tk::read_file(isolated.dir() / ".xlings.json")); + config["xim"]["index-repo"] = primary.generic_string(); + config["index_repos"] = Json::array({{{"name", std::string(xlings::Config::DEFAULT_INDEX_REPO_NAME)}, + {"url", primary.generic_string()}, {"source", "git"}}}); + tk::write_file(isolated.dir() / ".xlings.json", config.dump()); + const auto initialized = isolated.xlings({"self", "init"}); + ASSERT_EQ(initialized.exit_code, 0) << initialized.transcript(); + auto result = isolated.xlings({"use", "layer-tool", "1.0.0"}, {{"XLINGS_SYSTEM_LAYER", layer.home.string()}}); + ASSERT_EQ(result.exit_code, 0) << result.transcript(); + const auto state = Json::parse(tk::read_file(isolated.dir() / "subos/default/.xlings.json")); + EXPECT_EQ(state["workspace"]["layer-dep"]["active"], "fixture:2.0.0"); + EXPECT_EQ(state["workspace"]["layer-tool"]["active"], "fixture:1.0.0"); + EXPECT_FALSE(state.contains("configured") && state["configured"].contains("fixture:layer-tool@1.0.0")); + EXPECT_EQ(tk::read_file(isolated.dir() / "subos/default/usr/include/layer.h"), "header sentinel"); + EXPECT_EQ(tk::read_file(isolated.dir() / "subos/default/lib/liblayer.a"), "library sentinel"); + EXPECT_FALSE(fs::exists(isolated.dir() / "data/xpkgs/fixture-x-layer-tool")); + EXPECT_EQ(tk::read_file(layer.home / ".xlings.json"), sourceConfig); + const auto registration = Json::parse(tk::read_file(isolated.dir() / ".xlings.json")); + EXPECT_EQ(registration["versions"]["layer-tool"]["versions"]["fixture:1.0.0"]["layer"]["home"], fs::canonical(layer.home).string()); + fs::remove(layer.dependency / ".xlings-resolution.json"); + const auto before = tk::read_file(isolated.dir() / "subos/default/.xlings.json"); + result = isolated.xlings({"use", "layer-tool", "1.0.0"}, {{"XLINGS_SYSTEM_LAYER", layer.home.string()}}); + EXPECT_NE(result.exit_code, 0) << result.transcript(); + EXPECT_EQ(tk::read_file(isolated.dir() / "subos/default/.xlings.json"), before); + // A higher layer owns its own candidate even when the borrowed layer has + // a numerically newer version of the same target. + auto local = registration; + auto ownData = local["versions"]["layer-tool"]["versions"]["fixture:1.0.0"]; + ownData.erase("layer"); + const auto ownedPayload = isolated.dir() / "data/xpkgs/fixture-x-user-tool/0.1.0/bin"; + tk::write_file(ownedPayload / "layer-tool", "user tool"); + ownData["path"] = ownedPayload.string(); + ownData["alias"] = Json::array({(ownedPayload / "layer-tool").string()}); + local["versions"]["layer-tool"]["versions"]["fixture:0.1.0"] = ownData; + tk::write_file(isolated.dir() / ".xlings.json", local.dump()); + auto ownedState = Json::parse(before); + ownedState["workspace"]["layer-tool"]["installed"].push_back("fixture:0.1.0"); + tk::write_file(isolated.dir() / "subos/default/.xlings.json", ownedState.dump()); + result = isolated.xlings({"use", "layer-tool", "latest"}, {{"XLINGS_SYSTEM_LAYER", layer.home.string()}}); + ASSERT_EQ(result.exit_code, 0) << result.transcript(); + EXPECT_EQ(Json::parse(tk::read_file(isolated.dir() / "subos/default/.xlings.json")) + ["workspace"]["layer-tool"]["active"], "fixture:0.1.0"); + +} diff --git a/tests/unit/test_identity.cpp b/tests/unit/test_identity.cpp index 0c51e9cd8..cc0b6de34 100644 --- a/tests/unit/test_identity.cpp +++ b/tests/unit/test_identity.cpp @@ -51,10 +51,13 @@ TEST(Identity, NonRootIsNotRoot) { EXPECT_FALSE(xlings::platform::is_root()); } -TEST(Identity, PrivPrefixIsSudoWhenNonRoot) { - // Byte-for-byte identical to the historical hardcoded "sudo " string — - // this is the safety invariant that keeps existing flows unchanged. - EXPECT_EQ(xlings::platform::priv_prefix(), "sudo "); +TEST(Identity, ElevationIsRequiredExactlyWhenNotRoot) { + // Privileged steps run through platform::run_elevated, which runs them + // directly when this process already has the rights (sudo is often + // absent in minimal root containers) and asks otherwise. + if constexpr (xlings::platform::is_posix) + EXPECT_EQ(xlings::platform::is_elevated(), xlings::platform::is_root()); + EXPECT_EQ(xlings::platform::run_elevated({}), 127) << "an empty argv names no program"; } TEST(Identity, SudoInvokerGatedOnRoot) { diff --git a/tests/unit/test_install_tool_order.cpp b/tests/unit/test_install_tool_order.cpp new file mode 100644 index 000000000..13b30ca37 --- /dev/null +++ b/tests/unit/test_install_tool_order.cpp @@ -0,0 +1,69 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.core.xim.resolver; +import xlings.core.xim.libxpkg.types.type; + +namespace xim = xlings::xim; + +namespace { +xim::PlanNode node(std::string name) { + xim::PlanNode result; + result.name = name; + result.version = "1"; + result.namespaceName = "xim"; + result.canonicalName = "xim:" + name; + return result; +} +std::vector names(const xim::InstallPlan& plan) { + std::vector result; + for (const auto& item : plan.nodes) result.push_back(item.name); + return result; +} +} + +XTEST(InstallToolOrder, RelocationToolRunsBeforeConsumersWithoutChangingTheirRuntimeClosure, + .area = "xim", .covers = {"DOM-ELFPATCH-FAILCLOSED"}) { + xim::InstallPlan plan; + auto xz = node("xz"); + xz.deps = {"glibc"}; + xz.runtime_deps = {"glibc"}; + xz.depEdges = {{"glibc", xim::DepKind::Runtime, "xim:glibc@1"}}; + plan.nodes = {node("glibc"), xz, node("patchelf")}; + ASSERT_TRUE(xim::require_install_tool(plan, "xim:patchelf@1")); + EXPECT_EQ(names(plan), (std::vector{"patchelf", "glibc", "xz"})); + EXPECT_EQ(plan.nodes.back().runtime_deps, (std::vector{"glibc"})); + EXPECT_EQ(plan.nodes.back().deps, (std::vector{"glibc"})); + EXPECT_EQ(plan.nodes.back().depEdges.back().kind, xim::DepKind::Build); +} + +XTEST(InstallToolOrder, TheToolsOwnDependenciesPrecedeItWithoutCreatingABootstrapCycle, + .area = "xim", .covers = {"DOM-ELFPATCH-FAILCLOSED"}) { + xim::InstallPlan plan; + auto tool = node("patchelf"); + tool.depEdges = {{"glibc", xim::DepKind::Runtime, "xim:glibc@1"}}; + plan.nodes = {node("xz"), tool, node("glibc")}; + ASSERT_TRUE(xim::require_install_tool(plan, "xim:patchelf@1")); + EXPECT_EQ(names(plan), (std::vector{"glibc", "patchelf", "xz"})); + EXPECT_TRUE(plan.nodes.front().depEdges.empty()); +} + +XTEST(InstallToolOrder, MissingToolsDependenciesAndCyclesLeaveThePlanUnchanged, + .area = "xim", .covers = {"DOM-ELFPATCH-FAILCLOSED"}) { + xim::InstallPlan plan; + plan.nodes = {node("xz")}; + EXPECT_FALSE(xim::require_install_tool(plan, "xim:patchelf@1")); + EXPECT_EQ(names(plan), (std::vector{"xz"})); + auto tool = node("patchelf"); + tool.depEdges = {{"glibc", xim::DepKind::Runtime, "xim:glibc@1"}}; + plan.nodes.push_back(tool); + EXPECT_FALSE(xim::require_install_tool(plan, "xim:patchelf@1")); + auto libc = node("glibc"); + libc.depEdges = {{"patchelf", xim::DepKind::Runtime, "xim:patchelf@1"}}; + plan.nodes.push_back(libc); + EXPECT_FALSE(xim::require_install_tool(plan, "xim:patchelf@1")); + EXPECT_EQ(names(plan), (std::vector{"xz", "patchelf", "glibc"})); + EXPECT_TRUE(plan.nodes.front().depEdges.empty()); +} diff --git a/tests/unit/test_intent_eq.cpp b/tests/unit/test_intent_eq.cpp new file mode 100644 index 000000000..01f1a3122 --- /dev/null +++ b/tests/unit/test_intent_eq.cpp @@ -0,0 +1,232 @@ +// INTENT-EQ (SubOS design part 3 §6): the sandbox a policy compiles to is a +// golden, recorded from the compiler before the Intent IR and the confine +// registry replaced its insides. Every case below must produce the same +// description, the same backend argv and the same environment, byte for byte. +// +// XLINGS_INTENT_GOLDEN_WRITE=1 rewrites the golden; a change to it is a +// change to what a sandbox IS and is reviewed as one. +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.libs.json; +import xlings.libs.sha256; +import xlings.subos.home_view; +import xlings.subos.policy; +import xlings.subos.caps; +import xlings.subos.spec; +import xlings.confine.provider; +import xlings.confine; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; +namespace pol = xlings::subos::policy; +namespace sp = xlings::subos::spec; +namespace caps = xlings::subos::caps; +namespace pv = xlings::confine::provider; +using xlings::subos::HomeView; + +namespace { + +fs::path golden_path() { + return fs::path(__FILE__).parent_path().parent_path() / "fixtures/intent-eq/golden.tsv"; +} + +caps::Caps host(int which) { + caps::Caps c; + c.platform = "linux"; + c.kernel = "6.8.0"; + switch (which) { + case 0: // everything + c.bwrap = caps::Backend{.name = "bwrap", .bin = "/opt/bwrap", .source = "root-owned", .usable = true}; + c.proot = caps::Backend{.name = "proot", .bin = "/opt/proot", .source = "payload", .usable = true}; + c.userns = true; + c.pasta = fs::path("/opt/pasta"); + c.seccomp = true; + c.landlock_abi = 3; + break; + case 1: // bwrap found but cannot make a namespace; proot is there + c.bwrap = caps::Backend{.name = "bwrap", .bin = "/usr/bin/bwrap", .source = "system", .usable = false, + .probe_output = "bwrap: setting up uid map: Permission denied\n"}; + c.proot = caps::Backend{.name = "proot", .bin = "/opt/proot", .source = "payload", .usable = true}; + c.pasta_missing = "pasta (passt) is not installed"; + break; + case 2: // nothing + c.pasta_missing = "/dev/net/tun is missing on this host"; + break; + case 3: c.platform = "macos"; break; + default: c.platform = "windows"; break; + } + return c; +} + +pol::Policy policy_of(int preset, int net, int extra) { + auto p = pol::preset(static_cast(preset)); + if (net == 1) { p.net = pol::Net::Nat; } + if (net == 2) { p.net = pol::Net::Proxy; p.proxy = "socks5h://proxy.example:1080"; } + if (net == 3) { p.net = pol::Net::None; } + if (extra % 3 == 1) { + p.mounts.push_back({"/work/src", "", true, true}); + p.mounts.push_back({"/work/ref", "/ref", false, true}); + p.env_pass.push_back("EDITOR"); + } + if (extra % 6 == 5) { + p.mounts.push_back({"/etc", "/etc", false, true}); // refused: the sandbox's own + p.mounts.push_back({"/work/missing", "", true, true}); // refused: absent + } + if (extra % 3 == 2) { + p.disable_userns = true; + p.needs["net"] = pol::Need::Must; + p.env_explicit_any = false; + } + if (extra % 5 == 4) p.no_degrade = true; + return p; +} + +sp::Request request_of(int storage, bool interactive, int grants, bool root, std::optional preferred) { + sp::Request r; + r.instance = "box"; + r.instance_dir = "/h/.xlings/subos/box"; + r.user = "alice"; + r.interactive = interactive; + r.storage = static_cast(storage); + r.image_mountpoint = "/h/.xlings/subos/box/.img"; + r.preferred = preferred; + if (grants == 1) r.grants = {"gpu", "display", "ssh-agent"}; + if (grants == 2) r.grants = {"audio", "dbus", "camera", "host-loopback"}; + r.host_env = {{"TERM", "xterm-256color"}, {"LANG", "en_US.UTF-8"}, {"LC_ALL", "en_US.UTF-8"}, + {"DISPLAY", ":0"}, {"XAUTHORITY", "/h/.Xauthority"}, {"WAYLAND_DISPLAY", "wayland-0"}, + {"XDG_RUNTIME_DIR", "/run/user/1000"}, {"SSH_AUTH_SOCK", "/tmp/ssh-agent.sock"}, + {"DBUS_SESSION_BUS_ADDRESS", "unix:path=/run/user/1000/bus"}, {"http_proxy", "http://p:3128"}, + {"EDITOR", "vim"}, {"SECRET_TOKEN", "do-not-pass"}}; + r.explicit_env = {{"FOO", "bar"}, {"EDITOR", "nano"}}; + if (interactive) r.argv = {}; else r.argv = {"make", "-j4"}; + if (grants == 2) r.publish = {"8080:80"}; + if (root) { + r.root = "/h/.xlings/subos/box/rootfs"; + r.root_mounts = {{sp::MountKind::RoBind, "/h/.xlings/data/xpkgs/xim-x-glibc/2.44", "/h/.xlings/data/xpkgs/xim-x-glibc/2.44"}}; + } + r.host_exists = [](std::string_view p) { return p.find("missing") == std::string_view::npos; }; + return r; +} + +nlohmann::json outcome(const pol::Policy& p, const caps::Caps& c, const sp::Request& r) { + const HomeView home{"/h/.xlings"}; + auto compiled = xlings::confine::compile(p, home, c, r); + if (!compiled) { + nlohmann::json j{{"refused", nlohmann::json::array()}}; + for (const auto& u : compiled.error().missing) + j["refused"].push_back({{"dimension", u.dimension}, {"reason", u.reason}, {"fix", u.fix}}); + return j; + } + const auto& s = *compiled; + nlohmann::json j{{"spec", s.describe()}}; + j["backend_bin"] = s.backend_bin.generic_string(); + if (s.backend == sp::Backend::Bwrap || s.backend == sp::Backend::Fake) j["argv"] = pv::bwrap_argv(s, 7); + if (s.backend == sp::Backend::Proot) j["argv"] = pv::proot_argv(s); + if (s.net_nat) j["pasta"] = pv::pasta_args(s); + j["env"] = pv::process_env(s, {{"INHERITED", "1"}, {"PATH", "/usr/bin"}}); + j["landlock_rw"] = nlohmann::json::array(); + for (const auto& w : s.landlock_rw) j["landlock_rw"].push_back(w.generic_string()); + return j; +} + +struct Case { + std::string name; + nlohmann::json result; +}; + +std::string summary(const nlohmann::json& result) { + if (result.contains("refused")) { + std::string dims; + for (const auto& u : result["refused"]) dims += (dims.empty() ? "" : ",") + u["dimension"].get(); + return "refused:" + dims; + } + return "ok:" + result["spec"]["backend"].get(); +} + +std::vector cases() { + std::vector out; + const std::array, 5> preferred{ + std::nullopt, sp::Backend::Bwrap, sp::Backend::Proot, sp::Backend::Landlock, sp::Backend::Fake}; + auto add = [&](std::string sweep, int preset, std::size_t pi, int h, int storage, int net, int grants, + int extra, bool interactive, bool root) { + auto p = policy_of(preset, net, extra); + auto r = request_of(storage, interactive, grants, root, preferred[pi]); + out.push_back({std::format("{} preset{} pref{} host{} st{} net{} gr{} x{} i{} root{}", sweep, preset, pi, + h, storage, net, grants, extra, interactive, root), + outcome(p, host(h), r)}); + }; + // A: every host and backend preference, the other axes rotating -- + // the refusals live here. + int n = 0; + for (int round = 0; round != 2; ++round) + for (int preset = 0; preset != 4; ++preset) + for (std::size_t pi = 0; pi != preferred.size(); ++pi) + for (int h = 0; h != 5; ++h) { + const int k = n++ + round * 7; + add(std::format("A{}", round), preset, pi, h, k % 3, (k / 3) % 4, (k / 2) % 3, k / 5, + (k % 2) == 0, (k % 7) == round); + } + // B: a host with every backend, every combination of what changes the + // shape of a sandbox. + for (int preset = 0; preset != 4; ++preset) + for (std::size_t pi = 0; pi != 4; ++pi) + for (int storage = 0; storage != 3; ++storage) + for (int net = 0; net != 4; ++net) + for (int interactive = 0; interactive != 2; ++interactive) { + const int k = preset * 31 + static_cast(pi) * 7 + storage * 5 + net * 3 + interactive; + add("B", preset, pi, 0, storage, net, k % 3, k % 13, interactive == 1, + pi <= 1 && (k % 4) == 0); + } + // D: the namespace-less backends (proot, Landlock) where they can run: + // shared storage, on the full host and on the one whose bwrap fails. + for (std::size_t pi : {std::size_t{2}, std::size_t{3}}) + for (int h = 0; h != 2; ++h) + for (int preset = 0; preset != 4; ++preset) + for (int net = 0; net != 4; ++net) + for (int grants = 0; grants != 3; ++grants) { + const int k = preset * 11 + net * 5 + grants * 3 + h; + add("D", preset, pi, h, 0, net, grants, k % 6 == 5 ? 0 : k % 5, (k % 2) == 1, false); + } + // C: the home redirect of macOS and Windows. + for (int h = 3; h != 5; ++h) + for (int preset = 0; preset != 4; ++preset) + for (int net = 0; net != 4; ++net) + for (int grants = 0; grants != 3; ++grants) + add("C", preset, 0, h, 0, net, grants, grants * 3, grants == 1, false); + return out; +} + +} // namespace + +XTEST(IntentEq, TheCompiledSandboxIsByteIdenticalToTheRecordedGolden, + .area = "subos", .covers = {"INTENT-EQ"}, .requires_ = {"posix"}) { + // One line per case: its name, a summary, and the sha256 of everything + // it compiled to (description, argv, environment). A difference prints + // the whole of what it compiles to now. + const auto all = cases(); + ASSERT_GE(all.size(), 500u); + std::vector lines; + for (const auto& c : all) + lines.push_back(std::format("{}\t{}\t{}", c.name, summary(c.result), xlings::sha256::hex(c.result.dump()))); + if (const char* w = std::getenv("XLINGS_INTENT_GOLDEN_WRITE"); w && std::string_view(w) == "1") { + fs::create_directories(golden_path().parent_path()); + std::ofstream out(golden_path(), std::ios::binary); + for (const auto& l : lines) out << l << '\n'; + GTEST_SKIP() << "golden written: " << golden_path().string(); + } + std::ifstream in(golden_path(), std::ios::binary); + ASSERT_TRUE(in) << "no golden at " << golden_path().string() << " (XLINGS_INTENT_GOLDEN_WRITE=1)"; + std::vector golden; + for (std::string l; std::getline(in, l);) golden.push_back(l); + ASSERT_EQ(golden.size(), lines.size()); + int differing = 0; + for (std::size_t i = 0; i != lines.size(); ++i) { + if (lines[i] == golden[i]) continue; + if (++differing <= 3) ADD_FAILURE() << "case differs\n golden: " << golden[i] << "\n now: " + << lines[i] << "\n " << all[i].result.dump(); + } + EXPECT_EQ(differing, 0); +} diff --git a/tests/unit/test_interface_protocol.cpp b/tests/unit/test_interface_protocol.cpp index ff75ef2b1..1f6f8cd24 100644 --- a/tests/unit/test_interface_protocol.cpp +++ b/tests/unit/test_interface_protocol.cpp @@ -87,9 +87,14 @@ std::pair run_xlings_( // would override the `cd ` isolation below by // forcing the spawned binary to use the leaked project root. _putenv_s("XLINGS_PROJECT_DIR", ""); + // Same leak, one level down: a shell inside a subos exports which + // subos it is in, and the spawned binary obeys it over the test + // home's own choice. + _putenv_s("XLINGS_ACTIVE_SUBOS", ""); #else ::setenv("XLINGS_HOME", xlings_home.c_str(), 1); ::unsetenv("XLINGS_PROJECT_DIR"); + ::unsetenv("XLINGS_ACTIVE_SUBOS"); #endif } @@ -192,7 +197,7 @@ TEST(InterfaceProtocol, VersionFlagPrintsProtocolVersion) { auto j = nlohmann::json::parse(out, nullptr, false); ASSERT_FALSE(j.is_discarded()) << "non-JSON output: " << out; ASSERT_TRUE(j.contains("protocol_version")); - EXPECT_EQ(j["protocol_version"].get(), "1.5"); + EXPECT_EQ(j["protocol_version"].get(), "1.6"); std::filesystem::remove_all(home); } diff --git a/tests/unit/test_lua_protocol.cpp b/tests/unit/test_lua_protocol.cpp new file mode 100644 index 000000000..fa47b4d31 --- /dev/null +++ b/tests/unit/test_lua_protocol.cpp @@ -0,0 +1,98 @@ +#include + +import std; +import mcpplibs.xpkg; +import mcpplibs.xpkg.executor; +import xlings.core.xim.lua_protocol; +import xlings.libs.json; + +namespace xp = mcpplibs::xpkg; +namespace protocol = xlings::xim::lua_protocol; + +TEST(LuaProtocol, PreservesArchitectureResourcesAndAllDependencyDomains) { + xp::Package package; + package.spec = "2"; + package.name = "tool"; + package.namespace_ = "fixture"; + package.description = "unicode 路径"; + package.type = xp::PackageType::Config; + package.status = xp::PackageStatus::Stable; + package.programs = {"tool", "tool-cc"}; + package.archs = {"aarch64"}; + package.xpm.source = "xlings-res"; + package.xpm.source_mirrors = {{"CN", "cn/${version}"}}; + package.xpm.platform_sources = {{"linux", "mirror/${arch}"}}; + package.xpm.platform_source_mirrors = {{"linux", {{"GLOBAL", "global"}}}}; + auto& resource = package.xpm.entries["linux"]["1.2.3"]; + resource.revision = 7; + resource.is_res = true; + resource.ref = "stable"; + resource.archs["aarch64"] = {.url = "url", .sha256 = "sha", .mirrors = {{"CN", "cn-url"}}}; + resource.sha256_by_arch = {{"aarch64", "checksum"}}; + resource.arch_alias = {{"aarch64", "arm64"}}; + package.xpm.runtime_deps["linux"] = {"fixture:lib@>=2"}; + package.xpm.build_deps["linux"] = {"host:compiler@4"}; + package.xpm.deps["linux"] = {"fixture:lib@>=2", "host:compiler@4"}; + package.xpm.exports["linux"].runtime = {.loader = "ld", .libdirs = {"lib64"}, .abi = "musl"}; + package.xpm.inherits = {{"ubuntu", "linux"}}; + const auto restored = protocol::package(protocol::encode(package)); + EXPECT_EQ(restored.type, package.type); + EXPECT_EQ(restored.description, package.description); + EXPECT_EQ(restored.xpm.entries.at("linux").at("1.2.3").archs.at("aarch64").mirrors.at("CN"), + "cn-url"); + EXPECT_EQ(restored.xpm.entries.at("linux").at("1.2.3").revision, 7); + EXPECT_EQ(restored.xpm.entries.at("linux").at("1.2.3").arch_alias.at("aarch64"), "arm64"); + EXPECT_EQ(restored.xpm.platform_source_mirrors.at("linux").at("GLOBAL"), "global"); + EXPECT_EQ(restored.xpm.runtime_deps, package.xpm.runtime_deps); + EXPECT_EQ(restored.xpm.build_deps, package.xpm.build_deps); + EXPECT_EQ(restored.xpm.exports.at("linux").runtime.abi, "musl"); + auto malformed = protocol::encode(package); + malformed.erase("xpm"); + EXPECT_THROW(protocol::package(malformed), nlohmann::json::exception); +} + +TEST(LuaProtocol, PreservesResolvedDependenciesLogsAndDeferredScopeEffects) { + xp::HookInvocation invocation; + invocation.action = xp::HookAction::RunHook; + invocation.hook = xp::HookType::Config; + invocation.package = "recipe.lua"; + invocation.context.args = {"a b", "--credential=secret"}; + invocation.context.install_dir = "payload"; + invocation.context.install_file = "archive.tar.gz"; + invocation.context.hook_log = "only-this-hook.log"; + invocation.context.dependency_store_roots = {"one", "two"}; + invocation.context.deps_exports["lib@>=2"] = { + .loader = "/store/ld", .libdirs = {"/store/lib"}, .abi = "musl"}; + invocation.context.resolved_deps["lib@>=2"] = {.spec = "lib@>=2", + .name = "fixture:lib", + .version = "2.4", + .install_dir = "/store/lib", + .libdirs = {"/store/lib/lib64"}, + .source = "plan-range"}; + const auto restored = protocol::invocation(protocol::encode(invocation)); + EXPECT_EQ(restored.context.args, invocation.context.args); + EXPECT_EQ(restored.context.hook_log, invocation.context.hook_log); + EXPECT_EQ(restored.context.resolved_deps.at("lib@>=2").source, "plan-range"); + EXPECT_EQ(restored.context.dependency_store_roots, invocation.context.dependency_store_roots); + xp::HookResponse response; + response.result = {.success = true, .output = "log output", .version = "2.4"}; + response.hooks[3] = true; + response.xvm_ops.push_back({.op = "add", + .name = "tool", + .version = "2.4", + .binding = "suite@2.4", + .src = "share/data", + .dst = "usr/share/data", + .args = {"a b"}, + .envs = {{"MODE", "value"}}}); + response.install_requests = {{.op = "install", .target = "fixture:extra@1"}}; + const auto answer = protocol::response(protocol::encode(response)); + ASSERT_EQ(answer.xvm_ops.size(), 1u); + EXPECT_EQ(answer.xvm_ops.front().binding, "suite@2.4"); + EXPECT_EQ(answer.xvm_ops.front().args, (std::vector{"a b"})); + EXPECT_EQ(answer.xvm_ops.front().envs, response.xvm_ops.front().envs); + EXPECT_EQ(answer.install_requests.front().target, "fixture:extra@1"); + auto malformed = protocol::encode(response); + malformed.erase("install_requests"); + EXPECT_THROW(protocol::response(malformed), nlohmann::json::exception); +} diff --git a/tests/unit/test_platform_atomic.cpp b/tests/unit/test_platform_atomic.cpp index a55a1a778..25d988cae 100644 --- a/tests/unit/test_platform_atomic.cpp +++ b/tests/unit/test_platform_atomic.cpp @@ -44,7 +44,7 @@ import xlings.platform; import xlings.libs.json; import xlings.core.xself; import xlings.core.profile; -import xlings.core.subos.gpu; +import xlings.subos.gpu; import xlings.core.xim.downloader; import xlings.runtime; import xlings.capabilities; diff --git a/tests/unit/test_platform_atomic_write.cpp b/tests/unit/test_platform_atomic_write.cpp new file mode 100644 index 000000000..c1f1b2e74 --- /dev/null +++ b/tests/unit/test_platform_atomic_write.cpp @@ -0,0 +1,76 @@ +#include + +import std; +import xlings.platform; +import xlings.testkit; + +namespace fs = std::filesystem; +namespace tk = xlings::testkit; +namespace p = xlings::platform; + +TEST(PlatformAtomicWrite, ReplacesAnExistingFileAndDoesNotOverwriteTheOldFixedScratchName) { + auto home = tk::Home::isolated("atomic-file"); + const auto target = home.root() / "policy.json"; + const auto sentinel = home.root() / (".policy.json.xlings-tmp." + std::to_string(p::get_pid())); + tk::write_file(target, "old complete content"); + tk::write_file(sentinel, "unowned scratch name"); + EXPECT_NO_THROW(p::write_file_atomic(target.string(), "new complete content")); + EXPECT_EQ(tk::read_file(target), "new complete content"); + EXPECT_EQ(tk::read_file(sentinel), "unowned scratch name"); +} + +TEST(PlatformAtomicWrite, PublicationFailurePreservesThePreviousDestination) { + auto home = tk::Home::isolated("atomic-failure"); + const auto target = home.root() / "policy.json"; + fs::create_directories(target); + tk::write_file(target / "user-file", "old complete content"); + EXPECT_THROW(p::write_file_atomic(target.string(), "new content"), std::runtime_error); + EXPECT_TRUE(fs::is_directory(target)); + EXPECT_EQ(tk::read_file(target / "user-file"), "old complete content"); + std::size_t entries = 0; + for (const auto& entry : fs::directory_iterator(home.root())) + if (entry.path().filename().string().starts_with(".policy.json.xlings-tmp.")) ++entries; + EXPECT_EQ(entries, 0u) << "only an owned, unpublished scratch may be cleaned"; +} + +TEST(PlatformAtomicWrite, FollowsAUserSymlinkWithoutReplacingTheLink) { + if constexpr (!tk::is_posix) GTEST_SKIP() << "symlink privileges are not available on every Windows host"; + auto home = tk::Home::isolated("atomic-link"); + const auto original = home.root() / "user-rc"; + const auto link = home.root() / "linked-rc"; + tk::write_file(original, "old rc"); + fs::create_symlink(original.filename(), link); + EXPECT_NO_THROW(p::write_file_atomic(link.string(), "new rc")); + EXPECT_TRUE(fs::is_symlink(link)); + EXPECT_EQ(tk::read_file(original), "new rc"); +} + +TEST(PlatformAtomicWrite, PublicationWithoutReplacementPreservesEveryExistingDestination) { + auto home = tk::Home::isolated("atomic-publish"); + const auto source = home.root() / "scratch"; + const auto existing = home.root() / "existing"; + tk::write_file(source, "new artefact"); + tk::write_file(existing, "old artefact"); + EXPECT_FALSE(p::rename_no_replace(source, existing)); + EXPECT_EQ(tk::read_file(source), "new artefact"); + EXPECT_EQ(tk::read_file(existing), "old artefact"); + const auto directory = home.root() / "empty-dir"; + const auto scratch_directory = home.root() / "scratch-dir"; + fs::create_directories(directory); + fs::create_directories(scratch_directory); + EXPECT_FALSE(p::rename_no_replace(scratch_directory, directory)); + EXPECT_TRUE(fs::is_directory(scratch_directory)); + EXPECT_TRUE(fs::is_empty(directory)); + if constexpr (tk::is_posix) { + const auto dangling = home.root() / "dangling"; + fs::create_symlink("missing", dangling); + EXPECT_FALSE(p::rename_no_replace(source, dangling)); + EXPECT_EQ(fs::read_symlink(dangling), "missing"); + EXPECT_EQ(tk::read_file(source), "new artefact"); + } + const auto published = home.root() / "published"; + auto result = p::rename_no_replace(source, published); + ASSERT_TRUE(result) << result.error(); + EXPECT_FALSE(fs::exists(source)); + EXPECT_EQ(tk::read_file(published), "new artefact"); +} diff --git a/tests/unit/test_platform_seccomp.cpp b/tests/unit/test_platform_seccomp.cpp new file mode 100644 index 000000000..b846b8f1a --- /dev/null +++ b/tests/unit/test_platform_seccomp.cpp @@ -0,0 +1,196 @@ +// The terminal-injection filter (C11): its shape, and -- on a host that can +// install a seccomp filter -- what it does to a process that carries it. +#include +#if defined(__linux__) +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#endif + +import std; +import xlings.platform; + +// Linux only: the filter is a Linux mechanism, and elsewhere there is none. +TEST(SubosSeccomp, TheProgramIsNonEmptyOnSupportedArchitectures) { + auto p = xlings::platform::seccomp::block_terminal_injection(); +#if defined(__linux__) && (defined(__x86_64__) || defined(__aarch64__)) + ASSERT_FALSE(p.empty()); + EXPECT_EQ(p.size() % 8, 0u); + EXPECT_LT(xlings::platform::seccomp::instruction_count(p), 64u); +#else + EXPECT_TRUE(p.empty()); +#endif +} + +#if defined(__linux__) && (defined(__x86_64__) || defined(__aarch64__)) +TEST(SubosSeccomp, AProcessCarryingTheFilterCannotInjectIntoATerminal) { + // In a child: install the filter, then try TIOCSTI (and the 64-bit + // spelling with a high bit set) on stdin, and an unrelated ioctl. + auto program = xlings::platform::seccomp::block_terminal_injection(); + pid_t pid = ::fork(); + ASSERT_GE(pid, 0); + if (pid == 0) { + struct sock_fprog prog{}; + prog.len = static_cast(program.size() / 8); + prog.filter = reinterpret_cast(program.data()); + if (::prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) ::_exit(10); + if (::prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog) != 0) ::_exit(11); + char c = 'x'; + errno = 0; + int r1 = ::ioctl(0, TIOCSTI, &c); + int e1 = errno; + errno = 0; + int r2 = ::syscall(SYS_ioctl, 0, 0x100000000UL | TIOCSTI, &c); + int e2 = errno; + errno = 0; + int winsz[4]; + ::ioctl(0, TIOCGWINSZ, winsz); // allowed (may fail with ENOTTY, never EPERM) + int e3 = errno; + if (r1 != -1 || e1 != EPERM) ::_exit(20); + if (r2 != -1 || e2 != EPERM) ::_exit(21); + if (e3 == EPERM) ::_exit(22); + ::_exit(0); + } + int status = 0; + ::waitpid(pid, &status, 0); + ASSERT_TRUE(WIFEXITED(status)); + EXPECT_EQ(WEXITSTATUS(status), 0) << "10/11: could not install; 20: TIOCSTI allowed; " + "21: high-bit spelling allowed; 22: other ioctl blocked"; +} + +TEST(SubosSeccomp, ExecRemainsBlockedUntilTheSupervisorAllowsOrDeniesIt) { + for (bool allow : {false, true}) { + auto ctl = xlings::platform::unix_pair(); + ASSERT_TRUE(ctl); + const int pid = ::fork(); + ASSERT_GE(pid, 0); + if (pid == 0) { + ::close((*ctl)[0]); + const int listener = xlings::platform::seccomp::exec_listener(); + const int fds[] = {listener}; + (void)xlings::platform::send_message((*ctl)[1], "listener", + listener >= 0 ? std::span(fds) : std::span{}); + if (listener < 0) ::_exit(77); + ::close(listener); + ::close((*ctl)[1]); + ::execl("/bin/true", "true", static_cast(nullptr)); + ::_exit(!allow && errno == EACCES ? 0 : 91); + } + struct Cleanup { + int pid; + int socket; + int listener = -1; + ~Cleanup() { + if (pid > 0) { + ::kill(pid, SIGKILL); + (void)::waitpid(pid, nullptr, 0); + } + ::close(socket); + if (listener >= 0) ::close(listener); + } + } cleanup{pid, (*ctl)[0]}; + ::close((*ctl)[1]); + xlings::platform::PollFd ready{cleanup.socket}; + ASSERT_GT(xlings::platform::poll_fds(std::span(&ready, 1), 2000), 0); + auto msg = xlings::platform::receive_message(cleanup.socket); + ASSERT_TRUE(msg); + if (msg->fds.empty()) GTEST_SKIP() << "kernel cannot install an exec notification filter"; + cleanup.listener = msg->fds.front(); + xlings::platform::PollFd notification{cleanup.listener}; + ASSERT_GT(xlings::platform::poll_fds(std::span(¬ification, 1), 2000), 0); + auto ex = xlings::platform::seccomp::next_exec(cleanup.listener); + ASSERT_TRUE(ex); + EXPECT_EQ(ex->path, "/bin/true"); + int status = 0; + ASSERT_EQ(::waitpid(pid, &status, WNOHANG), 0) << "exec was released before its audit"; + ASSERT_TRUE(xlings::platform::seccomp::complete_exec(cleanup.listener, ex->id, allow)); + ASSERT_EQ(::waitpid(pid, &status, 0), pid); + cleanup.pid = -1; + ASSERT_TRUE(WIFEXITED(status)); + EXPECT_EQ(WEXITSTATUS(status), 0) << "allow=" << allow; + } +} +TEST(SubosSeccomp, OneListenerBlocksBothExecAndDatagramsUntilTheirAuditCompletes) { + for (bool allowExec : {false, true}) { + auto control = xlings::platform::unix_pair(); + ASSERT_TRUE(control); + const int pid = ::fork(); + ASSERT_GE(pid, 0); + if (pid == 0) { + ::close((*control)[0]); + std::promise prepared; + auto ready = prepared.get_future(); + std::jthread helper([&] { + const int listener = ready.get(); + const std::array descriptors{listener}; + (void)xlings::platform::send_message((*control)[1], "listener", + listener >= 0 ? std::span(descriptors) : std::span{}); + if (listener >= 0) ::close(listener); + }); + const int listener = xlings::platform::net_notify::listener({.network = true, .exec = true}); + prepared.set_value(listener); + helper.join(); + ::close((*control)[1]); + if (listener < 0) ::_exit(77); + const int socket = ::socket(AF_INET, SOCK_DGRAM, 0); + sockaddr_in destination{}; + destination.sin_family = AF_INET; + destination.sin_port = htons(9); + destination.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + const char payload = 'x'; + if (::sendto(socket, &payload, 1, 0, reinterpret_cast(&destination), sizeof(destination)) != -1 || + errno != EACCES) ::_exit(90); + ::close(socket); + ::execl("/bin/true", "true", static_cast(nullptr)); + ::_exit(!allowExec && errno == EACCES ? 0 : 91); + } + struct Cleanup { + int pid, channel; + int listener{-1}; + ~Cleanup() { + if (pid > 0) { ::kill(pid, SIGKILL); (void)::waitpid(pid, nullptr, 0); } + ::close(channel); + if (listener >= 0) ::close(listener); + } + } cleanup{pid, (*control)[0]}; + ::close((*control)[1]); + xlings::platform::PollFd ready{cleanup.channel}; + ASSERT_GT(xlings::platform::poll_fds(std::span(&ready, 1), 2000), 0); + const auto handoff = xlings::platform::receive_message(cleanup.channel); + ASSERT_TRUE(handoff); + ASSERT_EQ(handoff->fds.size(), 1u) << "one combined listener must be installed, with no EBUSY fallback"; + cleanup.listener = handoff->fds.front(); + for (const auto expected : {xlings::platform::net_notify::Kind::Network, xlings::platform::net_notify::Kind::Exec}) { + xlings::platform::PollFd pending{cleanup.listener}; + ASSERT_GT(xlings::platform::poll_fds(std::span(&pending, 1), 2000), 0); + const auto notification = xlings::platform::net_notify::next(cleanup.listener); + ASSERT_TRUE(notification); + EXPECT_EQ(notification->kind, expected); + int status{}; + ASSERT_EQ(::waitpid(pid, &status, WNOHANG), 0) << "the call ran before its audit acknowledgement"; + if (expected == xlings::platform::net_notify::Kind::Network) { + EXPECT_EQ(notification->syscall, "sendto"); + EXPECT_EQ(notification->address, "127.0.0.1"); + EXPECT_EQ(notification->port, 9u); + EXPECT_TRUE(notification->address_readable); + } else EXPECT_EQ(notification->execPath, "/bin/true"); + ASSERT_TRUE(xlings::platform::net_notify::complete(cleanup.listener, notification->id, + expected == xlings::platform::net_notify::Kind::Exec && allowExec)); + } + int status{}; + ASSERT_EQ(::waitpid(pid, &status, 0), pid); + cleanup.pid = -1; + ASSERT_TRUE(WIFEXITED(status)); + EXPECT_EQ(WEXITSTATUS(status), 0); + } +} + +#endif diff --git a/tests/unit/test_platform_stream.cpp b/tests/unit/test_platform_stream.cpp new file mode 100644 index 000000000..14ed04d4a --- /dev/null +++ b/tests/unit/test_platform_stream.cpp @@ -0,0 +1,148 @@ +#include +#include +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.platform; +import xlings.platform.stream; +import xlings.libs.json; + +namespace tk = xlings::testkit; +namespace ps = xlings::platform::stream; + +namespace { +struct FixtureProcess { + FixtureProcess() { + const auto* gate = std::getenv("XLINGS_STREAM_TEST_GATE"); + if (!gate || !*gate) return; + std::fwrite("EARLY-OUT", 1, 9, stdout); + std::fflush(stdout); + std::fwrite("EARLY-ERR", 1, 9, stderr); + std::fflush(stderr); + const auto until = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (!std::filesystem::exists(gate)) { + if (std::chrono::steady_clock::now() >= until) std::exit(99); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + const std::string binary(65536, '\xff'); + std::fwrite(binary.data(), 1, binary.size(), stdout); + std::fflush(stdout); + std::fwrite(binary.data(), 1, binary.size(), stderr); + std::fflush(stderr); + std::exit(7); + } +} fixture; + +struct Gate { + std::string key, prior; + explicit Gate(const std::filesystem::path& path) : Gate("XLINGS_STREAM_TEST_GATE", path.string()) {} + Gate(std::string name, std::string value) : key(std::move(name)) { + if (const auto* old = std::getenv(key.c_str())) prior = old; + xlings::platform::set_env_variable(key, value); + } + ~Gate() { + if (prior.empty()) xlings::platform::unset_env_variable(key); + else xlings::platform::set_env_variable(key, prior); + } +}; +} + +XTEST(PlatformStream, RealSubosInterfaceFramesEarlyStreamsAndBinaryChunks, + .area = "interface", .covers = {"IFACE-EXEC-STREAM"}, .requires_ = {"xlings-bin"}) { + auto home = tk::Home::isolated("interface-stream"); + const auto created = home.xlings({"subos", "new", "box"}); + ASSERT_EQ(created.exit_code, 0) << created.transcript(); + const auto gate_file = home.root() / "release-interface"; + const nlohmann::json arguments{{"name", "box"}, + {"argv", {xlings::platform::get_executable_path().string()}}, + {"env", {{"XLINGS_STREAM_TEST_GATE", gate_file.string()}}}}; + const auto args_file = home.root() / "arguments.json"; + tk::write_file(args_file, arguments.dump()); + Gate selected_home{"XLINGS_HOME", home.dir().string()}; + Gate project{"XLINGS_PROJECT_DIR", ""}; + Gate subos{"XLINGS_ACTIVE_SUBOS", ""}; + const auto original = std::filesystem::current_path(); + struct Cwd { + std::filesystem::path previous; + ~Cwd() { std::error_code ignored; std::filesystem::current_path(previous, ignored); } + } cwd{original}; + std::filesystem::current_path(home.root()); + std::string pending, outer_errors; + bool early_out{}, early_err{}, binary{}, final{}; + int result_code{}; + const auto code = ps::run({tk::xlings_binary().string(), "interface", "subos_exec", "--args-file", args_file.string()}, + [&](std::string_view channel, std::string_view bytes) { + if (channel == "stderr") { outer_errors.append(bytes); return; } + pending.append(bytes); + for (std::size_t newline; (newline = pending.find('\n')) != pending.npos;) { + const auto event = nlohmann::json::parse(pending.substr(0, newline), nullptr, false); + pending.erase(0, newline + 1); + ASSERT_TRUE(event.is_object()); + if (event.value("kind", "") == "result") { + final = true; + result_code = event.value("exitCode", 0); + } + if (event.value("dataKind", "") != "subos_exec_output") continue; + EXPECT_FALSE(final); + const auto& payload = event.at("payload"); + if (payload.value("data", "").find("EARLY-OUT") != std::string::npos) + early_out = payload.value("stream", "") == "stdout"; + if (payload.value("data", "").find("EARLY-ERR") != std::string::npos) + early_err = payload.value("stream", "") == "stderr"; + binary = binary || payload.value("encoding", "") == "base64"; + if (early_out && early_err && !std::filesystem::exists(gate_file)) + tk::write_file(gate_file, "the wire delivered both streams before completion"); + } + }); + EXPECT_EQ(code, 7) << outer_errors; + EXPECT_TRUE(early_out); + EXPECT_TRUE(early_err); + EXPECT_TRUE(binary); + EXPECT_TRUE(final); + EXPECT_EQ(result_code, 7); + EXPECT_TRUE(pending.empty()); +} + +XTEST(PlatformStream, SeparateByteStreamsArriveBeforeExitWithoutWaitingForNewlines, + .area = "platform", .covers = {"IFACE-EXEC-STREAM"}) { + auto home = tk::Home::isolated("stream-output"); + const auto gate_file = home.root() / "release gate with spaces"; + Gate gate{gate_file}; + std::string out, err; + std::size_t maximum{}; + const auto code = ps::run({xlings::platform::get_executable_path().string()}, + [&](std::string_view channel, std::string_view bytes) { + maximum = std::max(maximum, bytes.size()); + (channel == "stdout" ? out : err).append(bytes); + if (out.starts_with("EARLY-OUT") && err.starts_with("EARLY-ERR")) + tk::write_file(gate_file, "release child only after receiving both streams"); + }); + EXPECT_EQ(code, 7); + EXPECT_EQ(out, "EARLY-OUT" + std::string(65536, '\xff')); + EXPECT_EQ(err, "EARLY-ERR" + std::string(65536, '\xff')); + EXPECT_LE(maximum, 4096); +} + +XTEST(PlatformStream, CancellationReapsAChildBlockedAfterItsFirstOutput, + .area = "platform", .covers = {"IFACE-EXEC-STREAM"}) { + auto home = tk::Home::isolated("stream-cancel"); + Gate gate{home.root() / "never-release"}; + const auto began = std::chrono::steady_clock::now(); + bool early{}; + const auto code = ps::run({xlings::platform::get_executable_path().string()}, + [&](std::string_view, std::string_view bytes) { early = early || bytes.starts_with("EARLY-"); }, + [&] { return early; }); + EXPECT_TRUE(early); + EXPECT_EQ(code, 130); + EXPECT_LT(std::chrono::steady_clock::now() - began, std::chrono::seconds(2)); +} + +XTEST(PlatformStream, InvalidArgvAndMissingExecutablesHaveStableExitCodes, + .area = "platform", .covers = {"IFACE-EXEC-STREAM"}) { + const auto output = [](std::string_view, std::string_view) {}; + EXPECT_EQ(ps::run({}, output), 125); + EXPECT_EQ(ps::run({std::string{"invalid\0argument", 16}}, output), 125); + EXPECT_EQ(ps::run({"xlings-test-missing-stream-program-641"}, output), 127); +} diff --git a/tests/unit/test_platform_worker.cpp b/tests/unit/test_platform_worker.cpp new file mode 100644 index 000000000..154bc96bd --- /dev/null +++ b/tests/unit/test_platform_worker.cpp @@ -0,0 +1,85 @@ +#include + +import std; +import xlings.platform; +import xlings.platform.worker; +import xlings.testkit; + +namespace fs = std::filesystem; +namespace tk = xlings::testkit; +namespace worker = xlings::platform::worker; +namespace platform = xlings::platform; + +TEST(PlatformWorker, RecipeCaptureIncludesBothStreamsAndExecChildren) { + if constexpr (!tk::is_posix) + GTEST_SKIP() << "fork-based child output test is POSIX only"; + auto home = tk::Home::isolated("worker-capture"); + const auto log = home.root() / "recipe.log"; + int code = -1; + { + worker::OutputCapture capture(log); + std::cout << "recipe stdout\n"; + std::cerr << "recipe stderr\n"; + const auto pid = platform::fork_process(); + if (pid == 0) { + (void)platform::exec_path( + {"/bin/sh", "-c", "printf child_stdout; printf child_stderr >&2"}, home.env()); + platform::exit_now(127); + } + if (auto status = platform::wait_process(pid, true)) + code = platform::exit_code(*status); + } + EXPECT_EQ(code, 0); + const auto text = tk::read_file(log); + EXPECT_NE(text.find("recipe stdout"), std::string::npos); + EXPECT_NE(text.find("recipe stderr"), std::string::npos); + EXPECT_NE(text.find("child_stdout"), std::string::npos); + EXPECT_NE(text.find("child_stderr"), std::string::npos); + const auto next = home.root() / "next.log"; + { + worker::OutputCapture capture(next); + std::cout << "restored and recaptured\n"; + } + EXPECT_EQ(tk::read_file(log), text); + EXPECT_EQ(tk::read_file(next), "restored and recaptured\n"); +} + +TEST(PlatformWorker, RefusesNonRegularLogsAndRestoresStreamsAfterCaptureFailure) { + auto home = tk::Home::isolated("worker-log-type"); + const auto directory = home.root() / "directory"; + fs::create_directory(directory); + EXPECT_FALSE(worker::prepare_log(directory)); + EXPECT_THROW(worker::OutputCapture{directory}, std::runtime_error); + const auto regular = home.root() / "regular.log"; + EXPECT_TRUE(worker::prepare_log(regular)); + { + worker::OutputCapture capture(regular); + std::cerr << "stderr still works\n"; + } + EXPECT_EQ(tk::read_file(regular), "stderr still works\n"); + if constexpr (tk::is_posix) { + const auto sentinel = home.root() / "user-file"; + tk::write_file(sentinel, "never truncate"); + const auto link = home.root() / "link.log"; + fs::create_symlink(sentinel, link); + EXPECT_FALSE(worker::prepare_log(link)); + EXPECT_FALSE(worker::read_log(home.root(), "link.log")); + EXPECT_EQ(tk::read_file(sentinel), "never truncate"); + fs::remove(link); + fs::create_symlink(home.root() / "missing-user-file", link); + EXPECT_FALSE(worker::prepare_log(link)); + EXPECT_FALSE(fs::exists(home.root() / "missing-user-file")); + } +} + +TEST(PlatformWorker, OwnedLogReadsRefuseTraversalAndNonRegularFiles) { + auto home = tk::Home::isolated("worker-log-read"); + tk::write_file(home.root() / "output.log", "diagnostic output"); + const auto text = worker::read_log(home.root(), "output.log"); + ASSERT_TRUE(text) << text.error(); + EXPECT_EQ(*text, "diagnostic output"); + EXPECT_FALSE(worker::read_log(home.root(), "../output.log")); + EXPECT_FALSE(worker::read_log(home.root(), "nested/output.log")); + fs::create_directory(home.root() / "directory.log"); + EXPECT_FALSE(worker::read_log(home.root(), "directory.log")); +} diff --git a/tests/unit/test_prefix_domain.cpp b/tests/unit/test_prefix_domain.cpp new file mode 100644 index 000000000..bad761169 --- /dev/null +++ b/tests/unit/test_prefix_domain.cpp @@ -0,0 +1,146 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" +import std; +import xlings.core.home.prefix_domain; +import xlings.core.home; +import xlings.core.home.domain_producer; +import xlings.libs.json; +import xlings.subos.rootfs; +import xlings.subos.home_view; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; +namespace d = xlings::home::prefix_domain; +namespace producer = xlings::home::domain_producer; + +XTEST(PrefixDomain, SameOwnerStillRejectsInvalidHomeAuthority, .area = "home", + .covers = {"DOM-LAYOUTS"}) { + auto home = tk::Home::isolated("domain-owner-marker"); + const auto owner = fs::canonical(home.dir()); + tk::write_file(owner / ".xlings-home", R"({"mode":"root","layout":"invalid"})"); + EXPECT_FALSE(d::resolve(owner, owner)); + tk::write_file(owner / ".xlings-home", R"({"mode":"unknown","layout":2})"); + EXPECT_FALSE(d::resolve(owner, owner)); + tk::write_file(owner / ".xlings-home", R"({"mode":"root","layout":"single"})"); + auto valid = d::resolve(owner, owner); + ASSERT_TRUE(valid) << valid.error(); + EXPECT_FALSE(valid->privateHome); + EXPECT_EQ(valid->physicalHome, owner); + EXPECT_EQ(valid->layout, "single"); +} + +XTEST(PrefixDomain, DistinctOwnersReserveIndependentDomainsWithoutHostWrites, .area = "home", + .covers = {"DOM-LAYOUTS", "DOM-BUILD-INSIDE"}, .requires_ = {"posix"}) { + auto first = tk::Home::isolated("domain-first"); + auto second = tk::Home::isolated("domain-second"); + const auto missing = first.root() / "absent-system"; + auto one = d::resolve(first.dir(), "/xlings", missing); + auto two = d::resolve(second.dir(), "/xlings", missing); + ASSERT_TRUE(one) << one.error(); ASSERT_TRUE(two) << two.error(); + ASSERT_TRUE(d::prepare_private(*one)); ASSERT_TRUE(d::prepare_private(*two)); + EXPECT_NE(one->physicalHome, two->physicalHome); + EXPECT_EQ(one->logicalHome, two->logicalHome); + EXPECT_FALSE(fs::exists(missing)); + EXPECT_TRUE(d::parse(d::serialize(*one), first.dir())); + EXPECT_FALSE(d::parse(d::serialize(*one), second.dir())); + EXPECT_EQ(nlohmann::json::parse(tk::read_file(one->physicalHome / ".xlings-domain.json"))["owner_home"], + fs::canonical(first.dir()).generic_string()); +} + +XTEST(PrefixDomain, UnknownPrivateTreesAndRedirectedParentsArePreserved, .area = "home", + .covers = {"DOM-LAYOUTS"}, .requires_ = {"posix"}) { + auto home = tk::Home::isolated("domain-unknown"); + const auto missing = home.root() / "absent-system"; + const auto privateHome = home.dir() / "domains/xlings/private"; + tk::write_file(privateHome / "sentinel", "user-owned"); + EXPECT_FALSE(d::resolve(home.dir(), "/xlings", missing)); + EXPECT_EQ(tk::read_file(privateHome / "sentinel"), "user-owned"); + if constexpr (!tk::is_windows) { + auto other = tk::Home::isolated("domain-linked-parent"); + fs::create_directory_symlink(home.dir() / "domains", other.dir() / "domains"); + EXPECT_FALSE(d::resolve(other.dir(), "/xlings", missing)); + EXPECT_EQ(tk::read_file(privateHome / "sentinel"), "user-owned"); + } +} + +XTEST(PrefixDomain, SystemSourceIsCheckedReadOnlyAndPinnedAgainstDrift, .area = "home", + .covers = {"DOM-LAYOUTS", "HOME-LAYER-RESOLVE"}, .requires_ = {"posix"}) { + auto home = tk::Home::isolated("domain-system-proof"); + const auto system = home.root() / "system"; + tk::write_file(system / ".xlings-home", R"({"mode":"multi","layout":2})"); + tk::write_file(system / "sentinel", "system payload"); + auto domain = d::resolve(home.dir(), "/xlings", system); + ASSERT_TRUE(domain) << domain.error(); + ASSERT_TRUE(domain->systemSource); + EXPECT_EQ(*domain->systemSource, fs::canonical(system)); + ASSERT_TRUE(d::prepare_private(*domain)); + EXPECT_EQ(tk::read_file(system / "sentinel"), "system payload"); + auto marker = nlohmann::json::parse(tk::read_file(domain->physicalHome / ".xlings-domain.json")); + marker["system_source"] = (home.root() / "unrelated").generic_string(); + tk::write_file(domain->physicalHome / ".xlings-domain.json", marker.dump()); + EXPECT_FALSE(d::resolve(home.dir(), "/xlings", system)); + EXPECT_EQ(tk::read_file(system / "sentinel"), "system payload"); + tk::write_file(system / ".xlings-home", R"({"mode":"root","layout":42})"); + EXPECT_FALSE(d::resolve(home.dir(), "/xlings", system)); +} + +XTEST(PrefixDomain, TypedMappingsRejectTraversalAndKeepPhysicalAuthority, .area = "home", + .covers = {"DOM-PREFIX", "DOM-LAYOUTS"}, .requires_ = {"posix"}) { + auto home = tk::Home::isolated("domain-maps"); + auto domain = d::resolve(home.dir(), "/xlings", home.root() / "absent-system"); + ASSERT_TRUE(domain) << domain.error(); + EXPECT_EQ(*d::map_host(*domain, "/xlings/data/xpkgs/tool/1"), domain->physicalHome / "data/xpkgs/tool/1"); + EXPECT_EQ(*d::map_guest(*domain, domain->physicalHome / "data/xpkgs/tool/1"), fs::path("/xlings/data/xpkgs/tool/1")); + EXPECT_FALSE(d::map_host(*domain, "/xlings/../etc")); + EXPECT_FALSE(d::map_host(*domain, "/xlings-foreign/data")); + EXPECT_FALSE(d::map_host(*domain, "relative")); + EXPECT_FALSE(d::map_guest(*domain, home.dir() / "data/xpkgs/tool/1")); +} + +XTEST(PrefixDomain, ProducerCannotPublishAnUncommittedOrForeignControlScope, .area = "home", + .covers = {"DOM-BUILD-INSIDE"}, .requires_ = {"posix"}) { + auto home = tk::Home::isolated("domain-control-proof"); + auto domain = d::resolve(home.dir(), "/xlings", home.root() / "absent-system"); + ASSERT_TRUE(domain); ASSERT_TRUE(d::prepare_private(*domain)); + const auto actual = domain->physicalHome / "subos/demo"; + tk::write_file(actual / ".xlings.json", R"({"workspace":{}})"); + tk::write_file(xlings::subos::HomeView{domain->physicalHome}.instance_file("demo"), R"({"kind":"rootfs"})"); + EXPECT_FALSE(producer::publish_scope(*domain, "demo")); + EXPECT_FALSE(fs::exists(home.dir() / "subos/demo")); + auto committed = xlings::subos::rootfs::commit(actual, {}, "domain unit fixture"); + ASSERT_TRUE(committed) << committed.error(); + ASSERT_TRUE(producer::publish_scope(*domain, "demo")); + auto scope = producer::read_scope(home.dir(), "demo"); + ASSERT_TRUE(scope) << scope.error(); ASSERT_TRUE(*scope); + EXPECT_EQ((**scope).producerInstance, actual); + EXPECT_EQ((**scope).logicalInstance, fs::path("/xlings/subos/demo")); + EXPECT_FALSE(producer::publish_scope(*domain, "demo")); + tk::write_file(actual / ".xlings.json", "{corrupt"); + EXPECT_FALSE(producer::read_scope(home.dir(), "demo")); +} + +XTEST(PrefixDomain, KnownLegacyRootLayoutMigratesWithoutAcceptingUnknownNumbers, .area = "home", + .covers = {"DOM-LAYOUTS"}) { + auto home = tk::Home::isolated("domain-legacy-layout"); + for (const auto* layout : {"multi", "single"}) { + tk::write_file(home.dir() / ".xlings-home", nlohmann::json{{"mode", "root"}, {"layout", 2}, + {"root_layout", layout}, {"future", 42}}.dump()); + auto shared = xlings::home::shares_store(home.dir()); + ASSERT_TRUE(shared) << shared.error(); + EXPECT_EQ(*shared, std::string_view(layout) == "multi"); + auto sameOwner = d::resolve(home.dir(), fs::canonical(home.dir())); + ASSERT_TRUE(sameOwner) << sameOwner.error(); + ASSERT_TRUE(xlings::home::declare(home.dir(), std::nullopt, 2)); + const auto migrated = nlohmann::json::parse(tk::read_file(home.dir() / ".xlings-home")); + EXPECT_EQ(migrated["layout"], layout); + EXPECT_EQ(migrated["future"], 42); + } + for (auto marker : {nlohmann::json{{"mode", "root"}, {"layout", 3}, {"root_layout", "multi"}}, + nlohmann::json{{"mode", "root"}, {"layout", 2}}, + nlohmann::json{{"mode", "root"}, {"layout", "single"}, {"root_layout", "multi"}}}) { + tk::write_file(home.dir() / ".xlings-home", marker.dump()); + EXPECT_FALSE(xlings::home::shares_store(home.dir())); + EXPECT_FALSE(d::resolve(home.dir(), fs::canonical(home.dir()))); + } +} diff --git a/tests/unit/test_progress_output.cpp b/tests/unit/test_progress_output.cpp index 4d50904ca..5db58e54e 100644 --- a/tests/unit/test_progress_output.cpp +++ b/tests/unit/test_progress_output.cpp @@ -41,6 +41,9 @@ TEST(ProgressOutput, AgentRedirectionAndCliModeDisableTtyRewrite) { // all until the download finished -- a multi-minute silence that reads as a // hang. The two questions are answered by two predicates. TEST(ProgressOutput, NoColorSuppressesColourButNotProgress) { + const auto* previous_term = std::getenv("TERM"); + const std::string saved_term = previous_term ? previous_term : ""; + xlings::platform::set_env_variable("TERM", "xterm-256color"); const auto* previous = std::getenv("NO_COLOR"); const std::string saved = previous ? previous : ""; @@ -63,9 +66,8 @@ TEST(ProgressOutput, NoColorSuppressesColourButNotProgress) { EXPECT_TRUE(xlings::palette::opted_out_()); xlings::palette::set_plain(false); - if (!saved.empty()) { - xlings::platform::set_env_variable("NO_COLOR", saved); - } + xlings::platform::set_env_variable("NO_COLOR", saved); + xlings::platform::set_env_variable("TERM", saved_term); } TEST(ProgressOutput, RedirectedRenderingHasNoControlBytes) { diff --git a/tests/unit/test_root_compiler.cpp b/tests/unit/test_root_compiler.cpp new file mode 100644 index 000000000..63fb03fc7 --- /dev/null +++ b/tests/unit/test_root_compiler.cpp @@ -0,0 +1,53 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.core.xvm.shim; +import xlings.core.xvm.types; +import xlings.subos.rootfs; +import xlings.platform.target; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; +namespace xvm = xlings::xvm; +namespace rf = xlings::subos::rootfs; + +XTEST(RootCompiler, LogicalLoaderIsSelectedOnlyForARegisteredDriverInsideItsOwnProjection, + .area = "subos", .covers = {"ROOT-LIBSEARCH"}) { + if constexpr (!tk::is_linux) GTEST_SKIP() << "logical ELF loader is a Linux capability"; + auto home = tk::Home::isolated("root-compiler"); + const auto scope = home.dir() / "subos/box"; + const auto machine = home.root() / "machine"; + const auto name = xlings::platform::build_arch() == "aarch64" + ? "ld-linux-aarch64.so.1" : "ld-linux-x86-64.so.2"; + const std::string loader = xlings::platform::build_arch() == "aarch64" + ? std::string("/lib/") + name : std::string("/lib64/") + name; + const auto payload = home.root() / "glibc"; + tk::write_file(payload / "lib" / name, "logical loader"); + rf::Inputs inputs; + inputs.payloads = {payload}; + ASSERT_TRUE(rf::commit(scope, rf::plan(inputs), "compiler")); + ASSERT_TRUE(rf::lay_out(machine, rf::usr_of(scope), home.dir())); + ASSERT_TRUE(fs::is_regular_file(machine / fs::path(loader).relative_path())); + xvm::VData driver; + driver.bindingGroup = xvm::BindingGroupRef{.provider = "xim:gcc", .rootTarget = "xim-gnu-gcc"}; + const auto alias = "g++ --sysroot=" + scope.string(); + const auto projected = xvm::root_compiler_alias(alias, driver, scope, machine); + ASSERT_TRUE(projected) << projected.error(); + EXPECT_EQ(*projected, "g++ --sysroot=/ -Wl,--dynamic-linker," + loader); + const auto host = xvm::root_compiler_alias(alias, driver, scope, home.root()); + ASSERT_TRUE(host); + EXPECT_EQ(*host, alias); + driver.bindingGroup->provider = "custom:gcc"; + EXPECT_EQ(*xvm::root_compiler_alias(alias, driver, scope, machine), alias); + driver.bindingGroup->provider = "xim:gcc"; + EXPECT_EQ(*xvm::root_compiler_alias("gcc-ar --sysroot=" + scope.string(), driver, scope, machine), + "gcc-ar --sysroot=" + scope.string()); + EXPECT_EQ(*xvm::root_compiler_alias("g++ --sysroot=" + scope.string() + "-different", driver, scope, machine), + "g++ --sysroot=" + scope.string() + "-different"); + fs::remove(machine / fs::path(loader).relative_path()); + const auto missing = xvm::root_compiler_alias(alias, driver, scope, machine); + ASSERT_FALSE(missing); + EXPECT_NE(missing.error().find("install glibc"), std::string::npos); +} diff --git a/tests/unit/test_root_generation_perf.cpp b/tests/unit/test_root_generation_perf.cpp new file mode 100644 index 000000000..9ab0993f3 --- /dev/null +++ b/tests/unit/test_root_generation_perf.cpp @@ -0,0 +1,165 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" +import std; +import xlings.subos.rootfs; + +#if defined(__SANITIZE_ADDRESS__) +#define XLINGS_GENERATION_INSTRUMENTED 1 +#elif defined(__has_feature) +#if __has_feature(address_sanitizer) +#define XLINGS_GENERATION_INSTRUMENTED 1 +#endif +#endif + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; +namespace rf = xlings::subos::rootfs; + +XTEST(RootGenerationPerf, ThreeHundredPayloadsMeetBuildAndSwitchBudgets, .area = "subos", + .cost = tk::Cost::Medium, .covers = {"PERF-GEN-BUILD", "PERF-GEN-SWITCH", "ROOT-SWITCH-SCALE"}, + .requires_ = {"linux"}) { + auto home = tk::Home::isolated("generation-perf"); + const auto scope = home.dir() / "subos/box"; + rf::Inputs inputs; + for (int i = 0; i != 300; ++i) { + const auto payload = home.dir() / "data/xpkgs" / std::format("fixture-x-tool-{}/1", i); + const auto name = std::format("tool-{}", i); + tk::write_file(payload / "bin" / name, "#!/bin/sh\nexit 0\n"); + fs::permissions(payload / "bin" / name, fs::perms::owner_all); + tk::write_file(payload / "lib" / std::format("libtool-{}.so", i), "fixture library\n"); + inputs.payloads.push_back(payload); + inputs.programs.push_back({name, payload / "bin" / name}); + } + std::vector builds, switches; + std::vector generations; + for (int sample = 0; sample != 3; ++sample) { + inputs.programs.push_back( + {std::format("sample-{}", sample), inputs.programs.front().target}); + const auto started = std::chrono::steady_clock::now(); + const auto plan = rf::plan(inputs); + ASSERT_GE(plan.links.size(), 600u) << "the full payload projection must be measured"; + const auto generation = rf::commit(scope, plan, "performance sample"); + ASSERT_TRUE(generation) << generation.error(); + builds.push_back(std::chrono::duration_cast( + std::chrono::steady_clock::now() - started) + .count()); + generations.push_back(*generation); + EXPECT_EQ(fs::read_symlink(rf::usr_of(scope) / "bin/tool-299"), + inputs.programs[299].target); + EXPECT_EQ(fs::read_symlink(rf::usr_of(scope) / "lib/libtool-299.so"), + inputs.payloads[299] / "lib/libtool-299.so"); + } + std::vector durable; + for (const int generation : generations) { + // The check (inventory, payloads, the rename) apart from the device's + // flush latency, then the same switch durably. + auto started = std::chrono::steady_clock::now(); + auto switched = rf::switch_to(scope, generation, rf::Flush::Deferred); + ASSERT_TRUE(switched) << switched.error(); + switches.push_back(std::chrono::duration_cast( + std::chrono::steady_clock::now() - started) + .count()); + started = std::chrono::steady_clock::now(); + switched = rf::switch_to(scope, generation); + ASSERT_TRUE(switched) << switched.error(); + durable.push_back(std::chrono::duration_cast( + std::chrono::steady_clock::now() - started).count()); + ASSERT_TRUE(rf::current(scope)); + EXPECT_EQ(*rf::current(scope), generation); + } + std::ranges::sort(builds); + std::ranges::sort(switches); + std::ranges::sort(durable); + std::cout << "generation_300_payloads build_median_us=" << builds[1] + << " checked_switch_median_us=" << switches[1] + << " durable_switch_median_us=" << durable[1] << '\n'; +#if defined(XLINGS_GENERATION_INSTRUMENTED) + GTEST_SKIP() << "instrumented workload validated; runtime timing budgets require the mandatory " + "static performance lane"; +#endif + EXPECT_LE(builds[1], 1'000'000); + EXPECT_LE(switches[1], 10'000); +} + +XTEST(RootGenerationPerf, ThreeThousandPayloadsSwitchWithinTheSameBudget, .area = "subos", + .cost = tk::Cost::Slow, .covers = {"ROOT-SWITCH-SCALE", "PERF-GEN-SWITCH"}, + .requires_ = {"linux"}) { + // Ten times the payloads, twice the switch budget: a checked switch + // compares the recorded change stamps of a generation's directories and + // stats its payloads; it does not read every link (design part 3 §7.2). + auto home = tk::Home::isolated("generation-perf-3000"); + const auto scope = home.dir() / "subos/box"; + rf::Plan plan; + for (int i = 0; i != 3000; ++i) { + const auto payload = home.dir() / "data/xpkgs" / std::format("fixture-x-tool-{}/1", i); + tk::write_file(payload / "bin" / std::format("tool-{}", i), "#!/bin/sh\nexit 0\n"); + plan.links.push_back({std::format("usr/bin/tool-{}", i), payload / "bin" / std::format("tool-{}", i), "fixture"}); + plan.links.push_back({std::format("usr/lib/libtool-{}.so", i), payload / "bin" / std::format("tool-{}", i), "fixture"}); + } + std::vector generations; + for (int sample = 0; sample != 3; ++sample) { + auto sampled = plan; + sampled.links.push_back({std::format("usr/share/sample-{}", sample), "/nonexistent", "fixture"}); + const auto generation = rf::commit(scope, sampled, "performance sample"); + ASSERT_TRUE(generation) << generation.error(); + generations.push_back(*generation); + } + std::vector switches; + for (const int generation : generations) { + const auto started = std::chrono::steady_clock::now(); + const auto switched = rf::switch_to(scope, generation, rf::Flush::Deferred); + ASSERT_TRUE(switched) << switched.error(); + switches.push_back(std::chrono::duration_cast( + std::chrono::steady_clock::now() - started).count()); + } + std::ranges::sort(switches); + std::cout << "generation_3000_payloads checked_switch_median_us=" << switches[1] << '\n'; +#if defined(XLINGS_GENERATION_INSTRUMENTED) + GTEST_SKIP() << "instrumented workload validated; runtime timing budgets require the mandatory " + "static performance lane"; +#endif + // A budget is asserted where timings mean something: the static + // performance lane runs this case alone (XLINGS_PERF_BUDGETS=1). A lane + // that runs the suite in parallel measures its own contention -- the same + // binary read 11.9 ms and 25.3 ms on two runs of one runner. + if (const char* on = std::getenv("XLINGS_PERF_BUDGETS"); !on || std::string_view(on) != "1") + GTEST_SKIP() << "workload validated; the timing budget is the static performance lane's"; + // Ten times the payloads within twice the 300-payload budget: the check + // stats every payload root once (a rollback must not land on a deleted + // one), so it grows with the payloads, not with their links. Measured: + // 1.9 ms optimized, 3.0 ms unoptimized locally, 11.9 ms on a loaded + // root-lane runner. + EXPECT_LE(switches[1], 20'000); +} + +XTEST(RootGenerationPerf, OptimizedInventoryStillRefusesUnknownAndReplacedEntries, .area = "subos", + .covers = {"ROOT-GEN-ATOMIC", "ROOT-ROLLBACK"}, .requires_ = {"linux"}) { + auto home = tk::Home::isolated("generation-inventory"); + const auto scope = home.dir() / "subos/box"; + const auto source = home.dir() / "data/xpkgs/fixture-x-tool/1/bin/tool"; + tk::write_file(source, "fixture executable"); + const rf::Plan plan{{{"usr/bin/tool", source, "fixture"}}, {}}; + const auto first = rf::commit(scope, plan, "first"); + ASSERT_TRUE(first) << first.error(); + auto changed = plan; + changed.links.push_back({"usr/bin/other", source, "fixture"}); + const auto second = rf::commit(scope, changed, "second"); + ASSERT_TRUE(second) << second.error(); + const auto firstDir = scope / "root.gen" / std::to_string(*first); + const auto unexpected = firstDir / "usr/user-directory/personal.txt"; + tk::write_file(unexpected, "user data"); + EXPECT_FALSE(rf::switch_to(scope, *first)); + EXPECT_FALSE(rf::validate_generation(scope, *first)); + EXPECT_EQ(rf::current(scope), std::optional(*second)); + EXPECT_EQ(tk::read_file(unexpected), "user data"); + fs::remove(unexpected); + fs::remove(unexpected.parent_path()); + const auto recorded = firstDir / "usr/bin/tool"; + fs::remove(recorded); + tk::write_file(recorded, "user replacement"); + EXPECT_FALSE(rf::switch_to(scope, *first)); + EXPECT_FALSE(rf::validate_generation(scope, *first)); + EXPECT_EQ(rf::current(scope), std::optional(*second)); + EXPECT_EQ(tk::read_file(recorded), "user replacement"); +} diff --git a/tests/unit/test_root_library_cache.cpp b/tests/unit/test_root_library_cache.cpp new file mode 100644 index 000000000..44b00afe2 --- /dev/null +++ b/tests/unit/test_root_library_cache.cpp @@ -0,0 +1,162 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.subos.library_cache; +import xlings.subos.caps; +import xlings.platform.root_mount; +import xlings.core.elfread; + +namespace tk = xlings::testkit; +namespace lc = xlings::subos::library_cache; +namespace fs = std::filesystem; + +XTEST(RootLibraryCache, GeneratorSeesTheRootReadOnlyAndWritesOnlyOwnedStaging, .area = "subos", + .covers = {"ROOT-LDCACHE"}) { + const std::vector bindings{ + {"/private/skeleton", "/owned/home"}, + {"/owned/home/data/xpkgs/xim-x-glibc/2.42", "/owned/home/data/xpkgs/xim-x-glibc/2.42"}}; + const auto argv = + lc::command("/owned/root", {"/owned/home"}, "/owned/staging", "/measured/bwrap", bindings); + const auto has = [&](std::initializer_list words) { + return std::ranges::search(argv, words).begin() != argv.end(); + }; + EXPECT_TRUE(has({"--ro-bind", "/owned/root", "/"})); + EXPECT_TRUE(has({"--ro-bind", "/private/skeleton", "/owned/home"})); + EXPECT_FALSE(has({"--ro-bind", "/owned/home", "/owned/home"})); + EXPECT_TRUE(has({"--ro-bind", bindings[1].source.string(), bindings[1].destination.string()})); + EXPECT_TRUE(has({"--bind", "/owned/staging", "/run/xlings-ldcache"})); + EXPECT_FALSE(has({"--bind", "/owned/root", "/"})); + EXPECT_TRUE( + has({"--", "/usr/bin/ldconfig", "-X", "-i", "-C", "/run/xlings-ldcache/ld.so.cache"})); + EXPECT_TRUE(has({"-f", "/etc/ld.so.conf", "/usr/lib", "/usr/lib64"})); + EXPECT_TRUE(has({"--unshare-all"})); + const auto position = [&](std::initializer_list words) { + return std::ranges::search(argv, words).begin(); + }; + ASSERT_TRUE(has({"--tmpfs", "/run"})); + ASSERT_TRUE(has({"--remount-ro", "/run"})); + EXPECT_LT(position({"--ro-bind", "/owned/root", "/"}), position({"--tmpfs", "/run"})); + EXPECT_LT(position({"--tmpfs", "/run"}), position({"--ro-bind", "/private/skeleton", "/owned/home"})); + EXPECT_LT(position({"--ro-bind", bindings[1].source.string(), bindings[1].destination.string()}), + position({"--bind", "/owned/staging", "/run/xlings-ldcache"})); + EXPECT_LT(position({"--bind", "/owned/staging", "/run/xlings-ldcache"}), + position({"--remount-ro", "/run"})); +} + +XTEST(RootLibraryCache, RunningRootKeepsItsDirectGeneratorCommand, .area = "subos", + .covers = {"ROOT-LDCACHE"}) { + const std::vector expected{"/usr/bin/ldconfig", "-X", "-i", "-C", + (fs::path("/owned/staging") / "ld.so.cache").string(), "-f", "/etc/ld.so.conf", + "/usr/lib", "/usr/lib64"}; + EXPECT_EQ(lc::command("/", {"/owned/home"}, "/owned/staging", "/unused/bwrap"), expected); +} + +XTEST(RootLibraryCache, ActualGeneratorUsesPrivateRunAndPreservesMachineState, + .area = "subos", .covers = {"ROOT-LDCACHE"}, .requires_ = {"linux", "bwrap"}, + .resources = {"sandbox"}, .proves = "isolation") { + if constexpr (!tk::is_linux) GTEST_SKIP() << "Linux mount namespaces"; + if (const auto why = tk::probe("bwrap")) GTEST_SKIP() << *why; + fs::path generator; + for (const auto* path : {"/usr/sbin/ldconfig.real", "/sbin/ldconfig.real", + "/usr/sbin/ldconfig", "/sbin/ldconfig"}) { + const auto elf = xlings::elfread::read(path); + if (elf && elf->interpreter.empty()) { generator = fs::canonical(path); break; } + } + ASSERT_FALSE(generator.empty()) << "this Linux namespace lane needs its static glibc ldconfig"; + auto home = tk::Home::isolated("library-cache-namespace"); + const auto root = home.root() / "machine"; + const auto authority = home.root() / "runtime-authority"; + const auto listing = tk::run({.argv = {generator.string(), "-p"}, .env = home.env()}); + ASSERT_EQ(listing.exit_code, 0) << listing.transcript(); + fs::path libc; + std::istringstream lines(listing.out); + std::string line; + while (std::getline(lines, line)) { + const auto name = line.find_first_not_of(" \t"); + const auto separator = line.find(" => "); + if (name != std::string::npos && line.substr(name).starts_with("libc.so.6 ") && + separator != std::string::npos) { + libc = fs::canonical(line.substr(separator + 4)); + break; + } + } + ASSERT_FALSE(libc.empty()) << listing.transcript(); + fs::create_directories(root / "usr/bin"); + fs::create_directories(root / "usr/lib"); + fs::create_directories(root / "usr/lib64"); + fs::create_directories(root / "proc"); + fs::create_directories(root / "dev"); + fs::create_directory(authority); + fs::copy_file(generator, root / "usr/bin/ldconfig"); + fs::copy_file(libc, authority / "libc.so.6"); + tk::write_file(root / "etc/ld.so.conf", "/run/xlings-cache-authority\n"); + tk::write_file(root / "run/user-owned", "retain machine runtime data"); + const auto sourceBefore = tk::read_file(authority / "libc.so.6"); + const std::vector bindings{ + {authority, "/run/xlings-cache-authority"}}; + const auto generated = lc::refresh(root, {home.dir()}, "probe", bindings); + ASSERT_TRUE(generated) << generated.error(); + ASSERT_TRUE(*generated); + const auto cache = tk::read_file(root / "etc/ld.so.cache"); + EXPECT_NE(cache.find("/run/xlings-cache-authority/libc.so.6"), std::string::npos); + EXPECT_EQ(tk::read_file(root / "run/user-owned"), "retain machine runtime data"); + EXPECT_FALSE(fs::exists(root / "run/xlings-ldcache")); + EXPECT_FALSE(fs::exists(root / "run/xlings-cache-authority")); + EXPECT_EQ(tk::read_file(authority / "libc.so.6"), sourceBefore); + const auto backend = xlings::subos::caps::probe({home.dir()}, {}); + ASSERT_TRUE(backend.bwrap && backend.bwrap->usable); + const auto scratch = home.root() / "forbidden-output-staging"; + ASSERT_TRUE(fs::create_directory(scratch)); + auto forbidden = lc::command(root, {home.dir()}, scratch, backend.bwrap->bin, bindings); + const auto cacheFlag = std::ranges::find(forbidden, "-C"); + ASSERT_NE(cacheFlag, forbidden.end()); + *(cacheFlag + 1) = "/run/unowned-cache"; + const auto denied = tk::run({.argv = std::move(forbidden), .env = home.env()}); + EXPECT_NE(denied.exit_code, 0) << denied.transcript(); + EXPECT_FALSE(denied.timed_out) << denied.transcript(); + EXPECT_EQ(denied.signal, 0) << denied.transcript(); + EXPECT_FALSE(fs::exists(root / "run/unowned-cache")); + EXPECT_FALSE(fs::exists(scratch / "ld.so.cache")); + tk::write_file(root / "etc/ld.so.cache", "user replacement"); + const auto changed = lc::refresh(root, {home.dir()}, "probe", bindings); + ASSERT_FALSE(changed); + EXPECT_EQ(tk::read_file(root / "etc/ld.so.cache"), "user replacement"); +} + +XTEST(RootLibraryCache, MissingGeneratorAndUnknownExistingCacheNeverOverwriteUserFiles, + .area = "subos", .covers = {"ROOT-LDCACHE"}) { + auto home = tk::Home::isolated("library-cache"); + const auto root = home.root() / "root"; + tk::write_file(root / "etc/ld.so.cache", "user-owned bytes"); + auto missing = lc::refresh(root, {home.root()}, "probe"); + ASSERT_TRUE(missing.has_value()) << missing.error(); + EXPECT_FALSE(*missing); + tk::write_file(root / "usr/bin/ldconfig", "generator placeholder"); + auto malformed = lc::refresh(root, {home.root()}, "probe"); + ASSERT_FALSE(malformed.has_value()); + EXPECT_NE(malformed.error().find("unknown format"), std::string::npos); + EXPECT_EQ(tk::read_file(root / "etc/ld.so.cache"), "user-owned bytes"); + EXPECT_FALSE(std::filesystem::exists(home.root() / "run/subos/probe")); +} + +XTEST(RootLibraryCache, ARecognisableCacheStillNeedsProofThatXlingsOwnsIt, .area = "subos", + .covers = {"ROOT-LDCACHE"}) { + auto home = tk::Home::isolated("unowned-library-cache"); + const auto root = home.root() / "root"; + const auto bytes = std::string("glibc-ld.so.cache1.1") + std::string(64, '\0'); + tk::write_file(root / "etc/ld.so.cache", bytes); + tk::write_file(root / "usr/bin/ldconfig", "generator placeholder"); + const auto result = lc::refresh(root, {home.root()}, "probe"); + ASSERT_FALSE(result.has_value()); + EXPECT_NE(result.error().find("ownership record"), std::string::npos); + EXPECT_EQ(tk::read_file(root / "etc/ld.so.cache"), bytes); + EXPECT_FALSE(std::filesystem::exists(root / "etc/xlings/ldcache.json")); +} + +XTEST(RootLibraryCache, NonRootGeneratorRefusesAnUnprovedHomeBinding, .area = "subos", + .covers = {"ROOT-LDCACHE"}) { + EXPECT_THROW(lc::command("/owned/root", {"/owned/home"}, "/owned/staging", "/measured/bwrap"), + std::runtime_error); +} diff --git a/tests/unit/test_root_mount.cpp b/tests/unit/test_root_mount.cpp new file mode 100644 index 000000000..d3025e8d1 --- /dev/null +++ b/tests/unit/test_root_mount.cpp @@ -0,0 +1,33 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.platform; +import xlings.platform.root_mount; + +namespace tk = xlings::testkit; +namespace rm = xlings::platform::root_mount; + +XTEST(RootMount, InvalidDescriptorsCannotSelectAHostNamespace, .area = "subos", + .covers = {"ROOT-NO-HOST"}) { + const std::array invalid{-1, -1, -1}; + auto untrusted = invalid; + EXPECT_FALSE(rm::normalize(untrusted)); + EXPECT_EQ(untrusted, invalid); + const auto result = rm::update(invalid, {}, {}); + ASSERT_FALSE(result); + EXPECT_FALSE(result.error().empty()); + auto captured = rm::capture(); + if constexpr (!tk::is_linux) { + EXPECT_FALSE(captured); + } else { + ASSERT_TRUE(captured) << captured.error(); + ASSERT_TRUE(rm::normalize(*captured)); + const std::vector escape{{.source = "/", .destination = "../outside"}}; + const auto rejected = rm::update(*captured, escape, {}); + EXPECT_FALSE(rejected); + for (const int fd : *captured) + xlings::platform::close_fd(fd); + } +} diff --git a/tests/unit/test_root_store_closure.cpp b/tests/unit/test_root_store_closure.cpp new file mode 100644 index 000000000..5cb04d036 --- /dev/null +++ b/tests/unit/test_root_store_closure.cpp @@ -0,0 +1,175 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.libs.json; +import xlings.core.home.evidence; +import xlings.core.subos.store_closure; +import xlings.core.subos.root_view; +import xlings.subos.rootfs; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; +namespace closure = xlings::subos_root::store_closure; +namespace view = xlings::subos_root::root_view; +namespace evidence = xlings::home::evidence; +namespace rf = xlings::subos::rootfs; +using Json = nlohmann::json; + +XTEST(RootStoreClosure, PrivateSkeletonExposesOnlyOptedInPayloadsAndMetadata, .area = "subos", + .covers = {"ROOT-STORE-CLOSURE"}) { + if constexpr (!tk::is_posix) + GTEST_SKIP() << "root projection uses directory symlinks"; + auto home = tk::Home::isolated("root-store-closure"); + const auto visible = fs::canonical(home.dir()) / "data/xpkgs/fixture-x-visible/1.0.0"; + const auto hidden = fs::canonical(home.dir()) / "data/xpkgs/fixture-x-hidden/2.0.0"; + const auto scope = fs::canonical(home.dir()) / "subos/box"; + tk::write_file(visible / "bin/visible", "visible program"); + tk::write_file(hidden / "private.txt", "another scope's payload"); + tk::write_file(visible / ".xlings-resolution.json", + R"({"package":"fixture:visible@1.0.0","deps":[]})"); + Json versions = { + {"visible", + {{"type", "program"}, + {"versions", {{"fixture:1.0.0", {{"path", visible.string() + "/bin"}}}}}}}, + {"hidden", + {{"type", "program"}, {"versions", {{"fixture:2.0.0", {{"path", hidden.string()}}}}}}}}; + tk::write_file(fs::canonical(home.dir()) / ".xlings.json", Json{{"versions", versions}}.dump()); + tk::write_file( + scope / ".xlings.json", + Json{{"workspace", + {{"visible", {{"active", "fixture:1.0.0"}, {"installed", {"fixture:1.0.0"}}}}}}} + .dump()); + rf::Inputs generation; + generation.payloads = {visible}; + generation.programs = {{"visible", visible / "bin/visible"}}; + ASSERT_TRUE(rf::commit(scope, rf::plan(generation), "fixture")); + auto inputs = closure::read_scope(fs::canonical(home.dir()), "box", scope / "rootfs"); + ASSERT_TRUE(inputs) << inputs.error(); + auto prepared = view::prepare(*inputs); + ASSERT_TRUE(prepared) << prepared.error(); + const auto& bindings = (*prepared)->bindings(); + EXPECT_TRUE(std::ranges::any_of(bindings, [&](const auto& binding) { + return binding.source == visible && binding.destination == visible; + })); + EXPECT_FALSE(std::ranges::any_of(bindings, [&](const auto& binding) { + return binding.source == hidden || binding.source == fs::canonical(home.dir()) || + binding.source == fs::canonical(home.dir()) / "data/xpkgs"; + })); + EXPECT_FALSE(fs::exists((*prepared)->private_instance().parent_path().parent_path() / + "data/xpkgs/fixture-x-hidden/2.0.0")); + const auto primary = std::ranges::find_if(bindings, [&](const auto& binding) { + return binding.destination == fs::canonical(home.dir()) / ".xlings.json"; + }); + ASSERT_NE(primary, bindings.end()); + const auto filtered = Json::parse(tk::read_file(primary->source)); + EXPECT_TRUE(filtered["versions"].contains("visible")); + EXPECT_FALSE(filtered["versions"].contains("hidden")); + (*prepared)->close(); + fs::remove(visible / ".xlings-resolution.json"); + EXPECT_FALSE(view::prepare(*inputs)); + EXPECT_EQ(tk::read_file(hidden / "private.txt"), "another scope's payload"); +} + +XTEST(RootStoreClosure, CheckedEvidenceTranslatesOnlyTheProvedRecordedHome, .area = "subos", + .covers = {"ROOT-STORE-CLOSURE"}) { + auto home = tk::Home::isolated("root-recorded-home"); + const auto recordedHome = fs::canonical(home.root()) / "recorded"; + const auto recordedSecond = recordedHome / "data/xpkgs/fixture-x-second/2.0.0"; + const auto first = fs::canonical(home.dir()) / "data/xpkgs/fixture-x-first/1.0.0"; + const auto second = fs::canonical(home.dir()) / "data/xpkgs/fixture-x-second/2.0.0"; + tk::write_file(second / "lib/libsecond.a", "library"); + tk::write_file( + first / ".xlings-resolution.json", + Json{{"package", "fixture:first@1.0.0"}, + {"deps", + Json::array({{{"spec", "fixture:second@2.0.0"}, + {"name", "fixture:second"}, + {"version", "2.0.0"}, + {"source", "fixture"}, + {"install_dir", recordedSecond.string()}, + {"libdirs", {(recordedSecond / "lib").string()}}}})}} + .dump()); + auto owner = evidence::physical_store_root(fs::canonical(home.dir()), first); + auto dependency = evidence::physical_store_root(fs::canonical(home.dir()), second); + ASSERT_TRUE(owner); + ASSERT_TRUE(dependency); + owner->recordedHome = recordedHome; + dependency->recordedHome = recordedHome; + const auto resolve = + [&](const fs::path& recorded) -> std::expected { + if (recorded != recordedSecond) + return std::unexpected("path has no proved mapping"); + return *dependency; + }; + auto result = evidence::read_checked_resolution(*owner, resolve); + ASSERT_TRUE(result) << result.error(); + ASSERT_EQ(result->dependencies.size(), 1U); + EXPECT_EQ(result->dependencies[0].installDir, fs::canonical(second)); + EXPECT_EQ(result->dependencies[0].libdirs[0], fs::canonical(second / "lib")); + dependency->recordedHome.clear(); + EXPECT_FALSE(evidence::read_checked_resolution(*owner, resolve)); +} + +XTEST(RootStoreClosure, BorrowedHomeMetadataIsLimitedToTheCheckedScope, .area = "subos", + .covers = {"ROOT-STORE-CLOSURE", "HOME-LAYER-RESOLVE"}) { + if constexpr (!tk::is_posix) + GTEST_SKIP() << "root projection uses directory symlinks"; + auto home = tk::Home::isolated("root-borrowed-closure"); + const auto system = fs::canonical(home.root()) / "system"; + const auto payload = system / "data/xpkgs/fixture-x-visible/1.0.0"; + const auto hidden = system / "data/xpkgs/fixture-x-hidden/2.0.0"; + const auto scope = fs::canonical(home.dir()) / "subos/box"; + tk::write_file(payload / "bin/visible", "borrowed tool"); + tk::write_file(hidden / "private.txt", "other source scope"); + tk::write_file(payload / ".xlings-resolution.json", + R"({"package":"fixture:visible@1.0.0","deps":[]})"); + tk::write_file(system / ".xlings-home", R"({"mode":"root","layout":"multi"})"); + Json versions = { + {"visible", + {{"type", "program"}, + {"versions", {{"fixture:1.0.0", {{"path", (payload / "bin").string()}}}}}}}, + {"hidden", + {{"type", "program"}, {"versions", {{"fixture:2.0.0", {{"path", hidden.string()}}}}}}}}; + tk::write_file(system / ".xlings.json", Json{{"versions", versions}}.dump()); + tk::write_file( + system / "subos/default/.xlings.json", + Json{{"workspace", + {{"visible", {{"active", "fixture:1.0.0"}, {"installed", {"fixture:1.0.0"}}}}}}} + .dump()); + versions.erase("hidden"); + versions["visible"]["versions"]["fixture:1.0.0"]["layer"] = {{"home", system.string()}, + {"scope", "default"}}; + tk::write_file(fs::canonical(home.dir()) / ".xlings.json", Json{{"versions", versions}}.dump()); + tk::write_file( + scope / ".xlings.json", + Json{{"workspace", + {{"visible", {{"active", "fixture:1.0.0"}, {"installed", {"fixture:1.0.0"}}}}}}} + .dump()); + rf::Inputs generation; + generation.payloads = {payload}; + generation.programs = {{"visible", payload / "bin/visible"}}; + ASSERT_TRUE(rf::commit(scope, rf::plan(generation), "borrowed fixture")); + auto inputs = closure::read_scope(fs::canonical(home.dir()), "box", scope / "rootfs"); + ASSERT_TRUE(inputs) << inputs.error(); + auto prepared = view::prepare(*inputs); + ASSERT_TRUE(prepared) << prepared.error(); + const auto& bindings = (*prepared)->bindings(); + EXPECT_TRUE(std::ranges::any_of(bindings, [&](const auto& binding) { + return binding.source == payload && binding.destination == payload; + })); + EXPECT_FALSE(std::ranges::any_of(bindings, [&](const auto& binding) { + return binding.source == system || binding.source == system / "data/xpkgs" || + binding.source == hidden; + })); + const auto primary = std::ranges::find_if(bindings, [&](const auto& binding) { + return binding.destination == system / ".xlings.json"; + }); + ASSERT_NE(primary, bindings.end()); + const auto filtered = Json::parse(tk::read_file(primary->source)); + EXPECT_TRUE(filtered["versions"].contains("visible")); + EXPECT_FALSE(filtered["versions"].contains("hidden")); + (*prepared)->close(); + EXPECT_EQ(tk::read_file(hidden / "private.txt"), "other source scope"); +} diff --git a/tests/unit/test_runtime.cpp b/tests/unit/test_runtime.cpp index 1d8d4191b..f083ece09 100644 --- a/tests/unit/test_runtime.cpp +++ b/tests/unit/test_runtime.cpp @@ -45,7 +45,7 @@ import xlings.platform; import xlings.libs.json; import xlings.core.xself; import xlings.core.profile; -import xlings.core.subos.gpu; +import xlings.subos.gpu; import xlings.core.xim.downloader; import xlings.runtime; import xlings.capabilities; diff --git a/tests/unit/test_session_wakeup.cpp b/tests/unit/test_session_wakeup.cpp new file mode 100644 index 000000000..6adb9f45e --- /dev/null +++ b/tests/unit/test_session_wakeup.cpp @@ -0,0 +1,161 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.platform; +import xlings.subos.session; +import xlings.libs.json; + +namespace tk = xlings::testkit; +namespace platform = xlings::platform; +namespace session = xlings::subos::session; +using Json = nlohmann::json; + +namespace { + +struct Init { + int fd { -1 }; + int pid { -1 }; + + explicit Init(std::vector args = {}) { + const auto pair = platform::unix_pair(); + if (!pair) throw std::runtime_error("cannot create the session control socket"); + pid = platform::fork_process(); + if (pid == 0) { + platform::close_fd((*pair)[0]); + platform::reset_signals(); + for (const auto* name : {"XLINGS_SESSION_ROOT_VIEW", "XLINGS_SESSION_LANDLOCK_RW", + "XLINGS_SESSION_NET_TRACE", "XLINGS_SESSION_TRACE"}) + platform::unset_env_variable(name); + platform::set_env_variable(std::string(session::kControlFdEnv), std::to_string((*pair)[1])); + platform::set_env_variable(std::string(session::kTtlEnv), "0"); + platform::exit_now(session::session_init(args)); + } + platform::close_fd((*pair)[1]); + if (pid < 0) { + platform::close_fd((*pair)[0]); + throw std::runtime_error("cannot start session-init"); + } + fd = (*pair)[0]; + } + + ~Init() { + platform::close_fd(fd); + if (pid > 0) { + platform::send_signal(pid, platform::sig::kill); + (void)platform::wait_process(pid, true); + } + } + + std::optional reply(int timeoutMs) const { + platform::PollFd watched{fd}; + if (platform::poll_fds(std::span(&watched, 1), timeoutMs) <= 0) return std::nullopt; + auto message = platform::receive_message(fd); + if (!message) return std::nullopt; + platform::close_fds(message->fds); + return Json::parse(message->data); + } + + bool exec(int id, std::vector argv) const { + const int null = platform::open_null(); + const int stdio[]{null, null, null}; + const bool sent = platform::send_message(fd, + Json{{"op", "exec"}, {"id", id}, {"argv", argv}, {"cwd", "/"}}.dump(), stdio); + platform::close_fd(null); + return sent; + } +}; + +} // namespace + +XTEST(SessionMessages, PacketSizingPreservesBoundariesAndDescriptorsAndRejectsTruncation, + .area = "subos", .covers = {"SES-JOIN"}, .requires_ = {"linux"}) { + if constexpr (!tk::is_linux) GTEST_SKIP() << "Linux sequenced packet sockets"; + const auto sockets = platform::unix_pair(); + const auto pipe = platform::make_pipe(); + ASSERT_TRUE(sockets); + ASSERT_TRUE(pipe); + struct Close { + std::vector fds; + ~Close() { platform::close_fds(fds); } + } channels{{(*sockets)[0], (*sockets)[1], (*pipe)[0], (*pipe)[1]}}; + ASSERT_TRUE(platform::write_fd((*pipe)[1], "marker")); + const int descriptor[]{(*pipe)[0]}; + ASSERT_TRUE(platform::send_message((*sockets)[0], "small", descriptor)); + const std::string large(64 * 1024, 'x'); + ASSERT_TRUE(platform::send_message((*sockets)[0], large)); + auto first = platform::receive_message((*sockets)[1]); + ASSERT_TRUE(first); + EXPECT_EQ(first->data, "small"); + ASSERT_EQ(first->fds.size(), 1); + Close received{first->fds}; + char marker[6]; + ASSERT_TRUE(platform::read_exact(first->fds[0], marker, sizeof(marker))); + EXPECT_EQ(std::string(marker, sizeof(marker)), "marker"); + const auto second = platform::receive_message((*sockets)[1]); + ASSERT_TRUE(second); + EXPECT_EQ(second->data, large); + const auto fdCount = [] { + std::size_t count{}; + for (const auto& entry : std::filesystem::directory_iterator("/proc/self/fd")) ++count; + return count; + }; + const auto before = fdCount(); + for (int i = 0; i != 16; ++i) { + ASSERT_TRUE(platform::send_message((*sockets)[0], "too large", descriptor)); + EXPECT_FALSE(platform::receive_message((*sockets)[1], 4)); + } + const std::vector tooMany(9, (*pipe)[0]); + ASSERT_TRUE(platform::send_message((*sockets)[0], "fds", tooMany)); + EXPECT_FALSE(platform::receive_message((*sockets)[1])); + EXPECT_EQ(fdCount(), before); + ASSERT_TRUE(platform::send_message((*sockets)[0], "last")); + const auto last = platform::receive_message((*sockets)[1]); + ASSERT_TRUE(last); + EXPECT_EQ(last->data, "last"); +} + +XTEST(SessionWakeup, AColdChildExitsWithoutWaitingForTheControlPollTick, + .area = "subos", .covers = {"SES-SUPERVISOR"}, .requires_ = {"linux"}) { + if constexpr (!tk::is_linux) GTEST_SKIP() << "session-init is implemented on Linux"; + Init init{{"--", "/bin/sh", "-c", "/bin/sleep 0.025; exit 7"}}; + platform::PollFd watched{init.fd}; + // Keep the control peer open without sending traffic. The child exits + // after the first reap and must wake init instead of waiting its 200 ms tick. + ASSERT_GT(platform::poll_fds(std::span(&watched, 1), 150), 0); + EXPECT_TRUE(watched.closed); + const auto status = platform::wait_process(init.pid, true); + init.pid = -1; + ASSERT_TRUE(status); + EXPECT_EQ(platform::exit_code(*status), 7); +} + +XTEST(SessionWakeup, JoinedChildrenReportCompletionSignalsAndExecFailureImmediately, + .area = "subos", .covers = {"SES-JOIN"}, .requires_ = {"linux"}) { + if constexpr (!tk::is_linux) GTEST_SKIP() << "session-init is implemented on Linux"; + Init init; + ASSERT_TRUE(init.exec(1, {"/bin/sh", "-c", "/bin/sleep 0.025; exit 9"})); + const auto started = init.reply(2000); + ASSERT_TRUE(started); + EXPECT_EQ((*started)["id"], 1); + EXPECT_EQ((*started)["started"], true); + const auto done = init.reply(150); + ASSERT_TRUE(done) << "the child finished without control traffic; SIGCHLD must wake init"; + EXPECT_EQ((*done)["id"], 1); + EXPECT_EQ((*done)["exit"], 9); + + ASSERT_TRUE(init.exec(2, {"/bin/sh", "-c", "kill -TERM $$"})); + ASSERT_TRUE(init.reply(2000)); + const auto signaled = init.reply(150); + ASSERT_TRUE(signaled); + EXPECT_EQ((*signaled)["id"], 2); + EXPECT_EQ((*signaled)["signal"], platform::sig::terminate); + + ASSERT_TRUE(init.exec(3, {"/xlings-wakeup-test/no-such-program"})); + const auto failure = init.reply(2000); + ASSERT_TRUE(failure); + EXPECT_EQ((*failure)["id"], 3); + EXPECT_TRUE(failure->contains("error")); + EXPECT_FALSE(failure->contains("started")); +} diff --git a/tests/unit/test_shim_anchor.cpp b/tests/unit/test_shim_anchor.cpp index 8f7f9984f..dc2fb7ed7 100644 --- a/tests/unit/test_shim_anchor.cpp +++ b/tests/unit/test_shim_anchor.cpp @@ -336,3 +336,30 @@ TEST(ShimAnchorOwner, NestedHomePathsAreNotReRootedByTheOuterSubos) { EXPECT_EQ(xlings::xvm::normalize_subos_paths(baked, inner.string(), innerActive), "--sysroot=" + innerActive); } + +// Deployment S (design part 2 §2.1): the home's entry is a link to a system +// package's binary outside every home. A shim's owner is where the SHIM is, +// not where its link chain ends -- resolving the chain anchored every shim of +// such a home to nothing. +TEST(ShimAnchorOwner, AShimOfAHomeWhoseEntryIsASystemBinaryAnchorsToThatHome) { + if constexpr (xlings::platform::is_windows) GTEST_SKIP() << "symlinked entries are POSIX"; + TempDir tmp; + auto home = tmp.path / "home"; + auto bin = make_home(home); + auto system = tmp.path / "usr" / "bin" / kXlingsBin; + touch(system); + fs::remove(home / "bin" / kXlingsBin); + fs::create_symlink(system, home / "bin" / kXlingsBin); + fs::create_symlink(fs::path("..") / ".." / ".." / "bin" / kXlingsBin, bin / "gcc"); + auto owner = xlings::xvm::resolve_owner_home(bin / "gcc"); + ASSERT_TRUE(owner.has_value()); + EXPECT_EQ(fs::weakly_canonical(*owner), fs::weakly_canonical(home)); + + // A link from outside every home to that shim still reaches the home. + auto outside = tmp.path / "elsewhere" / "gcc"; + fs::create_directories(outside.parent_path()); + fs::create_symlink(bin / "gcc", outside); + owner = xlings::xvm::resolve_owner_home(outside); + ASSERT_TRUE(owner.has_value()); + EXPECT_EQ(fs::weakly_canonical(*owner), fs::weakly_canonical(home)); +} diff --git a/tests/unit/test_store.cpp b/tests/unit/test_store.cpp new file mode 100644 index 000000000..e4c43fa70 --- /dev/null +++ b/tests/unit/test_store.cpp @@ -0,0 +1,67 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.store; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; +namespace st = xlings::store; + +XTEST(Store, APayloadRootIsTheVersionDirectoryUnderXpkgs, + .area = "store", .covers = {"ROOT-GC-ROOT"}) { + EXPECT_EQ(st::payload_root("/h/data/xpkgs/xim-x-gcc/16.1.0/bin/gcc"), + fs::path("/h/data/xpkgs/xim-x-gcc/16.1.0")); + EXPECT_EQ(st::payload_root("/h/data/xpkgs/xim-x-gcc/16.1.0"), fs::path("/h/data/xpkgs/xim-x-gcc/16.1.0")); + EXPECT_FALSE(st::payload_root("/h/data/xpkgs/xim-x-gcc")); + EXPECT_FALSE(st::payload_root("/usr/bin/sh")); +} + +XTEST(Store, PinsNameEveryGenerationThatLinksIntoThePayloadAndEveryUnreadableOne, + .area = "store", .covers = {"ROOT-GC-ROOT"}) { + const fs::path payload = "/h/data/xpkgs/xim-x-tool/1"; + const std::vector roots{ + {"dev", 3, {"/h/data/xpkgs/xim-x-tool/1/bin/tool", "/h/data/xpkgs/xim-x-other/2/bin/o"}}, + {"dev", 4, {"/h/data/xpkgs/xim-x-other/2/bin/o"}}, + // A sibling version whose name starts with the same text is not inside. + {"ci", 1, {"/h/data/xpkgs/xim-x-tool/10/bin/tool"}}, + }; + auto none = st::pins(payload, roots, {}); + ASSERT_EQ(none.holders.size(), 1u); + EXPECT_EQ(none.holders.front(), (st::Holder{"dev", 3})); + EXPECT_TRUE(none.held()); + + const std::vector unreadable{"generation 7 of 'ci'"}; + auto unknown = st::pins("/h/data/xpkgs/xim-x-absent/1", roots, unreadable); + EXPECT_TRUE(unknown.holders.empty()); + EXPECT_TRUE(unknown.held()) << "an unreadable generation might link into it"; + EXPECT_FALSE(st::pins("/h/data/xpkgs/xim-x-absent/1", roots, {}).held()); +} + +XTEST(Store, TheLedgerIsAbsentEmptyAndAnUnparseableOneReleasesNothing, + .area = "store", .covers = {"ROOT-GC-ROOT"}) { + auto home = tk::Home::isolated("store-ledger"); + const auto ledger = st::ledger_path(home.dir() / "data"); + auto empty = st::read_ledger(ledger); + ASSERT_TRUE(empty) << empty.error(); + EXPECT_TRUE(empty->empty()); + + ASSERT_TRUE(st::retain(ledger, {"/p/a", "a", "1", {}})); + ASSERT_TRUE(st::retain(ledger, {"/p/b", "b", "2", {}})); + ASSERT_TRUE(st::retain(ledger, {"/p/a", "a", "1", "2026-10-09T00:00:00Z"})); // replaces + auto two = st::read_ledger(ledger); + ASSERT_TRUE(two) << two.error(); + ASSERT_EQ(two->size(), 2u); + ASSERT_TRUE(st::forget(ledger, "/p/a")); + ASSERT_TRUE(st::forget(ledger, "/p/never")); + auto one = st::read_ledger(ledger); + ASSERT_TRUE(one) << one.error(); + ASSERT_EQ(one->size(), 1u); + EXPECT_EQ(one->front().target, "b"); + EXPECT_FALSE(one->front().since.empty()); + + tk::write_file(ledger, "{ not json"); + EXPECT_FALSE(st::read_ledger(ledger)); + EXPECT_FALSE(st::retain(ledger, {"/p/c", "c", "3", {}})) << "a ledger that cannot be read is not rewritten"; +} diff --git a/tests/unit/test_subos_broker.cpp b/tests/unit/test_subos_broker.cpp new file mode 100644 index 000000000..7c2445725 --- /dev/null +++ b/tests/unit/test_subos_broker.cpp @@ -0,0 +1,56 @@ +// The broker's classification (C15): what the xlings inside may do itself, +// what it sends outside, and what only the owner does. +#include + +import std; +import xlings.subos.broker; +import xlings.subos.policy; + +namespace b = xlings::subos::broker; +namespace pol = xlings::subos::policy; +using V = std::vector; + +TEST(SubosBroker, ReadsStayLocal) { + for (V argv : {V{"list"}, V{"info", "gcc"}, V{"search", "x"}, V{"use", "gcc"}, + V{"subos", "list"}, V{"subos", "status", "box"}, V{"self", "doctor"}, + V{"--version"}}) + EXPECT_EQ(b::classify(argv, "box").route, b::Route::Local) << argv[0]; +} + +TEST(SubosBroker, ChangesToTheHomeGoToTheBroker) { + auto install = b::classify(V{"install", "gcc", "-y", "cmake"}, "box"); + EXPECT_EQ(install.route, b::Route::Broker); + ASSERT_EQ(install.ops.size(), 2u); + EXPECT_EQ(install.ops[0].target, "gcc"); + EXPECT_TRUE(install.ops[0].from_inside); + EXPECT_EQ(b::classify(V{"update"}, "box").ops.front().kind, "index_update"); + EXPECT_EQ(b::classify(V{"remove", "gcc"}, "box").route, b::Route::Broker); + EXPECT_EQ(b::classify(V{"use", "gcc", "15.1.0"}, "box").route, b::Route::Broker); +} + +TEST(SubosBroker, TheOwnersThingsAreRefusedInside) { + for (V argv : {V{"self", "update"}, V{"self", "doctor", "--fix"}, V{"subos", "remove", "x"}, + V{"subos", "config", "box", "--net", "host"}, V{"config", "--mirror", "CN"}, + V{"install", "x", "--subos", "other"}}) + EXPECT_EQ(b::classify(argv, "box").route, b::Route::Owner) << argv[0] << " " << argv[1]; + EXPECT_EQ(b::classify(V{"install", "x", "--subos", "box"}, "box").route, b::Route::Broker); +} + +// Activating a version of xlings rewrites the home's entry -- the binary +// every shim in the home runs, the owner's too. Not the instance's to do. +TEST(SubosBroker, XlingsItselfIsTheOwners) { + for (V argv : {V{"install", "xlings@2026.9.30.1"}, V{"install", "gcc", "xim:xlings"}, + V{"use", "xlings", "2026.9.30.1"}, V{"update", "xlings"}, V{"remove", "xlings"}}) + EXPECT_EQ(b::classify(argv, "box").route, b::Route::Owner) << argv[0] << " " << argv[1]; + EXPECT_EQ(b::classify(V{"use", "xlings"}, "box").route, b::Route::Local) << "listing versions"; + EXPECT_EQ(b::classify(V{"install", "xlings-tools"}, "box").route, b::Route::Broker); +} + +TEST(SubosBroker, TheStrictestTargetDecides) { + auto p = pol::preset(pol::Preset::Dev); + p.fetch_rules.push_back({.match = "evil*", .action = pol::Fetch::Deny}); + auto ok = b::classify(V{"install", "gcc"}, "box"); + EXPECT_EQ(b::decide(p, ok).action, pol::Action::Allow); + auto mixed = b::classify(V{"install", "gcc", "evil-tool"}, "box"); + EXPECT_EQ(b::decide(p, mixed).action, pol::Action::Deny); +} diff --git a/tests/unit/test_subos_caps.cpp b/tests/unit/test_subos_caps.cpp new file mode 100644 index 000000000..f7b2d9321 --- /dev/null +++ b/tests/unit/test_subos_caps.cpp @@ -0,0 +1,31 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" +import std; +import xlings.subos.caps; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; + +XTEST(SubosCaps, ProbeRequestsUserNamespaceAndPreservesBackendFailure, + .area = "subos", .requires_ = {"posix"}) { + auto home = tk::Home::isolated("caps-probe"); + const auto backend = home.root() / "backend with spaces"; + tk::write_file(backend, R"SH(#!/bin/sh +if [ "$#" != 6 ] || [ "$1" != --unshare-user ] || [ "$2" != --ro-bind ] || + [ "$3" != / ] || [ "$4" != / ] || [ "$5" != -- ] || [ "$6" != /bin/true ]; then + printf 'probe did not request its own user namespace\n' >&2 + exit 23 +fi +)SH"); + fs::permissions(backend, fs::perms::owner_exec, fs::perm_options::add); + xlings::subos::caps::Backend candidate{.name = "bwrap", .bin = backend, .source = "fixture"}; + xlings::subos::caps::probe_bwrap(candidate); + ASSERT_TRUE(candidate.usable) << candidate.probe_output; + EXPECT_TRUE(candidate.probe_output.empty()); + + tk::write_file(backend, "#!/bin/sh\nprintf 'namespace denied by fixture\\n' >&2\nexit 1\n"); + xlings::subos::caps::probe_bwrap(candidate); + EXPECT_FALSE(candidate.usable); + EXPECT_NE(candidate.probe_output.find("namespace denied by fixture"), std::string::npos); +} diff --git a/tests/unit/test_subos_graphics.cpp b/tests/unit/test_subos_graphics.cpp index 7fa5e1d79..26bce1c11 100644 --- a/tests/unit/test_subos_graphics.cpp +++ b/tests/unit/test_subos_graphics.cpp @@ -17,7 +17,7 @@ #include import std; -import xlings.core.subos.graphics; +import xlings.subos.graphics; namespace gfx = xlings::subos::graphics; namespace fs = std::filesystem; diff --git a/tests/unit/test_subos_manifest.cpp b/tests/unit/test_subos_manifest.cpp index 0e203b3e1..0f6c4629d 100644 --- a/tests/unit/test_subos_manifest.cpp +++ b/tests/unit/test_subos_manifest.cpp @@ -8,9 +8,9 @@ #include import std; -import xlings.core.subos.manifest; +import xlings.subos.manifest; import xlings.libs.json; -import xlings.core.subos.userdata; +import xlings.subos.userdata; namespace m = xlings::subos::manifest; namespace fs = std::filesystem; diff --git a/tests/unit/test_subos_model.cpp b/tests/unit/test_subos_model.cpp new file mode 100644 index 000000000..1ddec4b7e --- /dev/null +++ b/tests/unit/test_subos_model.cpp @@ -0,0 +1,58 @@ +// xlings.subos.model (C4): how a typed name resolves to an instance. The +// rules moved out of src/core/subos.cpp unchanged; these pin them on names +// alone, without a home. +#include + +import std; +import xlings.subos.model; + +namespace m = xlings::subos::model; + +namespace { +const std::vector kNames{"Dev", "default", "dev-gpu", "py311", "web"}; +} + +TEST(SubosModel, ExactWinsOverEverythingElse) { + auto r = m::resolve_name("default", kNames); + EXPECT_EQ(r.selected, "default"); + EXPECT_EQ(r.reason, "exact"); + EXPECT_FALSE(r.autoSelected); +} + +TEST(SubosModel, UniqueCaseInsensitiveMatchIsAutoSelected) { + auto r = m::resolve_name("dev", kNames); + EXPECT_EQ(r.selected, "Dev"); + EXPECT_EQ(r.reason, "case_insensitive_exact"); + EXPECT_TRUE(r.autoSelected); +} + +TEST(SubosModel, UniquePrefixIsAutoSelectedAndSharedPrefixIsAmbiguous) { + auto r = m::resolve_name("py", kNames); + EXPECT_EQ(r.selected, "py311"); + EXPECT_EQ(r.reason, "unique_prefix"); + + auto amb = m::resolve_name("de", kNames); + EXPECT_TRUE(amb.selected.empty()); + EXPECT_EQ(amb.reason, "ambiguous"); + EXPECT_EQ(amb.matches, (std::vector{"Dev", "default", "dev-gpu"})); +} + +TEST(SubosModel, NotFoundSuggestsRelatedNamesFirst) { + auto r = m::resolve_name("gpu", kNames); + EXPECT_EQ(r.reason, "not_found"); + ASSERT_FALSE(r.matches.empty()); + EXPECT_EQ(r.matches.front(), "dev-gpu"); + EXPECT_LE(r.matches.size(), 3u); +} + +TEST(SubosModel, MissingNameListsEverything) { + auto r = m::resolve_name("", kNames); + EXPECT_EQ(r.reason, "missing_name"); + EXPECT_EQ(r.matches, kNames); +} + +TEST(SubosModel, EditDistance) { + EXPECT_EQ(m::edit_distance("kitten", "sitting"), 3u); + EXPECT_EQ(m::edit_distance("", "abc"), 3u); + EXPECT_EQ(m::edit_distance("same", "same"), 0u); +} diff --git a/tests/unit/test_subos_network.cpp b/tests/unit/test_subos_network.cpp new file mode 100644 index 000000000..4389e477b --- /dev/null +++ b/tests/unit/test_subos_network.cpp @@ -0,0 +1,113 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" +import std; +import xlings.platform; +import xlings.subos.network; +import xlings.subos.policy; +import xlings.subos.spec; +import xlings.subos.caps; +import xlings.confine.provider; +import xlings.confine; + +namespace network = xlings::subos::network; +namespace platform = xlings::platform; +namespace tk = xlings::testkit; +namespace policy = xlings::subos::policy; +namespace spec = xlings::subos::spec; + +XTEST(SubosNetwork, ProxyUrlsRejectCredentialsAndNonRemoteDnsSchemes, + .area = "subos") { + for (const auto* url : {"http://proxy:80", "socks5://proxy:1080", "socks5h://user:secret@proxy:1080", + "socks5h://proxy:0", "socks5h://proxy:65536", "socks5h://proxy:80/path"}) + EXPECT_FALSE(network::parse_proxy(url)) << url; + const auto ipv6 = network::parse_proxy("socks5h://[::1]:1080"); + ASSERT_TRUE(ipv6); + EXPECT_EQ(ipv6->host, "::1"); + EXPECT_EQ(ipv6->port, 1080); + auto pinned = policy::preset(policy::Preset::Dev); + pinned.net = policy::Net::Proxy; + pinned.proxy = "socks5h://declared:1080"; + EXPECT_FALSE(policy::apply(pinned, {.proxy = "socks5h://other:1080"})); + EXPECT_TRUE(policy::apply(pinned, {.proxy = pinned.proxy})); +} + +XTEST(SubosNetwork, CompilerAndProviderKeepTheAlreadyCreatedProxyNamespace, + .area = "subos") { + auto requested = policy::preset(policy::Preset::Dev); + requested.net = policy::Net::Proxy; + requested.proxy = "socks5h://proxy.example:1080"; + xlings::subos::caps::Caps caps; + caps.platform = "linux"; + caps.bwrap = xlings::subos::caps::Backend{.name = "bwrap", .bin = "/bwrap", .usable = true}; + caps.userns = true; + const auto compiled = xlings::confine::compile(requested, {.home = "/h"}, caps, + {.instance = "box", .instance_dir = "/h/subos/box", .argv = {"true"}, + .host_exists = [](std::string_view) { return true; }}); + ASSERT_TRUE(compiled); + EXPECT_TRUE(compiled->unshare_net); + EXPECT_TRUE(compiled->net_proxy); + EXPECT_FALSE(compiled->net_nat); + EXPECT_EQ(compiled->env.at("ALL_PROXY"), "socks5h://127.0.0.1:1080"); + const auto argv = xlings::confine::provider::bwrap_argv(*compiled); + EXPECT_EQ(std::ranges::find(argv, "--unshare-net"), argv.end()); + EXPECT_EQ(compiled->describe()["net"]["dns"], "remote"); + for (const auto* other : {"macos", "windows"}) { + caps.platform = other; + requested.needs["net"] = policy::Need::Must; + EXPECT_FALSE(xlings::confine::compile(requested, {.home = "/h"}, caps, + {.instance = "box", .instance_dir = "/h/subos/box", .host_exists = [](std::string_view) { return true; }})); + } +} + +XTEST(SubosNetwork, AuditRefusalPrecedesAnyDialOfTheDeclaredProxy, + .area = "subos", .requires_ = {"linux"}) { + auto upstream = platform::LoopbackListener::open(); + ASSERT_TRUE(upstream) << upstream.error(); + auto resolved = network::resolve_proxy("socks5h://127.0.0.1:" + std::to_string(upstream->port())); + ASSERT_TRUE(resolved) << resolved.error(); + network::Relay relay{std::move(*resolved)}; + const auto listening = platform::network::listen_loopback(0); + ASSERT_TRUE(listening) << listening.error(); + struct Owned { int fd; ~Owned() { platform::close_fd(fd); } } listener{*listening}; + const auto port = platform::network::bound_port(listener.fd); + ASSERT_TRUE(port); + std::atomic done { false }; + std::string clientError; + std::jthread client([&] { + auto socket = platform::TcpStream::connect_loopback(*port); + if (!socket) { clientError = socket.error(); done = true; return; } + const std::string greeting{'\5', '\1', '\0'}; + if (!socket->send_all(greeting, std::chrono::seconds(2))) { clientError = "send greeting"; done = true; return; } + std::array method{}; + auto reply = socket->receive(method, std::chrono::seconds(2)); + if (!reply || *reply != 2 || method[1] != 0) { clientError = "invalid method reply"; done = true; return; } + const std::string domain { "must-never-resolve.invalid" }; + std::string request{'\5', '\1', '\0', '\3', static_cast(domain.size())}; + request += domain; + request += std::string{'\0', static_cast(80)}; + if (!socket->send_all(request, std::chrono::seconds(2))) clientError = "send request"; + std::array result{}; + const auto response = socket->receive(result, std::chrono::seconds(2)); + if (!response || *response != 0) clientError = "audit refusal must close without CONNECT acknowledgement"; + done = true; + }); + std::vector events; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (!done && std::chrono::steady_clock::now() < deadline) { + auto accepted = platform::network::accept(listener.fd); + ASSERT_TRUE(accepted); + if (accepted->has_value()) ASSERT_TRUE(relay.add(**accepted)); + relay.tick([&](const auto& event) { events.push_back(event); return false; }); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + client.join(); + EXPECT_TRUE(clientError.empty()) << clientError; + ASSERT_EQ(events.size(), 1); + EXPECT_EQ(events[0].event, "connect-attempt"); + EXPECT_EQ(events[0].target, "must-never-resolve.invalid"); + EXPECT_TRUE(events[0].remote_dns); + const auto outbound = upstream->accept(std::chrono::milliseconds(20)); + ASSERT_TRUE(outbound); + EXPECT_FALSE(outbound->has_value()) << "audit failure must precede the host proxy connect"; +} diff --git a/tests/unit/test_subos_policy.cpp b/tests/unit/test_subos_policy.cpp new file mode 100644 index 000000000..857ad147f --- /dev/null +++ b/tests/unit/test_subos_policy.cpp @@ -0,0 +1,412 @@ +// The policy model (C14): presets, the file (fail closed), tighten-only +// overrides, the one decide(), and what the compiler makes of a preset. +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.libs.json; +import xlings.subos.home_view; +import xlings.subos.policy; +import xlings.subos.policy_store; +import xlings.subos.caps; +import xlings.subos.spec; +import xlings.confine.provider; +import xlings.confine; + +namespace pol = xlings::subos::policy; +namespace sp = xlings::subos::spec; +namespace caps = xlings::subos::caps; +using xlings::subos::HomeView; + +namespace { + +caps::Caps bwrap_host(bool pasta = false) { + caps::Caps c; + c.platform = "linux"; + c.bwrap = caps::Backend{ .name = "bwrap", .bin = "/b/bwrap", .source = "payload", .usable = true }; + if (pasta) c.pasta = "/usr/bin/pasta"; + return c; +} + +sp::Request req() { + return sp::Request{ .instance = "box", .instance_dir = "/h/subos/box", .user = "alice", + .argv = {"/bin/sh", "-c", "true"}, + .host_env = {{"LANG", "de_DE.UTF-8"}, {"LC_TIME", "de_DE"}}, + .host_exists = [](std::string_view) { return true; } }; +} + +} // namespace + +XTEST(SubosPolicy, PresetsSayWhatTheDesignSays, + .area = "subos", .covers = {"POL-PRESETS"}) { + auto dev = pol::preset(pol::Preset::Dev); + EXPECT_EQ(dev.net, pol::Net::Host); + EXPECT_EQ(dev.fetch, pol::Fetch::Auto); + EXPECT_EQ(dev.identity, pol::Identity::Host); + + auto priv = pol::preset(pol::Preset::Private); + EXPECT_EQ(priv.net, pol::Net::Nat); + EXPECT_EQ(priv.fetch, pol::Fetch::Ask); + EXPECT_EQ(priv.identity, pol::Identity::Neutral); + EXPECT_EQ(priv.needs.at("net"), pol::Need::Must); + + auto locked = pol::preset(pol::Preset::Locked); + EXPECT_EQ(locked.net, pol::Net::None); + EXPECT_EQ(locked.fetch, pol::Fetch::Deny); + EXPECT_EQ(locked.observe, pol::Observe::Full); + EXPECT_TRUE(locked.disable_userns); + EXPECT_TRUE(locked.mounts_ro_default); + EXPECT_TRUE(locked.grants_allowed.empty()); +} + +XTEST(SubosPolicy, TheFileFailsClosedOnWhatItDoesNotKnow, + .area = "subos", .covers = {"POL-UNKNOWN-REFUSED"}) { + EXPECT_FALSE(pol::from_json(nlohmann::json::parse(R"({"isolation":{"net":"vpn"}})"))); + EXPECT_FALSE(pol::from_json(nlohmann::json::parse(R"({"isolation":{"seccomp":"off"}})"))); + EXPECT_FALSE(pol::from_json(nlohmann::json::parse(R"({"extends":"paranoid"})"))); + EXPECT_FALSE(pol::from_json(nlohmann::json::parse(R"({"permissions":{"exec":"any"}})"))); + EXPECT_FALSE(pol::from_json(nlohmann::json::parse(R"({"isolation":{"grants":["root"]}})"))); + auto err = pol::from_json(nlohmann::json::parse(R"({"sandbox":{"x":1}})")); + ASSERT_FALSE(err); + EXPECT_NE(err.error().find("unknown field 'sandbox'"), std::string::npos) << err.error(); + // Free text is free. + EXPECT_TRUE(pol::from_json(nlohmann::json::parse(R"({"comment":"x","x-team":"y","extends":"dev"})"))); +} + +XTEST(SubosPolicy, TheFileRoundTrips, + .area = "subos", .covers = {"POL-PRESETS"}) { + auto doc = nlohmann::json::parse(R"({ + "extends": "private", + "isolation": {"net": "none", "identity": {"tz": "Asia/Shanghai"}, "grants_allowed": ["gpu"]}, + "mounts": [{"src": "/work", "dst": "/work", "mode": "ro"}], + "permissions": {"fetch": {"default": "ask", + "rules": [{"match": "xim:*", "action": "auto"}, + {"match": "*", "size_gt": "2GB", "action": "deny"}]}, + "index_update": "deny"}, + "observe": {"level": "full"}})"); + auto p = pol::from_json(doc); + ASSERT_TRUE(p) << p.error(); + EXPECT_EQ(p->preset, pol::Preset::Private); + EXPECT_EQ(p->net, pol::Net::None); + EXPECT_EQ(p->tz, "Asia/Shanghai"); + ASSERT_EQ(p->fetch_rules.size(), 2u); + EXPECT_EQ(p->fetch_rules[1].size_gt, 2ull << 30); + auto again = pol::from_json(pol::to_json(*p)); + ASSERT_TRUE(again) << again.error(); + EXPECT_TRUE(pol::diff(*p, *again).empty()); +} + +XTEST(SubosPolicy, ACallMayOnlyTighten, + .area = "subos", .covers = {"POL-SANDBOX-ALIAS"}) { + auto priv = pol::preset(pol::Preset::Private); + EXPECT_FALSE(pol::apply(priv, {.net = pol::Net::Host})); + EXPECT_TRUE(pol::apply(priv, {.net = pol::Net::None})); + EXPECT_FALSE(pol::apply(priv, {.fetch = pol::Fetch::Auto})); + EXPECT_TRUE(pol::apply(priv, {.allow = {"gpu"}})); // in grants_allowed + auto locked = pol::preset(pol::Preset::Locked); + EXPECT_FALSE(pol::apply(locked, {.allow = {"gpu"}})); // grants_allowed is empty + EXPECT_FALSE(pol::apply(locked, {.allow = {"root"}})); // not a grant at all +} + +XTEST(SubosPolicy, OneDecideAnswersFetchGrantsAndChanges, + .area = "subos", .covers = {"POL-DECIDE", "POL-RULES"}) { + auto p = *pol::from_json(nlohmann::json::parse(R"({"extends":"private", + "permissions":{"fetch":{"default":"ask","rules":[ + {"match":"xim:*","index":"official","action":"auto"}, + {"match":"*","size_gt":"1GB","action":"deny"}]}}})")); + EXPECT_EQ(pol::decide(p, {.kind = "fetch", .target = "xim:gcc", .index = "official"}).action, + pol::Action::Allow); + EXPECT_EQ(pol::decide(p, {.kind = "fetch", .target = "xim:gcc", .index = "mirror"}).action, + pol::Action::Ask); // rule 1 needs the index + EXPECT_EQ(pol::decide(p, {.kind = "fetch", .target = "big", .size = 2ull << 30}).action, + pol::Action::Deny); + auto d = pol::decide(p, {.kind = "fetch", .target = "x", .instance = "box"}); + EXPECT_EQ(d.action, pol::Action::Ask); + EXPECT_EQ(d.owner_command, "xlings install x --subos box"); + auto inside = pol::decide(p, {.kind = "policy_change", .from_inside = true, .instance = "box"}); + EXPECT_EQ(inside.action, pol::Action::Deny); + EXPECT_EQ(pol::decide(p, {.kind = "policy_change"}).action, pol::Action::Allow); + EXPECT_TRUE(pol::glob_match("xim:*", "xim:gcc")); + EXPECT_FALSE(pol::glob_match("xim:*", "community:gcc")); +} + +XTEST(SubosPolicy, LockedCompilesToNoNetworkANeutralIdentityAndNoNestedNamespaces, + .area = "subos", .covers = {"POL-PRESETS", "ISO-SPEC-GOLDEN"}) { + auto s = xlings::confine::compile(pol::preset(pol::Preset::Locked), HomeView{"/h"}, bwrap_host(), req()); + ASSERT_TRUE(s.has_value()); + EXPECT_TRUE(s->unshare_net); + EXPECT_TRUE(s->disable_userns); + EXPECT_TRUE(s->unshare_user); + EXPECT_EQ(s->hostname, "box"); + EXPECT_EQ(s->env.at("USER"), "user"); + EXPECT_EQ(s->env.at("HOME"), "/home/user"); + EXPECT_EQ(s->env.at("TZ"), "UTC"); + EXPECT_EQ(s->env.at("LANG"), "C.UTF-8"); + EXPECT_FALSE(s->env.contains("LC_TIME")); + auto a = xlings::confine::provider::bwrap_argv(*s); + EXPECT_NE(std::ranges::find(a, "--unshare-net"), a.end()); + EXPECT_NE(std::ranges::find(a, "/h/subos/box/etc-neutral/passwd"), a.end()); + EXPECT_EQ(std::ranges::find(a, "/etc/localtime"), a.end()) << "the host's zone stays outside"; +} + +XTEST(SubosPolicy, AMustThisHostCannotMeetRefusesAndAShouldDegrades, + .area = "subos", .covers = {"ISO-MUST-SHOULD", "ISO-NO-DEGRADE"}) { + // private needs a private network; without pasta there is none to give. + auto r = xlings::confine::compile(pol::preset(pol::Preset::Private), HomeView{"/h"}, bwrap_host(), req()); + ASSERT_FALSE(r.has_value()); + EXPECT_EQ(r.error().missing.front().dimension, "net"); + EXPECT_NE(r.error().missing.front().fix.find("passt"), std::string::npos); + // With pasta, it enters. + EXPECT_TRUE(xlings::confine::compile(pol::preset(pol::Preset::Private), HomeView{"/h"}, bwrap_host(true), req())); + + // proot under dev: entered, degraded, and says what. + caps::Caps proot; + proot.platform = "linux"; + proot.proot = caps::Backend{ .name = "proot", .bin = "/p", .source = "payload", .usable = true }; + auto dev = xlings::confine::compile(pol::preset(pol::Preset::Dev), HomeView{"/h"}, proot, req()); + ASSERT_TRUE(dev.has_value()); + EXPECT_FALSE(dev->degraded.empty()); + // ...and with --no-degrade, refused. + auto strict = pol::preset(pol::Preset::Dev); + strict.no_degrade = true; + EXPECT_FALSE(xlings::confine::compile(strict, HomeView{"/h"}, proot, req())); +} + +XTEST(SubosPolicy, NatOpensNothingUnlessPublishedOrGranted, + .area = "subos", .covers = {"ISO-NET-NAT", "ISO-GRANTS"}) { + auto r = req(); + auto s = xlings::confine::compile(pol::preset(pol::Preset::Private), HomeView{"/h"}, bwrap_host(true), r); + ASSERT_TRUE(s.has_value()); + EXPECT_TRUE(s->net_nat); + auto a = xlings::confine::provider::pasta_args(*s); + auto has = [&](std::vector seq) { + return std::ranges::search(a, seq).begin() != a.end(); + }; + EXPECT_TRUE(has({"-t", "none"})); + EXPECT_TRUE(has({"-T", "none"})); + EXPECT_TRUE(has({"--no-map-gw"})); + // bwrap must not make a second network namespace: it runs in pasta's. + auto b = xlings::confine::provider::bwrap_argv(*s); + EXPECT_EQ(std::ranges::find(b, "--unshare-net"), b.end()); + + r.publish = {"18080:80"}; + r.grants = {"host-loopback"}; + auto p = xlings::confine::compile(pol::preset(pol::Preset::Private), HomeView{"/h"}, bwrap_host(true), r); + ASSERT_TRUE(p.has_value()); + auto pa = xlings::confine::provider::pasta_args(*p); + EXPECT_NE(std::ranges::search(pa, std::vector{"-t", "18080:80"}).begin(), pa.end()); + EXPECT_EQ(std::ranges::find(pa, "--no-map-gw"), pa.end()); + + // --publish without nat is a degradation dev reports, not silence. + auto d = xlings::confine::compile(pol::preset(pol::Preset::Dev), HomeView{"/h"}, bwrap_host(true), r); + ASSERT_TRUE(d.has_value()); + EXPECT_TRUE(std::ranges::any_of(d->degraded, [](auto& u) { return u.dimension == "publish"; })); +} + +XTEST(SubosPolicy, MountSpecsParseLikeDockerV, + .area = "subos", .covers = {"ISO-MOUNT"}) { + auto a = pol::parse_mount("~/.gitconfig:ro", "/home/alice", "/w"); + ASSERT_TRUE(a); + EXPECT_EQ(a->src, "/home/alice/.gitconfig"); + EXPECT_TRUE(a->dst.empty()); + EXPECT_FALSE(a->rw); + auto b = pol::parse_mount("proj:/work:rw", "/home/alice", "/w"); + ASSERT_TRUE(b); + EXPECT_EQ(b->src, "/w/proj"); + EXPECT_EQ(b->dst, "/work"); + EXPECT_TRUE(b->rw); + auto c = pol::parse_mount("/data", "/h", "/w"); + ASSERT_TRUE(c); + EXPECT_TRUE(c->rw); + EXPECT_FALSE(c->mode_given); + EXPECT_FALSE(pol::parse_mount("/a:/b:rx", "/h", "/w")); + EXPECT_FALSE(pol::parse_mount("", "/h", "/w")); + // locked maps read-only unless told otherwise -- and refuses `rw`. + auto locked = pol::preset(pol::Preset::Locked); + auto applied = pol::apply(locked, {.mounts = {*c}}); + ASSERT_TRUE(applied); + EXPECT_FALSE(applied->mounts.back().rw); + EXPECT_FALSE(pol::apply(locked, {.mounts = {*b}})); +} + +XTEST(SubosPolicy, AMountMayNotReachTheHomeOrTheSystem, + .area = "subos", .covers = {"ISO-MOUNT"}) { + auto refused = [&](std::string src, std::string dst) { + auto p = pol::preset(pol::Preset::Dev); + p.mounts.push_back({.src = src, .dst = dst}); + return !xlings::confine::compile(p, HomeView{"/h/.xlings"}, bwrap_host(), req()).has_value(); + }; + EXPECT_TRUE(refused("/h/.xlings", "")); // the home itself + EXPECT_TRUE(refused("/h", "/x")); // above it + EXPECT_TRUE(refused("/data", "/usr")); // over the system + EXPECT_TRUE(refused("/data", "/run/xlings/x")); // over the sandbox's machinery + EXPECT_FALSE(refused("/data", "/work")); +} + +XTEST(SubosPolicy, AGrantOpensOneSocketAndPointsItsVariableAtIt, + .area = "subos", .covers = {"ISO-GRANTS"}) { + auto r = req(); + r.host_env = {{"SSH_AUTH_SOCK", "/run/user/1/ssh"}, {"DISPLAY", ":0"}, + {"DBUS_SESSION_BUS_ADDRESS", "unix:path=/run/user/1/bus"}}; + r.grants = {"ssh-agent", "display", "dbus"}; + auto s = xlings::confine::compile(pol::preset(pol::Preset::Dev), HomeView{"/h"}, bwrap_host(), r); + ASSERT_TRUE(s.has_value()); + EXPECT_EQ(s->env.at("SSH_AUTH_SOCK"), "/tmp/.xlings-ssh-agent"); + EXPECT_EQ(s->env.at("DISPLAY"), ":0"); + EXPECT_EQ(s->env.at("DBUS_SESSION_BUS_ADDRESS"), "unix:path=/tmp/.xlings-dbus"); + auto has_bind = [&](std::string src, std::string dst) { + return std::ranges::any_of(s->mounts, [&](const sp::MountOp& m) { return m.src == src && m.dst == dst; }); + }; + EXPECT_TRUE(has_bind("/run/user/1/ssh", "/tmp/.xlings-ssh-agent")); + EXPECT_TRUE(has_bind("/tmp/.X11-unix", "/tmp/.X11-unix")); + // Ungranted, none of it is there. + auto none = xlings::confine::compile(pol::preset(pol::Preset::Dev), HomeView{"/h"}, bwrap_host(), req()); + EXPECT_FALSE(none->env.contains("SSH_AUTH_SOCK")); +} + +XTEST(SubosPolicy, APackageReferenceNeedsItsResolutionAndKeepsIt, + .area = "subos", .covers = {"POL-PACK", "POL-UNKNOWN-REFUSED"}) { + namespace p = xlings::subos::policy; + EXPECT_TRUE(p::is_package_ref("xim:policy-ci-strict@1")); + EXPECT_FALSE(p::is_package_ref("private")); + // Named but never selected through `subos config`: refused, not guessed. + EXPECT_FALSE(p::from_json(nlohmann::json{{"extends", "xim:policy-ci@1"}})); + auto doc = nlohmann::json{{"extends", "xim:policy-ci@1"}, + {"resolved", {{"from", "xim:policy-ci@1.2.0"}, {"sha256", "ab"}, {"base", "locked"}}}}; + auto pol = p::from_json(doc); + ASSERT_TRUE(pol) << pol.error(); + EXPECT_EQ(pol->preset, p::Preset::Locked); + ASSERT_TRUE(pol->package); + EXPECT_EQ(pol->package->from, "xim:policy-ci@1.2.0"); + auto back = p::to_json(*pol); + EXPECT_EQ(back["extends"], "xim:policy-ci@1"); + EXPECT_EQ(back["resolved"]["base"], "locked"); + EXPECT_EQ(back["resolved"]["sha256"], "ab"); +} + +XTEST(SubosPolicy, APackageExtendsAPresetNeverAnotherPackage, + .area = "subos", .covers = {"POL-PACK"}) { + namespace p = xlings::subos::policy; + auto ok = p::from_package(nlohmann::json{{"extends", "private"}, {"isolation", {{"net", "none"}}}}, + "xim:a@1", {"xim:a@1.0.0", "00"}); + ASSERT_TRUE(ok) << ok.error(); + EXPECT_EQ(ok->net, p::Net::None); + EXPECT_EQ(ok->extends, "xim:a@1"); + EXPECT_FALSE(p::from_package(nlohmann::json{{"extends", "xim:b@1"}}, "xim:a@1", {"xim:a@1.0.0", "00"})); + EXPECT_FALSE(p::from_package(nlohmann::json{{"resolved", {{"from", "x"}}}}, "xim:a@1", {"xim:a@1.0.0", "00"})); + // A field this version cannot enforce is refused in a package as anywhere. + EXPECT_FALSE(p::from_package(nlohmann::json{{"isolation", {{"net", "vpn"}}}}, "xim:a@1", {"xim:a@1.0.0", "00"})); +} + +// `layer` (part 2 §3.3) installs into a root's own system scope: it parses +// wherever it appears, is allowed by the decision, and is what tells the entry +// that the instance must be a root (a view refuses to start with it). +XTEST(SubosPolicy, FetchLayerIsTheRootsOwnScope, + .area = "subos", .covers = {"PERM-FETCH-LAYER", "POL-UNKNOWN-REFUSED"}) { + namespace p = xlings::subos::policy; + EXPECT_TRUE(p::fetch_from_string("layer")); + auto file = p::from_json(nlohmann::json{{"extends", "dev"}, {"permissions", {{"fetch", "layer"}}}}); + ASSERT_TRUE(file) << file.error(); + EXPECT_TRUE(p::fetches_into_layer(*file)); + EXPECT_EQ(p::decide(*file, {.kind = "fetch", .target = "make", .from_inside = true}).action, + p::Action::Allow); + auto rules = p::from_json(nlohmann::json{{"permissions", {{"fetch", {{"rules", + nlohmann::json::array({{{"match", "*"}, {"action", "layer"}}})}}}}}}); + ASSERT_TRUE(rules); + EXPECT_TRUE(p::fetches_into_layer(*rules)); + EXPECT_FALSE(p::fetches_into_layer(p::preset(p::Preset::Dev))); + // What this version cannot enforce is still refused. + EXPECT_FALSE(p::from_json(nlohmann::json{{"isolation", {{"net", "vpn"}}}})); +} + +XTEST(SubosPolicy, MinimumClientVersionsCompareNumericallyAndValidateTheirSyntax, + .area = "subos", .covers = {"POLICY-MIN-CLIENT"}) { + const auto compare = [](std::string_view a, std::string_view b, int expected) { + const auto result = pol::compare_client_versions(a, b); + ASSERT_TRUE(result) << result.error(); + EXPECT_EQ(*result, expected) << a << " versus " << b; + }; + compare("2026.10.8.2", "2026.9.30.12", 1); + compare("2026.10.8.10", "2026.10.8.2", 1); + compare("2026.10.8.2", "2026.10.8.2", 0); + compare("0.4.8", "2026.10.8.2", -1); + compare("1.2.3-rc.2", "1.2.3-rc.10", -1); + compare("1.2.3-rc.10", "1.2.3", -1); + compare("1.2.3+build.01", "1.2.3+another", 0); + compare("1.2.3-9", "1.2.3-alpha", -1); + compare("1.2.3-alpha", "1.2.3-alpha.1", -1); + for (std::string_view bad : {"", "latest", "1", "1.2", "v1.2.3", "1.02.3", "1.2.3.", + "1.2.3-", "1.2.3-01", "1.2.3+a..b", "1.2.3+", "1.2.3 ", + "2026.13.8.1", "2026.2.30.1", "2026.10.8.1-rc.1", "1.2.3.4.5", + "18446744073709551616.1.0"}) + EXPECT_FALSE(pol::compare_client_versions(bad, "2026.10.8.2")) << bad; +} + +XTEST(SubosPolicy, MinimumClientRoundTripsAndRefusesAnOlderClient, + .area = "subos", .covers = {"POLICY-MIN-CLIENT"}) { + auto p = pol::from_json({{"extends", "dev"}, {"min_client", "2026.10.8.2"}}); + ASSERT_TRUE(p) << p.error(); + EXPECT_EQ(pol::to_json(*p)["min_client"], "2026.10.8.2"); + EXPECT_FALSE(pol::check_client(*p, "2026.10.8.1")); + EXPECT_TRUE(pol::check_client(*p, "2026.10.8.2")); + EXPECT_TRUE(pol::check_client(*p, "2026.10.9.1")); + EXPECT_FALSE(pol::check_client(*p, "unknown")); + EXPECT_FALSE(pol::from_json({{"min_client", 42}})); + EXPECT_FALSE(pol::from_json({{"min_client", "bad"}})); + auto old = pol::from_json({{"extends", "dev"}}); + ASSERT_TRUE(old); + EXPECT_TRUE(old->min_client.empty()); + EXPECT_TRUE(pol::check_client(*old, "0.4.8")); +} + +XTEST(SubosPolicy, NewFilesDeclareASchemaFloorAndPreserveStricterMinimums, + .area = "subos", .covers = {"POLICY-MIN-CLIENT"}) { + namespace store = xlings::subos::policy_store; + auto temp = xlings::testkit::Home::isolated("policy-client"); + const HomeView home{temp.dir()}; + ASSERT_TRUE(store::write(home, "box", pol::preset(pol::Preset::Dev))); + auto p = store::read(home, "box"); + ASSERT_TRUE(p && *p); + EXPECT_EQ((**p).min_client, pol::kPolicyMinClient); + EXPECT_FALSE(store::effective(home, "box", pol::Preset::Locked, {}, "2026.10.8.1")); + EXPECT_TRUE(store::effective(home, "box", pol::Preset::Dev, {}, pol::kPolicyMinClient)); + auto future = **p; + future.min_client = "2099.1.1.1"; + ASSERT_TRUE(store::write(home, "box", future)); + ASSERT_TRUE(store::write(home, "box", pol::preset(pol::Preset::Dev))); + p = store::read(home, "box"); + ASSERT_TRUE(p && *p); + EXPECT_EQ((**p).min_client, "2099.1.1.1"); + EXPECT_FALSE(store::read(home, "box", pol::kPolicyMinClient)); +} + +XTEST(SubosPolicy, MissingPolicyIsLegacyButPresentInvalidStateRefuses, + .area = "subos", .covers = {"POL-UNKNOWN-REFUSED", "POLICY-MIN-CLIENT"}) { + namespace store = xlings::subos::policy_store; + auto temp = xlings::testkit::Home::isolated("policy-state"); + const HomeView home{temp.dir()}; + const auto path = home.policy_file("box"); + EXPECT_FALSE(store::has_file(home, "box")); + auto missing = store::read(home, "box"); + ASSERT_TRUE(missing); + EXPECT_FALSE(*missing); + std::filesystem::create_directories(path); + EXPECT_TRUE(store::has_file(home, "box")); + EXPECT_FALSE(store::read(home, "box")); + EXPECT_FALSE(store::write(home, "box", pol::preset(pol::Preset::Dev))); + std::filesystem::remove(path); + const std::string broken = "{invalid policy"; + xlings::testkit::write_file(path, broken); + EXPECT_FALSE(store::write(home, "box", pol::preset(pol::Preset::Dev))); + EXPECT_EQ(xlings::testkit::read_file(path), broken); + if constexpr (xlings::testkit::is_posix) { + std::filesystem::remove(path); + std::filesystem::create_symlink("missing-policy-target", path); + EXPECT_TRUE(store::has_file(home, "box")); + EXPECT_FALSE(store::read(home, "box")); + EXPECT_FALSE(store::effective(home, "box", std::nullopt, {}, pol::kPolicyMinClient)); + EXPECT_FALSE(store::write(home, "box", pol::preset(pol::Preset::Dev))); + EXPECT_EQ(std::filesystem::read_symlink(path), "missing-policy-target"); + } +} diff --git a/tests/unit/test_subos_rootfs.cpp b/tests/unit/test_subos_rootfs.cpp new file mode 100644 index 000000000..cd2c95d63 --- /dev/null +++ b/tests/unit/test_subos_rootfs.cpp @@ -0,0 +1,484 @@ +// The root projection, its generations, a root tree's machine state, the boot +// choice and the role table (design part 2 §6, §8): the pure half, on temporary +// directories. What they do inside a sandbox, a container and a booted kernel +// is the scenarios' (tests/e2e/rootfs_*.sh). +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import luban.machine; +import xlings.platform; +import xlings.subos.rootfs; +import luban.boot; +import xlings.subos.roles; + +namespace fs = std::filesystem; +namespace tk = xlings::testkit; +namespace rf = xlings::subos::rootfs; +namespace bt = luban::boot; +namespace rl = xlings::subos::roles; + +namespace { + +struct Tmp { + fs::path dir; + explicit Tmp(std::string_view name) { + dir = fs::temp_directory_path() + / std::format("xtest-rootfs-{}-{}", name, + std::chrono::steady_clock::now().time_since_epoch().count()); + fs::create_directories(dir); + } + ~Tmp() { std::error_code ec; fs::remove_all(dir, ec); } +}; + +void exe(const fs::path& p) { + tk::write_file(p, "#!/bin/sh\n"); + fs::permissions(p, fs::perms::owner_all, fs::perm_options::add); +} + +const rf::Link* find(const rf::Plan& p, std::string_view rel) { + auto it = std::ranges::find(p.links, rel, &rf::Link::rel); + return it == p.links.end() ? nullptr : &*it; +} + +fs::path target_of(const rf::Plan& p, std::string_view rel) { + auto* l = find(p, rel); + return l ? l->target : fs::path(""); +} + +} // namespace + +XTEST(SubosRootfs, AProjectionIsWhatADistributionWouldInstall, + .area = "subos", .covers = {"ROOT-PROJECT"}) { + if constexpr (!tk::is_posix) GTEST_SKIP() << "root projections are Linux"; + Tmp t("plan"); + const auto busybox = t.dir / "xpkgs" / "busybox" / "1.0"; + exe(busybox / "bin" / "busybox"); + fs::create_symlink("busybox", busybox / "bin" / "sh"); + fs::create_symlink("busybox", busybox / "bin" / "ls"); + const auto glibc = t.dir / "xpkgs" / "glibc" / "2.44"; + exe(glibc / "lib" / "ld-linux-x86-64.so.2"); + tk::write_file(glibc / "lib" / "libc.so.6", "elf"); + tk::write_file(glibc / "lib" / "crt1.o", "obj"); // not a shared object + exe(glibc / "sbin" / "ldconfig"); + const auto coreutils = t.dir / "xpkgs" / "coreutils" / "9.0"; + exe(coreutils / "bin" / "ls"); + const auto sysroot = t.dir / "subos" / "usr"; + fs::create_directories(sysroot / "include"); + fs::create_directories(sysroot / "share" / "terminfo"); + const auto shared = t.dir / "subos" / "share"; + fs::create_directories(shared / "glvnd" / "egl_vendor.d"); + + rf::Inputs in; + in.programs = {{"ls", coreutils / "bin" / "ls"}, {"xlings", t.dir / "bin" / "xlings"}}; + in.payloads = {busybox, glibc, coreutils}; + in.sysroot_usr = sysroot; + in.sysroot_share = shared; + auto p = rf::plan(in); + + ASSERT_NE(find(p, "usr/bin/sh"), nullptr); + EXPECT_EQ(target_of(p, "usr/bin/sh"), busybox / "bin" / "sh") << "an applet link counts"; + EXPECT_EQ(target_of(p, "usr/bin/ls"), coreutils / "bin" / "ls") << "a registered program wins"; + EXPECT_EQ(target_of(p, "usr/bin/xlings"), t.dir / "bin" / "xlings"); + EXPECT_EQ(target_of(p, "usr/lib/ld-linux-x86-64.so.2"), glibc / "lib" / "ld-linux-x86-64.so.2"); + EXPECT_NE(find(p, "usr/lib/libc.so.6"), nullptr); + EXPECT_EQ(find(p, "usr/lib/crt1.o"), nullptr) << "only shared objects go to /usr/lib"; + EXPECT_NE(find(p, "usr/bin/ldconfig"), nullptr) << "sbin merges into bin"; + EXPECT_EQ(target_of(p, "usr/lib64"), fs::path("lib")); + EXPECT_EQ(target_of(p, "usr/sbin"), fs::path("bin")); + EXPECT_EQ(target_of(p, "usr/include"), sysroot / "include"); + EXPECT_EQ(target_of(p, "usr/share/terminfo"), sysroot / "share/terminfo"); + EXPECT_EQ(target_of(p, "usr/share/glvnd"), shared / "glvnd"); + // busybox's ls lost to the registered one, and that is written down. + auto c = std::ranges::find(p.conflicts, "usr/bin/ls", &rf::Conflict::rel); + ASSERT_NE(c, p.conflicts.end()); + EXPECT_EQ(c->kept, "program"); +} + +XTEST(SubosRootfs, AGenerationSwitchIsOneRenameAndARollbackMovesThePointer, + .area = "subos", .covers = {"ROOT-GEN-ATOMIC", "ROOT-ROLLBACK"}) { + if constexpr (!tk::is_posix) GTEST_SKIP() << "root projections are Linux"; + Tmp t("gen"); + const auto subos = t.dir / "subos" / "box"; + fs::create_directories(subos); + exe(t.dir / "a" / "sh"); + exe(t.dir / "b" / "sh"); + rf::Plan one{{{"usr/bin/sh", t.dir / "a" / "sh", "x"}}, {}}; + rf::Plan two{{{"usr/bin/sh", t.dir / "b" / "sh", "x"}}, {}}; + + auto g1 = rf::commit(subos, one, "first"); + ASSERT_TRUE(g1.has_value()) << g1.error(); + EXPECT_EQ(*g1, 1); + EXPECT_EQ(rf::commit(subos, one, "same").value(), 1) << "an identical plan writes nothing"; + EXPECT_EQ(fs::read_symlink(rf::usr_of(subos) / "bin" / "sh"), t.dir / "a" / "sh"); + + // Acquire the pointer once, then read that immutable generation. Separate + // pathname lookups through a moving symlink are not a snapshot. The + // production reader handles macOS's transient EINVAL during vnode replacement. + std::atomic misses { 0 }, reads { 0 }; + std::vector diagnostics; + std::latch ready { 1 }; + std::jthread reader([&](std::stop_token stop) { + do { + std::error_code ec; + const auto target = xlings::platform::read_symlink(subos / rf::kPointer, ec); + if (ec || target.parent_path() != rf::kGenerations) { + ++misses; + if (diagnostics.size() < 8) + diagnostics.push_back(std::format("pointer: {} [{}:{}] target={}", + ec.message(), ec.category().name(), ec.value(), target.string())); + } else { + const auto program = fs::read_symlink(subos / target / "usr/bin/sh", ec); + if (ec || (program != t.dir / "a/sh" && program != t.dir / "b/sh")) { + ++misses; + if (diagnostics.size() < 8) + diagnostics.push_back(std::format("generation {}: {} [{}:{}] program={}", + target.string(), ec.message(), ec.category().name(), ec.value(), program.string())); + } + } + if (++reads == 1) ready.count_down(); + } while (!stop.stop_requested()); + }); + ready.wait(); + for (int i = 0; i < 40; ++i) { + const auto committed = rf::commit(subos, i % 2 ? one : two, "flip"); + EXPECT_TRUE(committed.has_value()); + } + reader.request_stop(); + reader.join(); + EXPECT_GT(reads.load(), 0); + EXPECT_EQ(misses.load(), 0) << "the pointer or its acquired generation was missing or incomplete: " + << ::testing::PrintToString(diagnostics); + + const auto gens = rf::generations(subos); + ASSERT_GE(gens.size(), 3u); + ASSERT_TRUE(rf::switch_to(subos, 1).has_value()); + EXPECT_EQ(rf::current(subos), 1); + EXPECT_EQ(fs::read_symlink(rf::usr_of(subos) / "bin" / "sh"), t.dir / "a" / "sh"); + EXPECT_FALSE(rf::switch_to(subos, 999).has_value()); + EXPECT_EQ(rf::info(subos, 1)->reason, "first"); + + auto removed = rf::prune(subos, 2); + EXPECT_FALSE(removed.empty()); + EXPECT_EQ(rf::current(subos), 1) << "the current generation is never pruned"; + EXPECT_EQ(rf::generations(subos).size(), 3u); +} + +XTEST(SubosRootfs, StagingAndPrunePreserveEverythingNotProvenDerived, + .area = "subos", .covers = {"ROOT-GEN-ATOMIC", "ROOT-ROLLBACK"}) { + if constexpr (!tk::is_posix) GTEST_SKIP() << "root projections require symlinks"; + Tmp t("ownership"); + const auto subos = t.dir / "subos/box"; + tk::write_file(subos / "root.gen/.1.partial/user.txt", "mine"); + tk::write_file(subos / ".root.1", "mine"); + exe(t.dir / "sh"); + const rf::Plan first{{{"usr/bin/sh", t.dir / "sh", "x"}}, {}}; + const auto committed = rf::commit(subos, first, "first"); + ASSERT_TRUE(committed.has_value()); + EXPECT_EQ(tk::read_file(subos / "root.gen/.1.partial/user.txt"), "mine"); + EXPECT_EQ(tk::read_file(subos / ".root.1"), "mine"); + tk::write_file(subos / "root.gen/2/user.txt", "mine"); + tk::write_file(subos / "root.gen/1/user.txt", "mine"); + auto second = first; + second.links.push_back({"usr/bin/other", t.dir / "sh", "x"}); + ASSERT_TRUE(rf::commit(subos, second, "second").has_value()); + EXPECT_TRUE(rf::prune(subos, 0).empty()); + EXPECT_EQ(tk::read_file(subos / "root.gen/1/user.txt"), "mine"); + EXPECT_EQ(tk::read_file(subos / "root.gen/2/user.txt"), "mine"); + EXPECT_FALSE(rf::switch_to(subos, 1).has_value()) << "a modified tree cannot become a projection"; +} + +XTEST(SubosRootfs, UnsafePlansAndForeignPointersAreRefusedWithoutWritingOutsideTheStage, + .area = "subos", .covers = {"ROOT-GEN-ATOMIC"}) { + if constexpr (!tk::is_posix) GTEST_SKIP() << "root projections require symlinks"; + Tmp t("unsafe-plan"); + const auto subos = t.dir / "subos"; + fs::create_directory(subos); + const rf::Plan escape{{{"../../escaped", t.dir / "target", "x"}}, {}}; + EXPECT_FALSE(rf::commit(subos, escape, "escape").has_value()); + EXPECT_FALSE(fs::exists(fs::symlink_status(subos / "escaped"))); + const auto outside = t.dir / "outside"; + fs::create_directory(outside); + const rf::Plan nested{{{"usr/bin", outside, "x"}, {"usr/bin/injected", outside, "x"}}, {}}; + EXPECT_FALSE(rf::commit(subos, nested, "nested").has_value()); + EXPECT_FALSE(fs::exists(fs::symlink_status(outside / "injected"))); + tk::write_file(subos / "root", "user file"); + const rf::Plan valid{{{"usr/bin/sh", t.dir / "target", "x"}}, {}}; + EXPECT_FALSE(rf::commit(subos, valid, "foreign-pointer").has_value()); + EXPECT_EQ(tk::read_file(subos / "root"), "user file"); + for (const auto& entry : fs::directory_iterator(subos)) + EXPECT_FALSE(entry.path().filename().string().starts_with(".xlings-stage-")); + for (const auto& entry : fs::directory_iterator(subos / "root.gen")) + EXPECT_FALSE(entry.path().filename().string().starts_with(".xlings-stage-")); +} + +XTEST(SubosRootfs, MachineStateIsFilledNeverOverwritten, + .area = "subos", .covers = {"ROOT-ETC-FACTORY"}) { + if constexpr (!tk::is_posix) GTEST_SKIP() << "root trees are Linux"; + Tmp t("etc"); + const auto root = t.dir / "root"; + const auto home = fs::path("/xlings"); + ASSERT_TRUE(rf::lay_out(root, "/xlings/subos/box/root/usr", home).has_value()); + EXPECT_EQ(fs::read_symlink(root / "usr"), fs::path("/xlings/subos/box/root/usr")); + EXPECT_EQ(fs::read_symlink(root / "lib64"), fs::path("usr/lib64")); + EXPECT_EQ(fs::read_symlink(root / "sbin"), fs::path("usr/bin")); + EXPECT_TRUE(fs::is_directory(root / "xlings")) << "the home's mount point"; + EXPECT_EQ(rf::host_of(root, home), "box"); + // Laid out again for another SubOS: /usr moves, nothing else does. + tk::write_file(root / "etc" / "hostname", "mine\n"); + ASSERT_TRUE(rf::lay_out(root, "/xlings/subos/next/root/usr", home).has_value()); + EXPECT_EQ(rf::host_of(root, home), "next"); + EXPECT_EQ(tk::read_file(root / "etc" / "hostname"), "mine\n"); + + const auto factory = t.dir / "factory"; + tk::write_file(factory / "hostname", "luban\n"); + tk::write_file(factory / "ssl" / "certs" / "ca-certificates.crt", "pem"); + auto added = luban::machine::fill_etc(root / "etc", factory); + ASSERT_TRUE(added.has_value()) << added.error(); + EXPECT_EQ(*added, std::vector{"ssl/certs/ca-certificates.crt"}); + EXPECT_EQ(tk::read_file(root / "etc" / "hostname"), "mine\n") << "the machine's file stays"; + EXPECT_TRUE(fs::is_symlink(root / "etc" / "ssl" / "certs" / "ca-certificates.crt")); + auto again = luban::machine::fill_etc(root / "etc", factory); + ASSERT_TRUE(again.has_value()) << again.error(); + EXPECT_TRUE(again->empty()) << "a second fill adds nothing"; + + // sysusers: root always, declared users appended once, existing ones kept. + const auto usr = t.dir / "usr"; + tk::write_file(usr / "lib" / "sysusers.d" / "sshd.conf", + "# comment\ng ssh 74\nu sshd 74 \"SSH daemon\" /var/empty /usr/bin/nologin\n"); + tk::write_file(root / "etc" / "passwd", "alice:x:1000:1000::/home/alice:/bin/sh\n"); + auto users = luban::machine::apply_sysusers(root / "etc", usr); + ASSERT_TRUE(users.has_value()) << users.error(); + EXPECT_NE(std::ranges::find(*users, "user root"), users->end()); + EXPECT_NE(std::ranges::find(*users, "user sshd"), users->end()); + const auto passwd = tk::read_file(root / "etc" / "passwd"); + EXPECT_TRUE(passwd.starts_with("alice:x:1000")) << passwd; + EXPECT_NE(passwd.find("sshd:x:74:74:SSH daemon:/var/empty:/usr/bin/nologin"), std::string::npos) << passwd; + auto usersAgain = luban::machine::apply_sysusers(root / "etc", usr); + ASSERT_TRUE(usersAgain.has_value()) << usersAgain.error(); + EXPECT_TRUE(usersAgain->empty()) << "nothing twice"; +} + +XTEST(SubosRootfs, LayoutPreservesForeignStagingAndReportsMachineStateConflicts, + .area = "subos", .covers = {"ROOT-ETC-FACTORY"}) { + if constexpr (!tk::is_posix) GTEST_SKIP() << "root trees require symlinks"; + Tmp t("layout-conflict"); + const auto root = t.dir / "root"; + ASSERT_TRUE(rf::lay_out(root, "/old/usr", "/xlings").has_value()); + tk::write_file(root / ".usr.new", "user-owned staging name"); + ASSERT_TRUE(rf::lay_out(root, "/new/usr", "/xlings").has_value()); + EXPECT_EQ(tk::read_file(root / ".usr.new"), "user-owned staging name"); + fs::remove(root / "tmp"); + tk::write_file(root / "tmp", "user-owned file"); + EXPECT_FALSE(rf::lay_out(root, "/new/usr", "/xlings").has_value()); + EXPECT_EQ(tk::read_file(root / "tmp"), "user-owned file"); +} + +XTEST(SubosRootfs, MachineEtcRefusesSymlinkAncestorsAndPreservesExternalData, + .area = "subos", .covers = {"ROOT-ETC-FACTORY"}) { + if constexpr (!tk::is_posix) GTEST_SKIP() << "anchored root machine writes require POSIX"; + Tmp t("etc-escape"); + const auto root = t.dir / "root"; + const auto outside = t.dir / "outside"; + const auto factory = t.dir / "factory"; + tk::write_file(outside / "sentinel", "external user bytes"); + tk::write_file(factory / "new-file", "factory bytes"); + fs::create_directories(root); + fs::create_directory_symlink(outside, root / "etc"); + EXPECT_FALSE(luban::machine::fill_etc(root / "etc", factory).has_value()); + EXPECT_FALSE(luban::machine::fill_machine_etc(root / "etc", t.dir / "scope").has_value()); + EXPECT_FALSE(luban::machine::apply_sysusers(root / "etc", t.dir / "usr").has_value()); + EXPECT_FALSE(rf::lay_out(root, "/xlings/subos/box/root/usr", "/xlings").has_value()); + EXPECT_EQ(tk::read_file(outside / "sentinel"), "external user bytes"); + EXPECT_FALSE(fs::exists(outside / "new-file")); + EXPECT_FALSE(fs::exists(outside / "passwd")); + EXPECT_FALSE(fs::exists(outside / "group")); + + fs::remove(root / "etc"); + fs::create_directories(root / "etc"); + tk::write_file(factory / "ssl" / "certs" / "new-certificate", "pem"); + fs::create_directory_symlink(outside, root / "etc" / "ssl"); + EXPECT_FALSE(luban::machine::fill_etc(root / "etc", factory).has_value()); + EXPECT_FALSE(fs::exists(outside / "certs")); + EXPECT_EQ(tk::read_file(outside / "sentinel"), "external user bytes"); + + fs::remove(root / "etc" / "ssl"); + fs::create_directory_symlink(outside, root / "var"); + EXPECT_FALSE(rf::lay_out(root, "/xlings/subos/box/root/usr", "/xlings").has_value()); + EXPECT_FALSE(fs::exists(outside / "log")); + EXPECT_FALSE(fs::exists(outside / "lib")); + // The tree's parent is also part of the authority, not canonicalized + // away as if an arbitrary user alias were a trusted filesystem root. + fs::create_directory_symlink(outside, t.dir / "alias"); + EXPECT_FALSE(luban::machine::fill_etc(t.dir / "alias" / "root" / "etc", factory).has_value()); + EXPECT_FALSE(fs::exists(outside / "root")); +} + +XTEST(SubosRootfs, FactoryProjectionLeavesUseLexicalDestinationsAndPrivateAccountCopies, + .area = "subos", .covers = {"ROOT-ETC-FACTORY"}) { + if constexpr (!tk::is_posix) GTEST_SKIP() << "anchored root machine writes require POSIX"; + Tmp t("factory-source-link"); + const auto root = t.dir / "root"; + const auto factory = t.dir / "factory"; + const auto payload = t.dir / "payload"; + fs::create_directories(factory); + tk::write_file(payload / "hostname", "payload hostname"); + tk::write_file(payload / "passwd", "alice:x:1000:1000::/home/alice:/bin/sh\n"); + fs::create_symlink(payload / "hostname", factory / "hostname"); + fs::create_symlink(payload / "passwd", factory / "passwd"); + auto filled = luban::machine::fill_etc(root / "etc", factory); + ASSERT_TRUE(filled.has_value()) << filled.error(); + EXPECT_TRUE(fs::is_symlink(root / "etc" / "hostname")); + EXPECT_EQ(fs::read_symlink(root / "etc" / "hostname"), factory / "hostname"); + EXPECT_FALSE(fs::exists(root / "payload")) << "source resolution must not manufacture ../ destinations"; + EXPECT_FALSE(fs::is_symlink(root / "etc" / "passwd")); + EXPECT_EQ(tk::read_file(root / "etc" / "passwd"), tk::read_file(payload / "passwd")); + auto users = luban::machine::apply_sysusers(root / "etc", t.dir / "usr"); + ASSERT_TRUE(users.has_value()) << users.error(); + EXPECT_NE(tk::read_file(root / "etc" / "passwd").find("root:x:0:0:"), std::string::npos); + EXPECT_EQ(tk::read_file(payload / "passwd"), "alice:x:1000:1000::/home/alice:/bin/sh\n"); + EXPECT_EQ(tk::read_file(payload / "hostname"), "payload hostname"); + const auto machine_passwd = tk::read_file(root / "etc" / "passwd"); + fs::remove(factory / "passwd"); + fs::create_symlink(payload / "missing-default", factory / "passwd"); + auto reused = luban::machine::fill_etc(root / "etc", factory); + ASSERT_TRUE(reused.has_value()) << reused.error(); + EXPECT_TRUE(reused->empty()) << "an existing account database does not read its factory default"; + EXPECT_EQ(tk::read_file(root / "etc" / "passwd"), machine_passwd); +} + +XTEST(SubosRootfs, SysusersRefusesUnknownLinksAndSharedFilesWithoutChangingTheirTargets, + .area = "subos", .covers = {"ROOT-ETC-FACTORY"}) { + if constexpr (!tk::is_posix) GTEST_SKIP() << "anchored root machine writes require POSIX"; + Tmp t("sysusers-links"); + const auto etc = t.dir / "root" / "etc"; + const auto external = t.dir / "external-passwd"; + tk::write_file(external, "external:x:900:900::/:/bin/sh\n"); + fs::create_directories(etc); + fs::create_symlink(external, etc / "passwd"); + EXPECT_FALSE(luban::machine::apply_sysusers(etc, t.dir / "usr").has_value()); + EXPECT_TRUE(fs::is_symlink(etc / "passwd")); + EXPECT_FALSE(fs::exists(etc / "group")); + EXPECT_EQ(tk::read_file(external), "external:x:900:900::/:/bin/sh\n"); + fs::remove(etc / "passwd"); + fs::create_hard_link(external, etc / "passwd"); + EXPECT_FALSE(luban::machine::apply_sysusers(etc, t.dir / "usr").has_value()); + EXPECT_EQ(tk::read_file(external), "external:x:900:900::/:/bin/sh\n"); + EXPECT_FALSE(fs::exists(etc / "group")); +} + +XTEST(SubosRootfs, AnchoredMachineDirectoryKeepsItsAuthorityWhenThePathIsReplaced, + .area = "subos", .covers = {"ROOT-ETC-FACTORY"}) { + if constexpr (!tk::is_posix) GTEST_SKIP() << "anchored root machine writes require POSIX"; + Tmp t("etc-anchored"); + const auto etc = t.dir / "root" / "etc"; + const auto outside = t.dir / "outside"; + fs::create_directories(etc); + tk::write_file(outside / "sentinel", "external"); + auto directory = xlings::platform::machine_etc::Directory::open(etc); + ASSERT_TRUE(directory.has_value()) << directory.error(); + fs::rename(etc, t.dir / "original-etc"); + fs::create_directory_symlink(outside, etc); + auto result = directory->write_missing("created", "anchored"); + ASSERT_TRUE(result.has_value()) << result.error(); + EXPECT_TRUE(*result); + EXPECT_EQ(tk::read_file(t.dir / "original-etc" / "created"), "anchored"); + EXPECT_FALSE(fs::exists(outside / "created")); + EXPECT_EQ(tk::read_file(outside / "sentinel"), "external"); + EXPECT_FALSE(directory->write_missing("../escaped", "reject").has_value()); + EXPECT_FALSE(fs::exists(t.dir / "root" / "escaped")); +} + +XTEST(SubosBoot, ATrialIsUsedOnceAndAnUnconfirmedDefaultFallsBack, + .area = "subos", .covers = {"BOOT-ONCE-FALLBACK"}) { + auto all = [](std::string_view) { return true; }; + bt::Config c; + c.default_entry = "core"; + c.fallback = "tiny"; + c.once = "trial"; + auto cands = bt::candidates(c, all); + ASSERT_EQ(cands.size(), 3u); + EXPECT_EQ(cands[0].subos, "trial"); + EXPECT_EQ(cands[0].via, "once"); + + c = bt::record_boot(c, cands[0]); + EXPECT_FALSE(c.once.has_value()) << "a trial is consumed by the boot that uses it"; + EXPECT_EQ(c.booted, "trial"); + EXPECT_FALSE(c.good); + // Not confirmed: the next boot is the default again. + EXPECT_EQ(bt::candidates(c, all).front().subos, "core"); + + // The default, never confirmed, spends its tries and then the fallback boots. + for (int i = 0; i < bt::kTries; ++i) c = bt::record_boot(c, bt::candidates(c, all).front()); + EXPECT_EQ(bt::candidates(c, all).front().subos, "tiny"); + // Confirmed once, it is chosen again. + c.booted = "core"; + c = bt::mark_good(c); + EXPECT_EQ(bt::candidates(c, all).front().subos, "core"); + + // What cannot be booted is skipped, never chosen. + auto only_tiny = [](std::string_view n) { return n == "tiny"; }; + EXPECT_EQ(bt::candidates(c, only_tiny).front().subos, "tiny"); + + // The file round-trips. + auto again = bt::from_json(bt::to_json(c)); + EXPECT_EQ(again.default_entry, "core"); + EXPECT_EQ(again.tries, c.tries); + EXPECT_TRUE(again.good); +} + +XTEST(SubosRoles, EveryOperationOnEveryKindAndRoleHasOneAnswer, + .area = "subos", .covers = {"ROOT-ROLE-TABLE"}) { + const rl::Role none{}, host{.host = true}, entry{.boot_entry = true}; + for (auto op : rl::kOps) { + for (auto kind : {rl::Kind::View, rl::Kind::Rootfs}) { + for (auto role : {none, host, entry}) { + auto v = rl::check(op, kind, role, "x"); + if (!v.allowed) { + EXPECT_FALSE(v.reason.empty()) << rl::to_string(op); + EXPECT_FALSE(v.next.empty()) << rl::to_string(op) << ": a refusal says what to do"; + } + } + } + } + // The ones that matter, spelled out. + EXPECT_FALSE(rl::check(rl::Op::Remove, rl::Kind::Rootfs, host, "default").allowed); + EXPECT_FALSE(rl::check(rl::Op::Remove, rl::Kind::Rootfs, entry, "core").allowed); + EXPECT_TRUE(rl::check(rl::Op::Remove, rl::Kind::Rootfs, none, "trial").allowed); + EXPECT_FALSE(rl::check(rl::Op::Policy, rl::Kind::Rootfs, host, "default").allowed); + EXPECT_TRUE(rl::check(rl::Op::Policy, rl::Kind::Rootfs, none, "box").allowed); + EXPECT_FALSE(rl::check(rl::Op::Boot, rl::Kind::View, none, "dev").allowed); + EXPECT_FALSE(rl::check(rl::Op::Export, rl::Kind::View, none, "dev").allowed); + EXPECT_FALSE(rl::check(rl::Op::Rollback, rl::Kind::View, none, "dev").allowed); + EXPECT_TRUE(rl::check(rl::Op::Packages, rl::Kind::Rootfs, host, "default").allowed); + EXPECT_EQ(rl::kind_from_string("rootfs"), rl::Kind::Rootfs); + EXPECT_FALSE(rl::kind_from_string("vm").has_value()); +} + +XTEST(SubosRootfs, RunningProjectionAcceptsAPinnedGenerationAndRejectsAnUnrelatedUsr, + .area = "subos", .covers = {"ROOT-LIBSEARCH", "ROOT-GEN-ATOMIC"}) { + if constexpr (!tk::is_posix) GTEST_SKIP() << "requires directory symlinks"; + Tmp t("running-projection"); + const auto scope = t.dir / "home/subos/box"; + const auto machine = t.dir / "machine"; + fs::create_directories(machine); + const auto first = rf::commit(scope, {}, "first"); + ASSERT_TRUE(first) << first.error(); + fs::create_directory_symlink(scope / "root.gen" / std::to_string(*first) / "usr", machine / "usr"); + const auto payload = t.dir / "payload/probe"; + exe(payload); + const auto second = rf::commit(scope, {.links = {{"usr/bin/probe", payload, "test"}}}, "second"); + ASSERT_TRUE(second) << second.error(); + ASSERT_NE(*first, *second); + EXPECT_EQ(rf::running_projection(machine, scope), *first); + const auto other = t.dir / "home/subos/other"; + ASSERT_TRUE(rf::commit(other, {}, "other")); + EXPECT_FALSE(rf::running_projection(machine, other)); + fs::remove(machine / "usr"); + fs::create_directory_symlink(scope / "root/usr", machine / "usr"); + EXPECT_EQ(rf::running_projection(machine, scope), *second); + fs::remove(machine / "usr"); + fs::create_directory(machine / "usr"); + EXPECT_FALSE(rf::running_projection(machine, scope)); +} diff --git a/tests/unit/test_subos_spec.cpp b/tests/unit/test_subos_spec.cpp new file mode 100644 index 000000000..8e36685d9 --- /dev/null +++ b/tests/unit/test_subos_spec.cpp @@ -0,0 +1,327 @@ +// The spec compiler and its providers (C8): Policy + HomeView + Caps -> +// SandboxSpec -> argv. Pure data, so isolation is checked here without a +// sandbox. The Legacy goldens are the argv the sandbox code produced before +// the compiler existed, byte for byte: introducing the compiler changes no +// behaviour, and every later change to a default shows up as a diff here. +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.libs.json; +import xlings.subos.home_view; +import xlings.subos.policy; +import xlings.subos.caps; +import xlings.subos.spec; +import xlings.confine.provider; +import xlings.confine; +import xlings.confine.gates; + +namespace sp = xlings::subos::spec; +namespace pv = xlings::confine::provider; +namespace pol = xlings::subos::policy; +namespace caps = xlings::subos::caps; +namespace gates = xlings::confine::gates; +using xlings::subos::HomeView; +using V = std::vector; + +namespace { + +const HomeView kHome{ .home = "/h" }; + +caps::Caps linux_caps(bool bwrap_ok = true, bool proot = true) { + caps::Caps c; + c.platform = "linux"; + c.bwrap = caps::Backend{ .name = "bwrap", .bin = "/h/data/xpkgs/xim-x-bwrap/0.11.2/bin/bwrap", + .source = "payload", .usable = bwrap_ok, + .probe_output = bwrap_ok ? "" : "setting up uid map: Permission denied" }; + if (proot) c.proot = caps::Backend{ .name = "proot", .bin = "/h/data/xpkgs/xim-x-proot/5.4.0/bin/proot", + .source = "payload", .usable = true }; + c.userns = bwrap_ok; + return c; +} + +// A host with every userland path but no GPU. +sp::Request request(V argv = {}) { + return sp::Request{ + .instance = "box", + .instance_dir = "/h/subos/box", + .user = "u", + .argv = std::move(argv), + .shell = "/bin/bash", + .host_exists = [](std::string_view p) { + return !p.starts_with("/dev/nvidia") && p != "/dev/dri" && p != "/dev/dxg" + && p != "/etc/pki"; + }, + }; +} + +const V kLegacyBinds{ + "--ro-bind", "/usr", "/usr", + "--ro-bind", "/bin", "/bin", + "--ro-bind", "/usr/lib", "/lib", + "--ro-bind", "/usr/lib64", "/lib64", + "--ro-bind", "/etc/resolv.conf", "/etc/resolv.conf", + "--ro-bind", "/etc/ld.so.cache", "/etc/ld.so.cache", + "--ro-bind", "/etc/ssl", "/etc/ssl", + "--ro-bind", "/etc/alternatives", "/etc/alternatives", + "--ro-bind", "/etc/localtime", "/etc/localtime", +}; + +V concat(std::initializer_list parts) { + V out; + for (auto& p : parts) out.insert(out.end(), p.begin(), p.end()); + return out; +} + +} // namespace + +XTEST(SubosSpec, LegacyBwrapArgvIsTheOldOnePlusTheS0Fixes, + .area = "subos", .covers = {"ISO-SPEC-GOLDEN", "COMPAT-UNDECLARED"}) { + auto spec = xlings::confine::compile(pol::legacy(), kHome, linux_caps(), request({"/bin/bash", "-c", "true"})); + ASSERT_TRUE(spec.has_value()); + EXPECT_EQ(spec->backend, sp::Backend::Bwrap); + auto expected = concat({ + {"/h/data/xpkgs/xim-x-bwrap/0.11.2/bin/bwrap", + "--unshare-pid", "--unshare-ipc", "--unshare-uts", "--die-with-parent", "--new-session", + "--dev", "/dev", "--proc", "/proc"}, + kLegacyBinds, + {"--bind", "/h/subos/box/home", "/home", + "--bind", "/h/subos/box/tmp", "/tmp", + // S0 (#640 F1, F6): the home read-only, only this instance writable, + // other instances, the audit, sockets and host facts covered. + "--ro-bind", "/h", "/h", + "--tmpfs", "/h/subos", + "--bind", "/h/subos/box", "/h/subos/box", + "--tmpfs", "/h/logs", + "--tmpfs", "/h/run", + "--tmpfs", "/h/state", + "--bind", "/h/subos/box/etc/passwd", "/etc/passwd", + "--bind", "/h/subos/box/etc/group", "/etc/group", + "--bind", "/h/subos/box/etc/hosts", "/etc/hosts", + "--bind", "/h/subos/box/etc/nsswitch.conf", "/etc/nsswitch.conf", + "--chdir", "/home/u", "--", "/bin/bash", "-c", "true"}, + }); + EXPECT_EQ(pv::bwrap_argv(*spec), expected); + EXPECT_TRUE(spec->clear_env); // S0: an allow-list, not inheritance + EXPECT_EQ(spec->env.at("HOME"), "/home/u"); + EXPECT_EQ(spec->env.at("PATH"), "/h/subos/box/bin:/h/bin:/usr/local/bin:/usr/bin:/bin:/run/xlings"); + EXPECT_EQ(spec->env.at("XLINGS_SUBOS_MODE"), "sandbox"); +} + +XTEST(SubosSpec, LegacyInteractiveShellGetsDashIOnlyOnATerminal, + .area = "subos", .covers = {"ISO-SPEC-GOLDEN"}) { + auto r = request(); + r.interactive = true; + auto spec = xlings::confine::compile(pol::legacy(), kHome, linux_caps(), r); + ASSERT_TRUE(spec.has_value()); + EXPECT_EQ(spec->argv, (V{"/bin/bash", "-i"})); + // A terminal keeps its job control: no new session, the seccomp filter instead. + EXPECT_FALSE(spec->new_session); + EXPECT_TRUE(spec->block_tiocsti); + auto a = pv::bwrap_argv(*spec, 7); + EXPECT_NE(std::ranges::search(a, V{"--seccomp", "7"}).begin(), a.end()); + r.interactive = false; + EXPECT_EQ(xlings::confine::compile(pol::legacy(), kHome, linux_caps(), r)->argv, (V{"/bin/bash"})); +} + +XTEST(SubosSpec, LegacyTmpfsAndImageStorage, + .area = "subos", .covers = {"ISO-SPEC-GOLDEN"}) { + auto r = request({"/bin/bash", "-c", "true"}); + r.storage = sp::Storage::Tmpfs; + auto t = pv::bwrap_argv(*xlings::confine::compile(pol::legacy(), kHome, linux_caps(), r)); + const V tail{"--tmpfs", "/home/u", "--tmpfs", "/tmp", + "--chdir", "/home/u", "--", "/bin/bash", "-c", "true"}; + ASSERT_GE(t.size(), tail.size()); + EXPECT_EQ(V(t.end() - static_cast(tail.size()), t.end()), tail); + EXPECT_EQ(std::ranges::find(t, "/h/subos/box/home"), t.end()) + << "tmpfs storage must not bind the shared home"; + + r.storage = sp::Storage::Image; + r.image_mountpoint = "/h/subos/box/.mountpoint"; + auto i = pv::bwrap_argv(*xlings::confine::compile(pol::legacy(), kHome, linux_caps(), r)); + EXPECT_NE(std::ranges::search(i, V{"--bind", "/h/subos/box/.mountpoint", "/home"}).begin(), i.end()); + EXPECT_NE(std::ranges::search(i, V{"--tmpfs", "/tmp"}).begin(), i.end()); +} + +XTEST(SubosSpec, LegacyProotArgvIsUnchanged, + .area = "subos", .covers = {"ISO-SPEC-GOLDEN"}) { + auto spec = xlings::confine::compile(pol::legacy(), kHome, linux_caps(/*bwrap_ok=*/false), + request({"/bin/bash", "-c", "true"})); + ASSERT_TRUE(spec.has_value()); + EXPECT_EQ(spec->backend, sp::Backend::Proot); + V expected{"/h/data/xpkgs/xim-x-proot/5.4.0/bin/proot", "-r", "/h/subos/box/sandbox-root", + "--bind=/proc:/proc", "--bind=/sys:/sys", "--bind=/dev:/dev", + "--bind=/usr:/usr", "--bind=/bin:/bin", "--bind=/usr/lib:/lib", + "--bind=/usr/lib64:/lib64", "--bind=/etc/resolv.conf:/etc/resolv.conf", + "--bind=/etc/ld.so.cache:/etc/ld.so.cache", "--bind=/etc/ssl:/etc/ssl", + "--bind=/etc/alternatives:/etc/alternatives", "--bind=/etc/localtime:/etc/localtime", + "--bind=/h/subos/box/home:/home", "--bind=/h/subos/box/tmp:/tmp", "--bind=/h:/h", + "--bind=/h/subos/box/etc/passwd:/etc/passwd", "--bind=/h/subos/box/etc/group:/etc/group", + "--bind=/h/subos/box/etc/hosts:/etc/hosts", + "--bind=/h/subos/box/etc/nsswitch.conf:/etc/nsswitch.conf", + "--cwd=/home/u", "/bin/bash", "-c", "true"}; + EXPECT_EQ(pv::proot_argv(*spec), expected); + EXPECT_EQ(spec->env.at("PROOT_NO_SECCOMP"), "1"); +} + +XTEST(SubosSpec, BackendRefusalsSayWhatIsMissingAndHowToFixIt, + .area = "subos", .covers = {"ISO-MUST-SHOULD"}) { + caps::Caps none; + none.platform = "linux"; + auto r = xlings::confine::compile(pol::legacy(), kHome, none, request()); + ASSERT_FALSE(r.has_value()); + EXPECT_EQ(r.error().missing.front().dimension, "backend"); + EXPECT_EQ(r.error().missing.front().need, pol::Need::Must); + + auto req = request(); + req.preferred = sp::Backend::Bwrap; + auto b = xlings::confine::compile(pol::legacy(), kHome, linux_caps(/*bwrap_ok=*/false), req); + ASSERT_FALSE(b.has_value()); + EXPECT_NE(b.error().missing.front().reason.find("uid map"), std::string::npos) + << "the probe's own words, not a guess"; + + auto tmpfs = request(); + tmpfs.storage = sp::Storage::Tmpfs; + auto t = xlings::confine::compile(pol::legacy(), kHome, linux_caps(/*bwrap_ok=*/false), tmpfs); + ASSERT_FALSE(t.has_value()); + EXPECT_EQ(t.error().missing.front().dimension, "storage"); +} + +XTEST(SubosSpec, GpuGrantAddsOnlyTheDevicesThatExist, + .area = "subos", .covers = {"ISO-GRANTS"}) { + auto r = request(); + r.grants.insert("gpu"); + r.host_exists = [](std::string_view p) { return p == "/dev/nvidiactl" || p == "/dev/dri" || p == "/usr"; }; + auto a = pv::bwrap_argv(*xlings::confine::compile(pol::legacy(), kHome, linux_caps(), r)); + EXPECT_NE(std::ranges::search(a, V{"--dev-bind", "/dev/nvidiactl", "/dev/nvidiactl"}).begin(), a.end()); + EXPECT_NE(std::ranges::search(a, V{"--dev-bind", "/dev/dri", "/dev/dri"}).begin(), a.end()); + EXPECT_EQ(std::ranges::search(a, V{"--dev-bind", "/dev/nvidia0", "/dev/nvidia0"}).begin(), a.end()); + EXPECT_NE(std::ranges::search(a, V{"--ro-bind", "/sys", "/sys"}).begin(), a.end()); +} + +XTEST(SubosSpec, HomeRedirectOnMacosAndWindows, + .area = "subos", .covers = {"PLAT-HINT"}) { + caps::Caps mac; + mac.platform = "macos"; + auto m = xlings::confine::compile(pol::legacy(), kHome, mac, request()); + ASSERT_TRUE(m.has_value()); + EXPECT_EQ(m->backend, sp::Backend::HomeRedirect); + EXPECT_EQ(m->env.at("HOME"), "/h/subos/box/home/u"); + EXPECT_EQ(m->env.at("TMPDIR"), "/h/subos/box/tmp"); + + caps::Caps win; + win.platform = "windows"; + auto w = xlings::confine::compile(pol::legacy(), kHome, win, request()); + ASSERT_TRUE(w.has_value()); + EXPECT_TRUE(w->env.contains("USERPROFILE")); + EXPECT_FALSE(w->env.contains("HOME")); +} + +XTEST(SubosSpec, DescribeCarriesEnvironmentNamesNeverValues, + .area = "subos", .covers = {"OBS-REDACT"}) { + auto r = request(); + r.host_env = {{"GITHUB_TOKEN", "ghp_secret"}, {"LANG", "C.UTF-8"}}; + auto p = pol::legacy(); + p.env_inherit = false; + auto spec = xlings::confine::compile(p, kHome, linux_caps(), r); + ASSERT_TRUE(spec.has_value()); + auto text = spec->describe().dump(); + EXPECT_EQ(text.find("ghp_secret"), std::string::npos); + EXPECT_NE(text.find("LANG"), std::string::npos); + EXPECT_FALSE(spec->env.contains("GITHUB_TOKEN")); +} + +XTEST(SubosSpec, ThePlatformMatrixIsMeasured, + .area = "subos", .covers = {"PLAT-STATUS-MATRIX"}) { + auto find = [](const std::vector& v, std::string_view g) { + return *std::ranges::find(v, g, &gates::Status::gate); + }; + auto ok = gates::probe(linux_caps()); + EXPECT_EQ(find(ok, "FsGate").enforced, gates::Enforced::Kernel); + auto proot_only = gates::probe(linux_caps(/*bwrap_ok=*/false)); + EXPECT_EQ(find(proot_only, "FsGate").enforced, gates::Enforced::Advisory); + caps::Caps mac; + mac.platform = "macos"; + auto m = gates::probe(mac); + EXPECT_EQ(m.size(), 8u); + EXPECT_EQ(find(m, "FsGate").enforced, gates::Enforced::Advisory); + EXPECT_FALSE(find(m, "NetGate").supported); + EXPECT_FALSE(find(m, "NetGate").route.empty()); +} + +XTEST(SubosSpec, TheEnvironmentIsAnAllowList, + .area = "subos", .covers = {"F3"}) { + auto r = request(); + r.host_env = {{"GITHUB_TOKEN", "x"}, {"SSH_AUTH_SOCK", "/run/a"}, {"XAUTHORITY", "/x"}, + {"DBUS_SESSION_BUS_ADDRESS", "unix:"}, {"LANG", "C.UTF-8"}, {"LC_ALL", "C"}, + {"EDITOR", "vi"}, {"https_proxy", "http://p"}, {"XLINGS_AGENT_MODE", "1"}}; + auto spec = xlings::confine::compile(pol::legacy(), kHome, linux_caps(), r); + ASSERT_TRUE(spec.has_value()); + for (auto k : {"GITHUB_TOKEN", "SSH_AUTH_SOCK", "XAUTHORITY", "DBUS_SESSION_BUS_ADDRESS"}) + EXPECT_FALSE(spec->env.contains(k)) << k; + for (auto k : {"LANG", "LC_ALL", "EDITOR", "https_proxy", "XLINGS_AGENT_MODE"}) + EXPECT_TRUE(spec->env.contains(k)) << k; + EXPECT_EQ(spec->env.at("USER"), "u"); +} + +XTEST(SubosSpec, ProotSaysWhatItCannotIsolate, + .area = "subos", .covers = {"ISO-MUST-SHOULD"}) { + auto spec = xlings::confine::compile(pol::legacy(), kHome, linux_caps(/*bwrap_ok=*/false), request()); + ASSERT_TRUE(spec.has_value()); + EXPECT_FALSE(spec->unshare_pid); + EXPECT_FALSE(spec->degraded.empty()); +} + +// ── Landlock (C18): a write fence without namespaces ───────────────── + +XTEST(SubosSpec, LandlockIsAskedForAndNeedsTheKernel, + .area = "subos", .covers = {"ISO-LANDLOCK"}) { + auto c = linux_caps(false, false); + auto r = request({"make"}); + r.preferred = sp::Backend::Landlock; + auto none = xlings::confine::compile(pol::preset(pol::Preset::Dev), kHome, c, r); + ASSERT_FALSE(none); + EXPECT_EQ(none.error().missing[0].dimension, "backend"); + EXPECT_NE(none.error().missing[0].reason.find("Landlock"), std::string::npos); + + // Not chosen on its own: an unusable bwrap and no proot is still a refusal. + c.landlock_abi = 3; + EXPECT_FALSE(xlings::confine::compile(pol::preset(pol::Preset::Dev), kHome, c, request({"make"}))); +} + +XTEST(SubosSpec, LandlockWritesOnlyTheInstanceAndWhatIsMountedReadWrite, + .area = "subos", .covers = {"ISO-LANDLOCK"}) { + auto c = linux_caps(false, false); + c.landlock_abi = 3; + auto r = request({"make"}); + r.preferred = sp::Backend::Landlock; + auto policy = pol::preset(pol::Preset::Dev); + policy.mounts.push_back({.src = "/work", .dst = "", .rw = true}); + policy.mounts.push_back({.src = "/ref", .dst = "", .rw = false}); + auto s = xlings::confine::compile(policy, kHome, c, r); + ASSERT_TRUE(s) << s.error().missing[0].reason; + EXPECT_EQ(s->backend, sp::Backend::Landlock); + EXPECT_TRUE(s->mounts.empty()) << "no view: nothing is mounted"; + std::vector rw; + for (auto& p : s->landlock_rw) rw.push_back(p.generic_string()); + EXPECT_EQ(rw, (V{"/h/subos/box", "/dev", "/proc", "/work"})) << "never the host's /tmp, never /ref"; + EXPECT_EQ(s->env.at("HOME"), "/h/subos/box/home/u"); + EXPECT_EQ(s->env.at("TMPDIR"), "/h/subos/box/tmp"); + EXPECT_EQ(s->env.at("XLINGS_BROKER_SOCKET"), kHome.broker_socket("box").generic_string()); + EXPECT_EQ(s->argv, V{"make"}); + // What it does not give is said, not implied. + std::set degraded; + for (auto& d : s->degraded) degraded.insert(d.dimension); + EXPECT_TRUE(degraded.contains("fs") && degraded.contains("pid") && degraded.contains("sockets")) + << s->describe().dump(); + // session-init reads the fence from its environment. + auto env = pv::process_env(*s, {}); + EXPECT_EQ(env.at("XLINGS_SESSION_LANDLOCK_RW"), "/h/subos/box\n/dev\n/proc\n/work\n"); + + // A mapping elsewhere has no meaning without a view. + policy.mounts = {{.src = "/work", .dst = "/w", .rw = true}}; + EXPECT_FALSE(xlings::confine::compile(policy, kHome, c, r)); + // What private requires, Landlock cannot give. + EXPECT_FALSE(xlings::confine::compile(pol::preset(pol::Preset::Private), kHome, c, r)); +} diff --git a/tests/unit/test_subos_state.cpp b/tests/unit/test_subos_state.cpp new file mode 100644 index 000000000..ebb30964f --- /dev/null +++ b/tests/unit/test_subos_state.cpp @@ -0,0 +1,150 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.libs.json; +import xlings.core.subos.root; +import luban.boot; +import luban.stage0; +import xlings.subos.home_view; +import xlings.subos.rootfs; +import xlings.subos.roles; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; +namespace sr = xlings::subos_root; +namespace bt = luban::boot; +namespace st = luban::stage0; +namespace rf = xlings::subos::rootfs; +namespace rl = xlings::subos::roles; +using xlings::subos::HomeView; + +XTEST(SubosState, OnlyMissingLegacyKindDefaultsToViewAndUpdatesKeepUnknownKeys, + .area = "subos", .covers = {"STATE-STRICT", "STATE-KEEP-UNKNOWN"}) { + auto home = tk::Home::isolated("kind-state"); + const auto file = HomeView{home.dir()}.instance_file("box"); + EXPECT_EQ(sr::read_kind(home.dir(), "box").value(), rl::Kind::View); + tk::write_file(file, R"({"future":{"feature":true}})"); + EXPECT_EQ(sr::read_kind(home.dir(), "box").value(), rl::Kind::View); + ASSERT_TRUE(sr::declare_kind(home.dir(), "box", rl::Kind::Rootfs).has_value()); + const auto document = nlohmann::json::parse(tk::read_file(file)); + EXPECT_EQ(document["future"]["feature"], true); + EXPECT_EQ(sr::read_kind(home.dir(), "box").value(), rl::Kind::Rootfs); + for (auto bad : {"{broken", "[]", R"({"kind":7})", R"({"kind":null})", R"({"kind":"future-root"})"}) { + tk::write_file(file, bad); + const auto read = sr::read_kind(home.dir(), "box"); + EXPECT_FALSE(read.has_value()) << bad; + EXPECT_FALSE(sr::declare_kind(home.dir(), "box", rl::Kind::View).has_value()) << bad; + EXPECT_EQ(tk::read_file(file), bad); + } +} + +XTEST(SubosState, PresentUnreadableAndDanglingMetadataCannotBecomeMissingDefaults, + .area = "subos", .covers = {"STATE-STRICT"}) { + auto home = tk::Home::isolated("unreadable-state"); + const auto file = HomeView{home.dir()}.instance_file("box"); + fs::create_directories(file); + EXPECT_FALSE(sr::read_kind(home.dir(), "box").has_value()); + EXPECT_FALSE(sr::declare_kind(home.dir(), "box", rl::Kind::View).has_value()); + EXPECT_TRUE(fs::is_directory(file)); + fs::remove(file); + if constexpr (tk::is_posix) { + fs::create_symlink("missing-instance", file); + EXPECT_FALSE(sr::read_kind(home.dir(), "box").has_value()); + EXPECT_FALSE(sr::declare_kind(home.dir(), "box", rl::Kind::View).has_value()); + EXPECT_EQ(fs::read_symlink(file), fs::path("missing-instance")); + } + const auto boot = home.dir() / "boot.json"; + fs::create_directory(boot); + EXPECT_FALSE(bt::load(boot).has_value()); + EXPECT_FALSE(bt::save(boot, bt::Config{}).has_value()); + EXPECT_TRUE(fs::is_directory(boot)); + fs::remove(boot); + if constexpr (tk::is_posix) { + fs::create_symlink("missing-boot", boot); + EXPECT_FALSE(bt::load(boot).has_value()); + EXPECT_FALSE(bt::save(boot, bt::Config{}).has_value()); + EXPECT_EQ(fs::read_symlink(boot), fs::path("missing-boot")); + } +} + +XTEST(SubosState, BootUpdatesPreserveUnknownTopLevelAndLastFieldsFromTheCurrentFile, + .area = "subos", .covers = {"STATE-KEEP-UNKNOWN", "BOOT-ONCE-FALLBACK"}) { + auto home = tk::Home::isolated("boot-unknown"); + const auto file = home.dir() / "boot.json"; + tk::write_file(file, R"({"default":"core","fallback":"tiny","once":"trial", "future":{"v":2},"last":{"subos":"core","via":"default","good":true,"future-last":17}})"); + const auto loaded = bt::load(file); + ASSERT_TRUE(loaded.has_value()); + auto document = nlohmann::json::parse(tk::read_file(file)); + document["added-after-load"] = "keep"; + tk::write_file(file, document.dump()); + auto consumed = bt::record_boot(*loaded, {"trial", "once"}); + ASSERT_TRUE(bt::save(file, consumed).has_value()); + document = nlohmann::json::parse(tk::read_file(file)); + EXPECT_EQ(document["future"]["v"], 2); + EXPECT_EQ(document["added-after-load"], "keep"); + EXPECT_EQ(document["last"]["future-last"], 17); + EXPECT_EQ(document["last"]["subos"], "trial"); + EXPECT_TRUE(document["once"].is_null()); + ASSERT_TRUE(bt::save(file, bt::mark_good(consumed)).has_value()); + document = nlohmann::json::parse(tk::read_file(file)); + EXPECT_EQ(document["last"]["future-last"], 17); + EXPECT_EQ(document["last"]["good"], true); +} + +XTEST(SubosState, InvalidBootTypesAreRejectedAndSaveLeavesTheDocumentUntouched, + .area = "subos", .covers = {"STATE-STRICT"}) { + auto home = tk::Home::isolated("boot-invalid"); + const auto file = home.dir() / "boot.json"; + EXPECT_EQ(bt::load(file)->default_entry, "default"); + for (auto bad : {"{broken", "[]", R"({"default":7})", R"({"fallback":null})", + R"({"once":false})", R"({"default":"../outside"})", R"({"tries":[]})", + R"({"tries":{"box":-1}})", R"({"tries":{"box":1.5}})", + R"({"tries":{"box":2147483648}})", R"({"last":null})", + R"({"last":{"subos":7}})", R"({"last":{"good":"true"}})", + R"({"last":{"via":"unknown"}})"}) { + tk::write_file(file, bad); + EXPECT_FALSE(bt::load(file).has_value()) << bad; + EXPECT_FALSE(bt::save(file, bt::Config{}).has_value()) << bad; + EXPECT_EQ(tk::read_file(file), bad); + } +} + +XTEST(SubosState, StageZeroRefusesInvalidAnchorsAndDeclaredInitInsteadOfGuessing, + .area = "subos", .covers = {"STATE-STRICT", "BOOT-STAGE0"}) { + if constexpr (!tk::is_posix) GTEST_SKIP() << "stage-0 is a Linux boot boundary"; + auto home = tk::Home::isolated("stage0-state"); + const auto anchor = home.root() / "anchor.json"; + EXPECT_EQ(st::read_home_anchor(anchor).value(), fs::path("/xlings")); + for (auto bad : {"{broken", "[]", "{}", R"({"home":7})", R"({"home":""})", + R"({"home":"relative"})", R"({"home":"/xlings/../other"})"}) { + tk::write_file(anchor, bad); + EXPECT_FALSE(st::read_home_anchor(anchor).has_value()) << bad; + } + tk::write_file(anchor, R"({"home":"/xlings","future":true})"); + EXPECT_EQ(st::read_home_anchor(anchor).value(), fs::path("/xlings")); + fs::remove(anchor); + fs::create_symlink("missing-anchor", anchor); + EXPECT_FALSE(st::read_home_anchor(anchor).has_value()); + + const HomeView view{home.dir()}; + const auto init = home.root() / "init"; + tk::write_file(init, "#!/bin/sh\n"); + fs::permissions(init, fs::perms::owner_all); + const rf::Plan plan{{{"usr/bin/init", init, "test"}}, {}}; + ASSERT_TRUE(rf::commit(view.instance("box"), plan, "test init").has_value()); + EXPECT_EQ(st::init_of(view, "box").value(), std::optional{"/sbin/init"}); + for (auto bad : {"{broken", "[]", R"({"init":7})", R"({"init":""})", + R"({"init":"relative"})", R"({"init":"/usr/../bin/init"})"}) { + tk::write_file(view.instance_file("box"), bad); + EXPECT_FALSE(st::init_of(view, "box").has_value()) << bad; + } + tk::write_file(view.instance_file("box"), R"({"init":"/usr/bin/init"})"); + EXPECT_EQ(st::init_of(view, "box").value(), std::optional{"/usr/bin/init"}); + fs::remove(init); + EXPECT_FALSE(st::init_of(view, "box").has_value()) << "a dangling declared init is unreadable, not absent"; + fs::remove(view.instance_file("box")); + fs::create_symlink("missing-instance", view.instance_file("box")); + EXPECT_FALSE(st::init_of(view, "box").has_value()); +} diff --git a/tests/unit/test_subos_tools.cpp b/tests/unit/test_subos_tools.cpp new file mode 100644 index 000000000..de865145e --- /dev/null +++ b/tests/unit/test_subos_tools.cpp @@ -0,0 +1,95 @@ +// Every external program the SubOS code runs comes from one table +// (xlings.subos.tools), and a tarball is written in-process (part 3 §6.4). +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.subos.home_view; +import xlings.subos.ports; +import xlings.subos.tools; +import xlings.core.xim.extract; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; +namespace tools = xlings::subos::tools; +using xlings::subos::HomeView; +using xlings::subos::Ports; + +XTEST(SubosTools, APayloadComesFromTheHomeAndAnUnknownNameFromNowhere, + .area = "subos", .covers = {"TOOL-RESOLVE"}, .requires_ = {"posix"}) { + auto home = tk::Home::isolated("tools-payload"); + const HomeView view{home.dir()}; + EXPECT_TRUE(tools::candidates("no-such-tool", view, Ports{}).empty()); + EXPECT_EQ(tools::install_hint("pasta"), "xlings install passt"); + + const auto pasta = home.dir() / "data/xpkgs/xim-x-passt/2025.01/bin/pasta"; + tk::write_file(pasta, "#!/bin/sh\n"); + auto found = tools::first("pasta", view, Ports{}); + ASSERT_TRUE(found); + EXPECT_EQ(found->bin, pasta); + EXPECT_EQ(found->source, tools::Source::Payload) << "the home's payload before the machine's"; + EXPECT_EQ(tools::to_string(found->source), "payload"); + + // mkfs.ext4 lives in sbin in e2fsprogs. + const auto mkfs = home.dir() / "data/xpkgs/xim-x-e2fsprogs/1.47/sbin/mkfs.ext4"; + tk::write_file(mkfs, "#!/bin/sh\n"); + auto fs_tool = tools::first("mkfs.ext4", view, Ports{}); + ASSERT_TRUE(fs_tool); + EXPECT_EQ(fs_tool->bin, mkfs); +} + +XTEST(SubosTools, AMachineBinaryThatIsAnotherHomesShimIsNotATool, + .area = "subos", .covers = {"TOOL-RESOLVE"}, .requires_ = {"linux"}) { + auto home = tk::Home::isolated("tools-shim"); + const HomeView view{home.dir()}; + auto machine = tools::candidates("cp", view, Ports{}); + if (machine.empty()) GTEST_SKIP() << "no /bin/cp or /usr/bin/cp on this machine"; + EXPECT_EQ(machine.front().source, tools::Source::Host); + EXPECT_EQ(tools::to_string(machine.front().source), "system"); + Ports shims; + shims.shim_owner = [](const fs::path&) -> std::optional { return fs::path("/elsewhere"); }; + EXPECT_TRUE(tools::candidates("cp", view, shims).empty()); +} + +XTEST(SubosTools, ATarballIsWrittenInProcessWithLinksKeptAndRootOwnedEntries, + .area = "subos", .covers = {"TOOL-RESOLVE"}, .requires_ = {"posix"}) { + auto home = tk::Home::isolated("tools-tar"); + const auto tree = home.root() / "tree"; + tk::write_file(tree / "usr/bin/hello", "#!/bin/sh\necho hello\n"); + fs::create_directory_symlink("usr/bin", tree / "bin"); + tk::write_file(tree / "etc/hostname", "luban\n"); + const auto archive = home.root() / "out.tar.gz"; + auto written = xlings::xim::write_tar_gz(tree, archive, xlings::xim::ArchiveOwner::Root); + ASSERT_TRUE(written) << written.error(); + + const auto back = home.root() / "back"; + auto extracted = xlings::xim::extract_archive(archive, back); + ASSERT_TRUE(extracted) << extracted.error(); + EXPECT_EQ(tk::read_file(back / "usr/bin/hello"), "#!/bin/sh\necho hello\n"); + EXPECT_TRUE(fs::is_symlink(back / "bin")) << "a link is stored as a link"; + EXPECT_EQ(fs::read_symlink(back / "bin"), fs::path("usr/bin")); + + // Who the entries belong to, as the tar format records it: 0/0. + const fs::path tar = fs::exists("/usr/bin/tar") ? "/usr/bin/tar" : "/bin/tar"; + if (fs::exists(tar)) { + auto listed = tk::run({.argv = {tar.string(), "--numeric-owner", "-tvzf", archive.string()}, + .env = {{"PATH", "/usr/bin:/bin"}}}); + ASSERT_EQ(listed.exit_code, 0) << listed.transcript(); + std::istringstream lines(listed.out); + int entries = 0; + for (std::string line; std::getline(lines, line);) { + if (line.empty()) continue; + ++entries; + // GNU tar: "-rw-r--r-- 0/0 22 ..."; bsdtar (macOS): "-rw-r--r-- 0 0 0 22 ..." + // (link count, uid, gid). + std::istringstream fields(line); + std::vector f; + for (std::string w; fields >> w && f.size() < 4;) f.push_back(w); + ASSERT_GE(f.size(), 3u) << line; + if (f[1].find('/') != std::string::npos) EXPECT_EQ(f[1], "0/0") << line; + else EXPECT_TRUE(f.size() == 4 && f[2] == "0" && f[3] == "0") << line; + } + EXPECT_GE(entries, 5); + } +} diff --git a/tests/unit/test_sysroot_assets.cpp b/tests/unit/test_sysroot_assets.cpp index 6073076ba..0225a9d57 100644 --- a/tests/unit/test_sysroot_assets.cpp +++ b/tests/unit/test_sysroot_assets.cpp @@ -19,6 +19,7 @@ import xlings.core.config; import xlings.core.xvm.types; import xlings.core.xvm.bindings; import xlings.core.xvm.commands; +import xlings.core.xvm.materialize; import xlings.core.xvm.switch_plan; import xlings.core.xvm.removal; import xlings.core.xim.installer; @@ -251,11 +252,12 @@ TEST(ReclaimDeclaredAssets, RemovesWhatNothingActiveDeclares) { VersionDB db; // the record is gone: this is a full uninstall xlings::xvm::reclaim_declared_assets( - subos, root / "store", {"usr/include/demo/a.h"}, db, Workspace{}); + subos, root / "store", {"usr/include/demo/a.h"}, db, Workspace{}, + std::vector{{payload / "include/a.h", subos / "usr/include/demo/a.h"}}); EXPECT_FALSE(present_(subos / "usr" / "include" / "demo" / "a.h")); - // The directory only existed to hold it. - EXPECT_FALSE(fs::exists(subos / "usr" / "include" / "demo")); + // Only the old leaf was proved owned; its real parent is retained. + EXPECT_TRUE(fs::exists(subos / "usr" / "include" / "demo")); // The sysroot's own shape stays: a compiler configured with --sysroot // cares that this exists. EXPECT_TRUE(fs::exists(subos / "usr" / "include")); @@ -299,7 +301,8 @@ TEST(ReclaimDeclaredAssets, RePointsADestinationAnotherActiveReleaseDeclares) { {"staying.files.1", "2.0.0"}}; xlings::xvm::reclaim_declared_assets( - subos, storeRoot, {"usr/include/scsi.h"}, db, activeAfter); + subos, storeRoot, {"usr/include/scsi.h"}, db, activeAfter, + std::vector{{goingPayload / "include/scsi.h", subos / "usr/include/scsi.h"}}); ASSERT_TRUE(present_(subos / "usr" / "include" / "scsi.h")); // What the destination now SERVES, not how it is wired. The wiring is @@ -343,7 +346,8 @@ TEST(ReclaimDeclaredAssets, RemovesWhenTheSurvivingClaimIsNotActiveHere) { {{"include/x.h", "usr/include/x.h"}}); xlings::xvm::reclaim_declared_assets( - subos, storeRoot, {"usr/include/x.h"}, db, Workspace{}); + subos, storeRoot, {"usr/include/x.h"}, db, Workspace{}, + std::vector{{payload / "include/x.h", subos / "usr/include/x.h"}}); EXPECT_FALSE(present_(subos / "usr" / "include" / "x.h")); @@ -463,7 +467,8 @@ TEST(PlaceAsset, DoesNotWriteThroughAPayloadDirectoryLink) { xlings::xvm::place_asset( (newcomer / "arriving.h").string(), - subos / "usr" / "include" / "scsi" / "arriving.h"); + subos / "usr" / "include" / "scsi" / "arriving.h", + std::vector{{owner, subos / "usr/include/scsi", true}}); EXPECT_FALSE(fs::exists(owner / "arriving.h")) << "the placement wrote into another package's payload"; @@ -555,14 +560,16 @@ TEST(CleanupRemovedFileArtifacts, ReclaimsExactlyTheRemovedFilesEntries) { // the file members are ignored by the program cleanup. const VersionDB after; - xlings::xim::cleanup_removed_xvm_file_artifacts( + const std::vector claims{ + {payload / "include" / "a.h", subos / "usr" / "include" / "going" / "a.h"}}; + ASSERT_TRUE(xlings::xim::cleanup_removed_xvm_file_artifacts( subos, storeRoot, before, after, Workspace{}, xlings::xvm::RemovalBatchResult{ .removed = {{"going", "1.0.0"}, - {"going.files.1", "1.0.0"}}}); + {"going.files.1", "1.0.0"}}}, claims)); EXPECT_FALSE(present_(subos / "usr" / "include" / "going" / "a.h")); - EXPECT_FALSE(fs::exists(subos / "usr" / "include" / "going")); + EXPECT_TRUE(fs::exists(subos / "usr" / "include" / "going")); EXPECT_TRUE(fs::exists(subos / "usr" / "include")); std::error_code ec; @@ -586,10 +593,10 @@ TEST(CleanupRemovedFileArtifacts, RefusesADestinationTheRulesWouldNotAllow) { {{"x", "usr/include/x"}}); before["going.files.1"].versions["1.0.0"].fileDst = "../outside/victim"; - xlings::xim::cleanup_removed_xvm_file_artifacts( + ASSERT_TRUE(xlings::xim::cleanup_removed_xvm_file_artifacts( subos, root / "store", before, VersionDB{}, Workspace{}, xlings::xvm::RemovalBatchResult{ - .removed = {{"going.files.1", "1.0.0"}}}); + .removed = {{"going.files.1", "1.0.0"}}})); EXPECT_TRUE(fs::exists(outside / "victim")); @@ -656,7 +663,8 @@ TEST(PlaceAsset, UnwrapsSubdirectoriesRatherThanRelinkingThem) { xlings::xvm::place_asset( (newcomer / "arriving.h").string(), - subos / "usr" / "include" / "sub" / "arriving.h"); + subos / "usr" / "include" / "sub" / "arriving.h", + std::vector{{owner, subos / "usr/include/sub", true}}); std::error_code ec; const auto deep = subos / "usr" / "include" / "sub" / "deep"; diff --git a/tests/unit/test_testkit.cpp b/tests/unit/test_testkit.cpp new file mode 100644 index 000000000..bf62f82f7 --- /dev/null +++ b/tests/unit/test_testkit.cpp @@ -0,0 +1,119 @@ +// The test library's own tests (T1). Everything else trusts these four +// answers -- which binary, which home, which environment, why it did not run +// -- so they are checked first. +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; + +XTEST(Testkit, RunReportsExitCodeAndBothStreams, + .area = "testkit", .covers = {"TK-RUN"}, .requires_ = {"posix"}) { + auto r = tk::run({ .argv = {"/bin/sh", "-c", "echo out; echo err >&2; exit 7"}, + .env = {{"PATH", "/usr/bin:/bin"}} }); + EXPECT_EQ(r.exit_code, 7) << r.transcript(); + EXPECT_EQ(r.out, "out\n"); + EXPECT_EQ(r.err, "err\n"); + EXPECT_FALSE(r.timed_out); +} + +XTEST(Testkit, RunStartsFromTheGivenEnvironmentOnly, + .area = "testkit", .covers = {"TK-HERMETIC"}, .requires_ = {"posix"}) { + // Whatever the developer's shell exports must not reach the child: this + // is the leak that made test_interface_protocol see the developer's subos. + auto r = tk::run({ .argv = {"/usr/bin/env"}, .env = {{"ONLY", "1"}} }); + EXPECT_EQ(r.out, "ONLY=1\n") << r.transcript(); +} + +XTEST(Testkit, RunKillsATimedOutProcessGroup, + .area = "testkit", .covers = {"TK-TIMEOUT"}, .requires_ = {"posix"}) { + auto r = tk::run({ .argv = {"/bin/sh", "-c", "sleep 30 & sleep 30"}, + .env = {{"PATH", "/usr/bin:/bin"}}, + .timeout = std::chrono::milliseconds(300) }); + EXPECT_TRUE(r.timed_out); + EXPECT_EQ(r.exit_code, -1); + EXPECT_LT(r.elapsed, std::chrono::seconds(5)); +} + +XTEST(Testkit, PtyWithNoInputExposesAProgramThatWaits, + .area = "testkit", .covers = {"TK-PTY"}, .requires_ = {"pty"}) { + // `read` on a terminal nobody types into blocks: the agent contract scan + // relies on exactly this to catch a command that asks a question. + auto waits = tk::run({ .argv = {"/bin/sh", "-c", "read answer; echo got"}, + .env = {{"PATH", "/usr/bin:/bin"}}, + .timeout = std::chrono::milliseconds(500), .pty = true }); + EXPECT_TRUE(waits.timed_out) << waits.transcript(); + + auto answered = tk::run({ .argv = {"/bin/sh", "-c", "read answer; echo got-$answer"}, + .env = {{"PATH", "/usr/bin:/bin"}}, + .timeout = std::chrono::seconds(5), + .stdin_data = "y\n", .pty = true }); + EXPECT_FALSE(answered.timed_out); + EXPECT_NE(answered.out.find("got-y"), std::string::npos) << answered.transcript(); +} + +XTEST(Testkit, IsolatedHomeIsOutsideTheUsersHomeAndIsRemoved, + .area = "testkit", .covers = {"TK-HOME"}) { + fs::path root; + { + auto home = tk::Home::isolated("self"); + root = home.root(); + EXPECT_TRUE(fs::is_directory(home.dir())); + EXPECT_EQ(home.dir(), home.root() / ".xlings"); + if (const char* h = std::getenv("HOME"); h && *h) { + auto real = fs::path(h).lexically_normal().string(); + EXPECT_FALSE(home.root().string().starts_with(real + "/.xlings")); + } + auto env = home.env(); + EXPECT_EQ(env["XLINGS_HOME"], home.dir().string()); + EXPECT_FALSE(env.contains("XLINGS_ACTIVE_SUBOS")); + EXPECT_FALSE(env.contains("XLINGS_SUBOS_MODE")); + } + EXPECT_FALSE(fs::exists(root)); +} + +XTEST(Testkit, UnknownCapabilityIsAFailureNotASkip, + .area = "testkit", .covers = {"TK-CAPS"}) { + auto verdict = tk::check_requirements({ .requires_ = {"bwarp"} }); + ASSERT_TRUE(verdict.has_value()); + EXPECT_TRUE(verdict->fail); + EXPECT_NE(verdict->reason.find("unknown capability"), std::string::npos); +} + +XTEST(Testkit, NetworkIsOptIn, + .area = "testkit", .covers = {"TK-CAPS"}) { + if (tk::lane_declares("network") || std::getenv("XDEV_NETWORK")) GTEST_SKIP() << "lane opts in"; + auto verdict = tk::check_requirements({ .requires_ = {"network"} }); + ASSERT_TRUE(verdict.has_value()); + EXPECT_FALSE(verdict->fail); +} + +XTEST(Testkit, MetadataIsRegisteredBeforeAnyTestRuns, + .area = "testkit", .covers = {"TK-META"}) { + const auto& reg = tk::registry(); + auto it = reg.find("Testkit.RunReportsExitCodeAndBothStreams"); + ASSERT_NE(it, reg.end()); + EXPECT_EQ(it->second.area, "testkit"); + EXPECT_EQ(it->second.covers, std::vector{"TK-RUN"}); +} + +XTEST(Testkit, ALaneThatDeclaresACapabilityFailsWhenItIsMissing, + .area = "testkit", .covers = {"F13"}) { + // The rule that replaces "skip when the backend is missing" (F13): a skip + // on a developer machine, a failure on the lane that promised it. + const tk::Meta needs_other_os{ .requires_ = {tk::is_windows ? "linux" : "windows"} }; + const std::vector nothing; + const std::vector promised{"linux", "windows", "macos"}; + + auto dev = tk::check_requirements(needs_other_os, nothing); + ASSERT_TRUE(dev.has_value()); + EXPECT_FALSE(dev->fail) << dev->reason; + + auto lane = tk::check_requirements(needs_other_os, promised); + ASSERT_TRUE(lane.has_value()); + EXPECT_TRUE(lane->fail); + EXPECT_NE(lane->reason.find("declares"), std::string::npos) << lane->reason; +} diff --git a/tests/unit/test_xim_catalog.cpp b/tests/unit/test_xim_catalog.cpp index 777254702..c4e5116e4 100644 --- a/tests/unit/test_xim_catalog.cpp +++ b/tests/unit/test_xim_catalog.cpp @@ -47,7 +47,7 @@ import xlings.platform.target; import xlings.libs.json; import xlings.core.xself; import xlings.core.profile; -import xlings.core.subos.gpu; +import xlings.subos.gpu; import xlings.core.xim.downloader; import xlings.runtime; import xlings.capabilities; diff --git a/tests/unit/test_xim_downloader.cpp b/tests/unit/test_xim_downloader.cpp index 5fdedaee5..abf5af9c6 100644 --- a/tests/unit/test_xim_downloader.cpp +++ b/tests/unit/test_xim_downloader.cpp @@ -44,7 +44,7 @@ import xlings.platform; import xlings.libs.json; import xlings.core.xself; import xlings.core.profile; -import xlings.core.subos.gpu; +import xlings.subos.gpu; import xlings.core.xim.downloader; import xlings.runtime; import xlings.capabilities; diff --git a/tests/unit/test_xim_install.cpp b/tests/unit/test_xim_install.cpp index e9e8033e2..c48b0cdfd 100644 --- a/tests/unit/test_xim_install.cpp +++ b/tests/unit/test_xim_install.cpp @@ -46,7 +46,7 @@ import xlings.platform; import xlings.libs.json; import xlings.core.xself; import xlings.core.profile; -import xlings.core.subos.gpu; +import xlings.subos.gpu; import xlings.core.xim.downloader; import xlings.runtime; import xlings.capabilities; diff --git a/tests/unit/test_xpkg_spec.cpp b/tests/unit/test_xpkg_spec.cpp index 725e592fd..c550bf47e 100644 --- a/tests/unit/test_xpkg_spec.cpp +++ b/tests/unit/test_xpkg_spec.cpp @@ -44,7 +44,7 @@ import xlings.platform; import xlings.libs.json; import xlings.core.xself; import xlings.core.profile; -import xlings.core.subos.gpu; +import xlings.subos.gpu; import xlings.core.xim.downloader; import xlings.runtime; import xlings.capabilities; diff --git a/tests/unit/test_xvm_bindings.cpp b/tests/unit/test_xvm_bindings.cpp index 71cfa938a..9bd1d2b8c 100644 --- a/tests/unit/test_xvm_bindings.cpp +++ b/tests/unit/test_xvm_bindings.cpp @@ -35,6 +35,7 @@ import xlings.core.xim.catalog; import xlings.core.xim.resolver; import xlings.core.xim.downloader; import xlings.core.xim.installer; +import xlings.core.xvm.materialize; import xlings.core.xim.commands; import xlings.core.xim.repo; import xlings.core.xim.extract; @@ -57,7 +58,7 @@ import xlings.platform; import xlings.libs.json; import xlings.core.xself; import xlings.core.profile; -import xlings.core.subos.gpu; +import xlings.subos.gpu; import xlings.core.xim.downloader; import xlings.runtime; import xlings.capabilities; @@ -6642,6 +6643,10 @@ int run_xvm_registration_production_child_( xlings::platform::set_env_variable( "XLINGS_ACTIVE_SUBOS", "env-scope"); xlings::platform::set_env_variable("XLINGS_PROJECT_DIR", ""); + // The child is a fresh home's global scope, whatever SubOS the test runs + // in: an inherited XLINGS_SUBOS_MODE reads as "entered", and the entry is + // then (correctly) left alone -- a result about the caller, not the code. + xlings::platform::set_env_variable("XLINGS_SUBOS_MODE", ""); xlings::platform::set_env_variable( "XDG_CONFIG_HOME", (root / "config").string()); xlings::platform::set_env_variable( @@ -6694,7 +6699,8 @@ int run_xvm_registration_production_child_( std::string name, std::string version, const fs::path& installDir, - bool useAfterInstall) { + bool useAfterInstall, + std::string namespaceName = {}) { auto executor = mcpplibs::xpkg::create_executor(recipe); if (!executor) { std::cerr << executor.error() << '\n'; @@ -6702,7 +6708,11 @@ int run_xvm_registration_production_child_( } xlings::xim::PlanNode node; node.name = std::move(name); - node.canonicalName = node.name; + node.namespaceName = std::move(namespaceName); + node.canonicalName = node.namespaceName.empty() + ? node.name : node.namespaceName + ":" + node.name; + if (!node.namespaceName.empty()) + node.storeRoot = home / "data" / "xpkgs"; node.version = std::move(version); mcpplibs::xpkg::ExecutionContext context; context.pkg_name = node.name; @@ -6795,7 +6805,7 @@ int run_xvm_registration_production_child_( selfRecipe, "xlings", "xlings-real")) { return fail(12, "failed to write self-replace recipe"); } - const auto selfPayload = payload / "self"; + const auto selfPayload = home / "data" / "xpkgs" / "xim-x-xlings" / "2.0.0"; const auto selfSource = selfPayload / "bin" / ("xlings-real" @@ -6813,10 +6823,11 @@ int run_xvm_registration_production_child_( fs::perm_options::replace); if (!run_config( selfRecipe, - "task3b-self-provider", + "xlings", "2.0.0", selfPayload, - true)) { + true, + "xim")) { return fail(14, "force-global self config hook failed"); } if (!xlings::Config::global_versions().contains("xlings") @@ -7344,8 +7355,10 @@ TEST(XimXvmRemovalArtifactTest, ASSERT_TRUE(removalResult.has_value()) << removalResult.error().message; - xlings::xim::cleanup_removed_xvm_library_artifacts( - libDir, dbBefore, db, *removalResult); + const std::vector claims{ + {oldSource, libDir / destinationName}}; + ASSERT_TRUE(xlings::xim::cleanup_removed_xvm_library_artifacts( + libDir, dbBefore, db, *removalResult, claims)); EXPECT_FALSE(fs::exists(libDir / destinationName)); ASSERT_TRUE(fs::exists(libDir / target)); @@ -7404,8 +7417,10 @@ TEST(XimXvmRemovalArtifactTest, }, }; - xlings::xim::cleanup_removed_xvm_library_artifacts( - libDir, dbBefore, {}, removalResult); + const std::vector claims{ + {sourceDir / filename, libDir / filename}}; + ASSERT_TRUE(xlings::xim::cleanup_removed_xvm_library_artifacts( + libDir, dbBefore, {}, removalResult, claims)); EXPECT_FALSE(fs::exists(libDir / filename)); std::error_code ec; @@ -7447,8 +7462,8 @@ TEST(XimXvmRemovalArtifactTest, }, }; - xlings::xim::cleanup_removed_xvm_library_artifacts( - libDir, dbBefore, currentDb, removalResult); + ASSERT_TRUE(xlings::xim::cleanup_removed_xvm_library_artifacts( + libDir, dbBefore, currentDb, removalResult)); ASSERT_TRUE(fs::exists(libDir / filename)); EXPECT_EQ( @@ -7494,8 +7509,8 @@ TEST(XimXvmRemovalArtifactTest, }, }; - xlings::xim::cleanup_removed_xvm_program_artifacts( - binDir, dbBefore, currentDb, installed, removalResult); + ASSERT_TRUE(xlings::xim::cleanup_removed_xvm_program_artifacts( + binDir, dbBefore, currentDb, installed, removalResult)); EXPECT_TRUE(fs::exists(shim)); EXPECT_EQ( @@ -7535,8 +7550,8 @@ TEST(XimXvmRemovalArtifactTest, }, }; - xlings::xim::cleanup_removed_xvm_program_artifacts( - binDir, dbBefore, {}, {}, removalResult); + ASSERT_TRUE(xlings::xim::cleanup_removed_xvm_program_artifacts( + binDir, dbBefore, {}, {}, removalResult)); EXPECT_TRUE(fs::exists(shim)); EXPECT_EQ( @@ -8358,3 +8373,31 @@ TEST(XvmExactRemovalTest, VersionlessRemovalWithTwoRecordsIsStillAmbiguous) { xlings::xvm::RemovalErrorKind::AmbiguousVersion); EXPECT_EQ(db["fd"].versions.size(), 2u); } + +TEST(XimXvmMetadataBatchTest, AnActiveOnlyLegacyBindingSurvivesAnUnrelatedPublication) { + xlings::xvm::VersionDB db; + db["gcc-specs-config"].versions["0.0.1"].path = "/shared/specs/bin"; + xlings::xvm::Workspace active{{"gcc-specs-config", "0.0.1"}}; + xlings::xvm::WorkspaceInstalled installed{{"retained", {"repo:2.0.0", "repo:1.0.0"}}}; + xlings::xim::PlanNode node; + node.name = "unrelated"; + node.version = "1.0.0"; + node.namespaceName = "fixture"; + node.canonicalName = "fixture:unrelated"; + const std::vector operations{ + {.op = "add", .name = "unrelated", .bindir = "/shared/unrelated/bin"}}; + const auto plan = xlings::xim::normalize_xpkg_registration_plan( + node, operations, "fixture", "/data", false); + ASSERT_TRUE(plan) << plan.error().message; + const auto result = xlings::xim::apply_xpkg_xvm_metadata_batch( + db, active, installed, operations, {}, *plan); + ASSERT_TRUE(result); + EXPECT_EQ(active.at("gcc-specs-config"), "0.0.1"); + ASSERT_TRUE(installed.contains("gcc-specs-config")); + EXPECT_EQ(installed.at("gcc-specs-config"), (std::vector{"0.0.1"})); + EXPECT_EQ(installed.at("retained"), (std::vector{"repo:1.0.0", "repo:2.0.0"})); + const auto persisted = xlings::xvm::subos_workspace_from_json( + xlings::xvm::subos_workspace_to_json({.active = active, .installed = installed})); + EXPECT_EQ(persisted.active, active); + EXPECT_EQ(persisted.installed, installed); +} diff --git a/tests/unit/test_xvm_doctor.cpp b/tests/unit/test_xvm_doctor.cpp index 4c7b08b1f..5061ec60c 100644 --- a/tests/unit/test_xvm_doctor.cpp +++ b/tests/unit/test_xvm_doctor.cpp @@ -46,7 +46,7 @@ import xlings.platform; import xlings.libs.json; import xlings.core.xself; import xlings.core.profile; -import xlings.core.subos.gpu; +import xlings.subos.gpu; import xlings.core.xim.downloader; import xlings.runtime; import xlings.capabilities; diff --git a/tests/unit/test_xvm_lock.cpp b/tests/unit/test_xvm_lock.cpp index 79a20927a..af00c0753 100644 --- a/tests/unit/test_xvm_lock.cpp +++ b/tests/unit/test_xvm_lock.cpp @@ -44,7 +44,7 @@ import xlings.platform; import xlings.libs.json; import xlings.core.xself; import xlings.core.profile; -import xlings.core.subos.gpu; +import xlings.subos.gpu; import xlings.core.xim.downloader; import xlings.runtime; import xlings.capabilities; diff --git a/tests/unit/test_xvm_materialize.cpp b/tests/unit/test_xvm_materialize.cpp new file mode 100644 index 000000000..797a5b827 --- /dev/null +++ b/tests/unit/test_xvm_materialize.cpp @@ -0,0 +1,302 @@ +#include +import xlings.testkit; +#include "xlings/xtest.hpp" + +import std; +import xlings.platform; +import xlings.libs.json; +import xlings.core.xvm.types; +import xlings.core.xvm.db; +import xlings.core.xvm.materialize; +import xlings.core.xvm.switch_plan; + +namespace tk = xlings::testkit; +namespace fs = std::filesystem; +namespace m = xlings::xvm::materialize; + +namespace { +void derived(const fs::path& source, const fs::path& destination) { + if constexpr (xlings::platform::is_windows) { + if (fs::is_directory(source)) ASSERT_TRUE(xlings::platform::create_directory_link(destination.string(), source.string())); + else fs::create_hard_link(source, destination); + } else fs::create_symlink(source, destination); +} +} + +XTEST(XvmMaterialize, SelectingAContestedFileRetiresOnlyThePreviousBindingAndKeepsDirectoryClaims, + .area = "xvm", .covers = {"HOME-LAYER-RESOLVE"}) { + auto home = tk::Home::isolated("contested-file-selection"); + const auto first = home.dir() / "data/xpkgs/first/1"; + const auto next = home.dir() / "data/xpkgs/next/1"; + tk::write_file(first / "header.h", "old"); + tk::write_file(next / "header.h", "new"); + xlings::xvm::VersionDB db; + for (const auto& [target, payload] : std::map{{"old", first}, {"new", next}}) { + xlings::xvm::add_version(db, target, "1", payload.string()); + auto& data = db[target].versions["1"]; + data.kind = "files"; + data.fileSrc = "header.h"; + data.fileDst = "usr/include/header.h"; + } + xlings::xvm::Workspace active{{"old", "1"}, {"new", "1"}, {"untouched", "7"}}; + xlings::xvm::reclaim_conflicting_file_bindings(db, active, {{"new", "1"}}, home.dir().string()); + EXPECT_FALSE(active.contains("old")); + EXPECT_EQ(active.at("new"), "1"); + EXPECT_EQ(active.at("untouched"), "7"); + fs::remove(next / "header.h"); + fs::create_directory(next / "header.h"); + active["old"] = "1"; + xlings::xvm::reclaim_conflicting_file_bindings(db, active, {{"new", "1"}}, home.dir().string()); + EXPECT_EQ(active.at("old"), "1"); +} + +XTEST(XvmMaterialize, RecordedHeaderClaimsSurviveMissingSourcesButIncomingAssetsRequireThem, + .area = "xvm", .covers = {"HOME-LAYER-RESOLVE"}) { + auto home = tk::Home::isolated("asset-missing-source"); + const auto scope = home.dir() / "subos/default"; + const auto include = home.dir() / "data/xpkgs/demo/1/include"; + xlings::xvm::VersionDB db; + xlings::xvm::add_version(db, "tool", "1", include.parent_path().string()); + db["tool"].versions["1"].includedir = include.string(); + const xlings::xvm::WorkspaceInstalled installed{{"tool", {"1"}}}; + const auto recorded = m::collect_claims(db, installed, scope, scope / "lib", home.dir().string()); + ASSERT_TRUE(recorded) << recorded.error(); + ASSERT_EQ(recorded->size(), 1); + EXPECT_EQ(recorded->front().source, include); + EXPECT_TRUE(recorded->front().descendants); + EXPECT_FALSE(m::collect_claims(db, installed, scope, scope / "lib", home.dir().string(), + m::ClaimSource::Present)); +} + +XTEST(XvmMaterialize, MissingRegistrationsProveNoOldAssetsAndCannotAuthorizeAnIncomingOverwrite, + .area = "xvm", .covers = {"HOME-LAYER-RESOLVE"}) { + auto home = tk::Home::isolated("missing-registration-claims"); + const auto scope = home.dir() / "subos/default"; + const xlings::xvm::WorkspaceInstalled installed{{"missing", {"9"}}}; + const auto old = m::collect_claims({}, installed, scope, scope / "lib", home.dir().string()); + ASSERT_TRUE(old) << old.error(); + EXPECT_TRUE(old->empty()); + EXPECT_FALSE(m::collect_claims({}, installed, scope, scope / "lib", home.dir().string(), + m::ClaimSource::Present)); + const auto source = home.dir() / "data/xpkgs/demo/1/asset"; + const auto destination = scope / "usr/include/user.h"; + tk::write_file(source, "package"); + tk::write_file(destination, "user data"); + EXPECT_FALSE(m::preflight_materialization(std::vector{{source, destination}}, + *old, scope)); + EXPECT_EQ(tk::read_file(destination), "user data"); +} + +XTEST(XvmMaterialize, ReplacedPayloadRepairsProvenDanglingHeadersAndPreservesUnknownSiblings, + .area = "xvm", .covers = {"HOME-LAYER-RESOLVE"}) { + if constexpr (xlings::platform::is_windows) GTEST_SKIP() << "deleted sources cannot prove Windows hardlink ownership"; + auto home = tk::Home::isolated("asset-dangling-header"); + const auto scope = home.dir() / "subos/default"; + const auto old = home.dir() / "data/xpkgs/demo/1/gen1/include"; + const auto fresh = home.dir() / "data/xpkgs/demo/1/gen2/include"; + const auto destination = scope / "usr/include"; + tk::write_file(old / "owned.h", "old"); + tk::write_file(old / "obsolete.h", "obsolete"); + tk::write_file(fresh / "owned.h", "fresh"); + fs::create_directories(destination); + derived(old / "owned.h", destination / "owned.h"); + derived(old / "obsolete.h", destination / "obsolete.h"); + tk::write_file(destination / "user.h", "user header"); + fs::remove_all(old); + xlings::xvm::VersionDB db; + xlings::xvm::add_version(db, "tool", "1", old.parent_path().string()); + db["tool"].versions["1"].includedir = old.string(); + auto recorded = m::collect_claims(db, {{"tool", {"1"}}}, scope, scope / "lib", home.dir().string()); + ASSERT_TRUE(recorded) << recorded.error(); + const auto& claims = *recorded; + const std::vector desired{{fresh / "owned.h", destination / "owned.h"}}; + auto obsolete = m::obsolete_assets(claims, desired); + ASSERT_TRUE(obsolete) << obsolete.error(); + ASSERT_EQ(obsolete->size(), 1); + EXPECT_EQ(obsolete->front().destination, destination / "obsolete.h"); + auto changes = std::move(*obsolete); + changes.push_back({desired.front().source, desired.front().destination}); + auto prepared = m::preflight_materialization(changes, claims, scope); + ASSERT_TRUE(prepared) << prepared.error(); + auto applied = prepared->execute(); + ASSERT_TRUE(applied) << applied.error(); + ASSERT_TRUE(applied->commit()); + EXPECT_EQ(tk::read_file(destination / "owned.h"), "fresh"); + EXPECT_EQ(tk::read_file(destination / "user.h"), "user header"); + EXPECT_FALSE(fs::is_symlink(destination / "obsolete.h")); +} + +XTEST(XvmMaterialize, PreflightPreservesUnknownFilesDirectoriesAndLinks, .area = "xvm", + .covers = {"HOME-LAYER-RESOLVE"}) { + auto home = tk::Home::isolated("asset-user-data"); + const auto scope = home.dir() / "subos/default"; + const auto source = home.dir() / "data/xpkgs/demo/1/include/owned.h"; + tk::write_file(source, "incoming"); + const auto destination = scope / "usr/include/owned.h"; + tk::write_file(destination, "user file"); + const std::vector changes{{source, destination}}; + EXPECT_FALSE(m::preflight_materialization(changes, {}, scope)); + EXPECT_EQ(tk::read_file(destination), "user file"); + fs::remove(destination); + tk::write_file(destination / "keep", "user directory"); + EXPECT_FALSE(m::preflight_materialization(changes, {}, scope)); + EXPECT_EQ(tk::read_file(destination / "keep"), "user directory"); + fs::remove(destination / "keep"); + fs::remove(destination); + derived(source, destination); + const auto other = home.dir() / "data/xpkgs/demo/2/include/owned.h"; + tk::write_file(other, "replacement"); + EXPECT_FALSE(m::preflight_materialization(std::vector{{other, destination}}, {}, scope)); + EXPECT_EQ(tk::read_file(destination), "incoming"); +} + +XTEST(XvmMaterialize, ProvenReplacementCanRollbackAndCommitWithoutFixedStaging, .area = "xvm", + .covers = {"HOME-LAYER-RESOLVE"}) { + auto home = tk::Home::isolated("asset-replace"); + const auto scope = home.dir() / "subos/default"; + const auto old = home.dir() / "data/xpkgs/demo/1/file"; + const auto fresh = home.dir() / "data/xpkgs/demo/2/file"; + const auto destination = scope / "usr/share/demo/file"; + tk::write_file(old, "old"); + tk::write_file(fresh, "fresh"); + fs::create_directories(destination.parent_path()); + derived(old, destination); + tk::write_file(destination.string() + ".xlings-new/sentinel", "user staging"); + const std::vector claims{{old, destination}}; + const std::vector changes{{fresh, destination}}; + auto prepared = m::preflight_materialization(changes, claims, scope); + ASSERT_TRUE(prepared) << prepared.error(); + auto applied = prepared->execute(); + ASSERT_TRUE(applied) << applied.error(); + EXPECT_EQ(tk::read_file(destination), "fresh"); + ASSERT_EQ(applied->proofs().size(), 1); + ASSERT_TRUE(applied->rollback()); + EXPECT_EQ(tk::read_file(destination), "old"); + prepared = m::preflight_materialization(changes, claims, scope); + ASSERT_TRUE(prepared); + applied = prepared->execute(); + ASSERT_TRUE(applied) << applied.error(); + ASSERT_TRUE(applied->commit()); + EXPECT_EQ(tk::read_file(destination), "fresh"); + EXPECT_EQ(tk::read_file(destination.string() + ".xlings-new/sentinel"), "user staging"); + EXPECT_EQ(tk::read_file(old), "old"); +} + +XTEST(XvmMaterialize, PublicationConflictRestoresEarlierChanges, .area = "xvm", + .covers = {"HOME-LAYER-RESOLVE"}) { + auto home = tk::Home::isolated("asset-race"); + const auto scope = home.dir() / "subos/default"; + const auto source = home.dir() / "data/xpkgs/demo/1/file"; + tk::write_file(source, "payload"); + const auto first = scope / "usr/share/a"; + const auto second = scope / "usr/share/b"; + auto prepared = m::preflight_materialization(std::vector{{source, first}, {source, second}}, {}, scope); + ASSERT_TRUE(prepared) << prepared.error(); + tk::write_file(second, "user appeared after preflight"); + auto applied = prepared->execute(); + ASSERT_FALSE(applied); + EXPECT_FALSE(fs::exists(first)); + EXPECT_EQ(tk::read_file(second), "user appeared after preflight"); + EXPECT_EQ(tk::read_file(source), "payload"); +} + +XTEST(XvmMaterialize, DirectoryMergeAndLaterUpdateRequireTheLedger, .area = "xvm", + .covers = {"HOME-LAYER-RESOLVE"}) { + auto home = tk::Home::isolated("asset-directory"); + const auto scope = home.dir() / "subos/default"; + const auto old = home.dir() / "data/xpkgs/demo/1/include/scsi"; + const auto fresh = home.dir() / "data/xpkgs/demo/2/include/scsi"; + const auto arrival = home.dir() / "data/xpkgs/other/1/arrival.h"; + const auto destination = scope / "usr/include/scsi"; + tk::write_file(old / "deep/owned.h", "old"); + tk::write_file(old / "obsolete.h", "obsolete"); + tk::write_file(fresh / "deep/owned.h", "fresh"); + tk::write_file(arrival, "arrival"); + fs::create_directories(destination.parent_path()); + derived(old, destination); + const std::vector merge{{old, destination}, {arrival, destination / "arrival.h"}}; + EXPECT_FALSE(m::preflight_materialization(merge, {}, scope)); + std::vector claims{{old, destination, true}}; + auto prepared = m::preflight_materialization(merge, claims, scope); + ASSERT_TRUE(prepared) << prepared.error(); + auto applied = prepared->execute(); + ASSERT_TRUE(applied) << applied.error(); + ASSERT_TRUE(applied->commit()); + EXPECT_FALSE(fs::is_symlink(destination)); + EXPECT_EQ(tk::read_file(destination / "deep/owned.h"), "old"); + EXPECT_EQ(tk::read_file(destination / "arrival.h"), "arrival"); + EXPECT_FALSE(fs::exists(old / "arrival.h")); + claims.push_back({arrival, destination / "arrival.h"}); + const std::vector update{{fresh, destination}, {arrival, destination / "arrival.h"}}; + prepared = m::preflight_materialization(update, claims, scope); + ASSERT_TRUE(prepared) << prepared.error(); + applied = prepared->execute(); + ASSERT_TRUE(applied) << applied.error(); + ASSERT_TRUE(applied->commit()); + EXPECT_EQ(tk::read_file(destination / "deep/owned.h"), "fresh"); + EXPECT_FALSE(fs::exists(destination / "obsolete.h")); + EXPECT_EQ(tk::read_file(destination / "arrival.h"), "arrival"); + EXPECT_EQ(tk::read_file(old / "obsolete.h"), "obsolete"); + tk::write_file(destination / "user.h", "user"); + claims.front().source = fresh; + EXPECT_FALSE(m::preflight_materialization(update, claims, scope)); + EXPECT_EQ(tk::read_file(destination / "user.h"), "user"); +} + +XTEST(XvmMaterialize, AbandonedTransactionRestoresThePreviousFileObject, .area = "xvm", + .covers = {"HOME-LAYER-RESOLVE"}) { + auto home = tk::Home::isolated("asset-raii"); + const auto scope = home.dir() / "subos/default"; + const auto old = home.dir() / "data/xpkgs/demo/1/file"; + const auto fresh = home.dir() / "data/xpkgs/demo/2/file"; + const auto destination = scope / "usr/share/demo"; + tk::write_file(old, "same bytes"); + tk::write_file(fresh, "same bytes"); + fs::create_directories(destination.parent_path()); + derived(old, destination); + { + auto prepared = m::preflight_materialization(std::vector{{fresh, destination}}, + std::vector{{old, destination}}, scope); + ASSERT_TRUE(prepared); + auto applied = prepared->execute(); + ASSERT_TRUE(applied) << applied.error(); + EXPECT_TRUE(fs::equivalent(destination, fresh)); + } + EXPECT_TRUE(fs::equivalent(destination, old)); + fs::remove(destination); + tk::write_file(destination, "same bytes"); + EXPECT_FALSE(m::preflight_materialization(std::vector{{fresh, destination}}, + std::vector{{old, destination}}, scope)); + EXPECT_EQ(tk::read_file(destination), "same bytes"); +} + +XTEST(XvmMaterialize, UseConflictDoesNotPublishMetadataOrDeleteUserHeaders, .area = "xvm", + .covers = {"HOME-LAYER-RESOLVE"}, .requires_ = {"xlings-bin"}) { + auto home = tk::Home::isolated("asset-use-conflict"); + auto init = home.xlings({"self", "init"}); + ASSERT_EQ(init.exit_code, 0) << init.transcript(); + const auto payload = home.dir() / "data/xpkgs/demo-x-tool/1/bin"; + const auto include = payload.parent_path() / "include"; + tk::write_file(payload / "tool", "tool"); + tk::write_file(include / "shared.h", "payload header"); + xlings::xvm::VersionDB db; + xlings::xvm::add_version(db, "tool", "1", payload.string()); + db["tool"].versions["1"].includedir = include.string(); + auto config = nlohmann::json::parse(tk::read_file(home.dir() / ".xlings.json")); + config["versions"] = xlings::xvm::versions_to_json(db); + tk::write_file(home.dir() / ".xlings.json", config.dump(2)); + const auto scope = home.dir() / "subos/default"; + auto scopeConfig = nlohmann::json::parse(tk::read_file(scope / ".xlings.json")); + xlings::xvm::SubosWorkspace workspace; + workspace.installed["tool"] = {"1"}; + scopeConfig["workspace"] = xlings::xvm::subos_workspace_to_json(workspace); + scopeConfig["future"] = "preserve"; + tk::write_file(scope / ".xlings.json", scopeConfig.dump(2)); + tk::write_file(scope / "usr/include/shared.h", "user header"); + tk::write_file(scope / "usr/include/shared.h.xlings-new/sentinel", "user staging"); + auto used = home.xlings({"use", "tool@1"}); + EXPECT_NE(used.exit_code, 0) << used.transcript(); + EXPECT_EQ(tk::read_file(scope / "usr/include/shared.h"), "user header"); + EXPECT_EQ(tk::read_file(scope / "usr/include/shared.h.xlings-new/sentinel"), "user staging"); + EXPECT_EQ(nlohmann::json::parse(tk::read_file(scope / ".xlings.json")), scopeConfig); +} diff --git a/tests/unit/test_xvm_shim.cpp b/tests/unit/test_xvm_shim.cpp index d57a30c79..fdf0cbd5c 100644 --- a/tests/unit/test_xvm_shim.cpp +++ b/tests/unit/test_xvm_shim.cpp @@ -37,6 +37,7 @@ import xlings.core.xvm.lock; import xlings.core.xvm.switch_plan; import xlings.core.xvm.shim; import xlings.core.xvm.commands; +import xlings.core.xvm.materialize; import xlings.core.compact; import xlings.core.config; import xlings.core.home_config; @@ -44,7 +45,7 @@ import xlings.platform; import xlings.libs.json; import xlings.core.xself; import xlings.core.profile; -import xlings.core.subos.gpu; +import xlings.subos.gpu; import xlings.core.xim.downloader; import xlings.runtime; import xlings.capabilities; @@ -544,7 +545,10 @@ TEST_F(XvmHeaderSymlinkTest, InstallAndRemoveHeaders) { // Install headers auto sysrootInclude = testDir_ / "sysroot" / "usr" / "include"; - xlings::xvm::install_headers(srcInclude.string(), sysrootInclude); + auto installed = xlings::xvm::install_headers(srcInclude.string(), sysrootInclude); + ASSERT_TRUE(installed) << installed.error(); + std::vector claims; + for (const auto& proof : *installed) claims.push_back({proof.source, proof.destination}); // Verify links created (symlinks on Unix, hard links/copies on Windows) EXPECT_TRUE(fs::exists(sysrootInclude / "stdio.h")); @@ -556,7 +560,8 @@ TEST_F(XvmHeaderSymlinkTest, InstallAndRemoveHeaders) { #endif // Remove headers - xlings::xvm::remove_headers(srcInclude.string(), sysrootInclude); + auto removed = xlings::xvm::remove_headers(srcInclude.string(), sysrootInclude, claims); + ASSERT_TRUE(removed) << removed.error(); // Verify links removed EXPECT_FALSE(fs::exists(sysrootInclude / "stdio.h")); @@ -611,7 +616,10 @@ TEST_F(XvmHeaderSymlinkTest, InstallHeadersOverwrite) { auto sysrootInclude = testDir_ / "sysroot" / "usr" / "include"; // Install first, then overwrite with second - xlings::xvm::install_headers(srcInclude1.string(), sysrootInclude); + auto first = xlings::xvm::install_headers(srcInclude1.string(), sysrootInclude); + ASSERT_TRUE(first) << first.error(); + std::vector claims; + for (const auto& proof : *first) claims.push_back({proof.source, proof.destination}); EXPECT_TRUE(fs::exists(sysrootInclude / "common.h")); #if !defined(_WIN32) EXPECT_TRUE(fs::is_symlink(sysrootInclude / "common.h")); @@ -619,7 +627,7 @@ TEST_F(XvmHeaderSymlinkTest, InstallHeadersOverwrite) { (srcInclude1 / "common.h").string()); #endif - xlings::xvm::install_headers(srcInclude2.string(), sysrootInclude); + ASSERT_TRUE(xlings::xvm::install_headers(srcInclude2.string(), sysrootInclude, claims)); EXPECT_TRUE(fs::exists(sysrootInclude / "common.h")); #if !defined(_WIN32) EXPECT_TRUE(fs::is_symlink(sysrootInclude / "common.h")); @@ -659,17 +667,19 @@ TEST_F(XvmHeaderSymlinkTest, ADestinationPrefixDecidesWhereHeadersLand) { .destinationPrefix = "c++/15.1.0", }; - xlings::xvm::install_headers(asset, sysrootInclude); + auto installed = xlings::xvm::install_headers(asset, sysrootInclude); + ASSERT_TRUE(installed) << installed.error(); + std::vector claims; + for (const auto& proof : *installed) claims.push_back({proof.source, proof.destination}); EXPECT_TRUE(fs::exists(sysrootInclude / "c++" / "15.1.0" / "vector")); EXPECT_FALSE(fs::exists(sysrootInclude / "vector")) << "the prefix was ignored and the headers were flattened into the " "include root"; - xlings::xvm::remove_headers(asset, sysrootInclude); + ASSERT_TRUE(xlings::xvm::remove_headers(asset, sysrootInclude, claims)); EXPECT_FALSE(fs::exists(sysrootInclude / "c++" / "15.1.0" / "vector")); - // The prefix directory only ever held this release's links, so it goes - // too rather than accumulating as litter. - EXPECT_FALSE(fs::exists(sysrootInclude / "c++" / "15.1.0")); + // A leaf proof cannot prove ownership of a pre-existing directory. + EXPECT_TRUE(fs::exists(sysrootInclude / "c++" / "15.1.0")); } TEST_F(XvmHeaderSymlinkTest, APrefixDirectoryWithOtherContentSurvivesRemoval) { @@ -685,14 +695,17 @@ TEST_F(XvmHeaderSymlinkTest, APrefixDirectoryWithOtherContentSurvivesRemoval) { .sourceDir = srcInclude.string(), .destinationPrefix = "c++", }; - xlings::xvm::install_headers(asset, sysrootInclude); + auto installed = xlings::xvm::install_headers(asset, sysrootInclude); + ASSERT_TRUE(installed) << installed.error(); + std::vector claims; + for (const auto& proof : *installed) claims.push_back({proof.source, proof.destination}); // Something else lives under the same prefix -- another package's // headers, or a file the user put there. xlings::platform::write_string_to_file( (sysrootInclude / "c++" / "keep.h").string(), "/* not ours */"); - xlings::xvm::remove_headers(asset, sysrootInclude); + ASSERT_TRUE(xlings::xvm::remove_headers(asset, sysrootInclude, claims)); EXPECT_FALSE(fs::exists(sysrootInclude / "c++" / "vector")); EXPECT_TRUE(fs::exists(sysrootInclude / "c++" / "keep.h")) << "removing a header asset deleted a directory it did not own"; diff --git a/tests/unit/test_xvm_switch.cpp b/tests/unit/test_xvm_switch.cpp index e6ace8f7b..4e02bafd7 100644 --- a/tests/unit/test_xvm_switch.cpp +++ b/tests/unit/test_xvm_switch.cpp @@ -45,7 +45,7 @@ import xlings.platform; import xlings.libs.json; import xlings.core.xself; import xlings.core.profile; -import xlings.core.subos.gpu; +import xlings.subos.gpu; import xlings.core.xim.downloader; import xlings.runtime; import xlings.capabilities; diff --git a/tools/bump_index.sh b/tools/bump_index.sh index 436feb988..ff6f2a422 100755 --- a/tools/bump_index.sh +++ b/tools/bump_index.sh @@ -24,38 +24,42 @@ set -euo pipefail PROJ="${1:?usage: bump_index.sh }" VER="${2:?usage: bump_index.sh }" INDEX_REPO="${INDEX_REPO:-openxlings/xim-pkgindex}" -# NOT `bump/${PROJ}-${VER}`. The force-push below is described as idempotent, -# and with the version in the branch name that was true only for a re-run of -# the SAME version -- across versions every release opened a NEW branch and a -# NEW PR, and they stacked. -# -# Measured 2026-08-06: #503 (2026.8.5.2), #520 (2026.8.5.3) and #524 -# (2026.8.6.1) were all open at once, and the index's `latest` sat at -# 2026.8.5.1 -- two releases behind -- while every release job reported -# success. -# -# And that was only what was still OPEN. The index carries 49 stale `bump/*` -# branches -- 36 for mcpp, 12 for xlings, one for mingw-cross-gcc -- one per -# release since this script was written. Every one of them is a branch this -# code created and then abandoned. Worse, each PR's diff was generated against whatever `latest` was -# when it ran, so merging an OLDER one after a NEWER one moves the pointer -# BACKWARDS. That is not a merge-order rule anyone can be expected to -# remember; it is a mechanism that should not exist. -# -# One branch means the force-push genuinely supersedes: there is always -# exactly one open PR and it always carries the newest release. +# One branch per project keeps one open PR carrying the newest release. +# Retain every previous bump commit, merge main, and append the next bump. +# Conflicts and concurrent remote updates fail without rewriting history. BRANCH="bump/${PROJ}" TOKEN="${PKGINDEX_TOKEN:?PKGINDEX_TOKEN required (write to $INDEX_REPO)}" +export PKGINDEX_TOKEN="$TOKEN" + +# Keep credentials out of remote URLs and git's failure diagnostics. +auth_git() { + GIT_TERMINAL_PROMPT=0 git -c credential.helper= \ + -c 'credential.helper=!f() { printf "%s\n" "username=x-access-token" "password=$PKGINDEX_TOKEN"; }; f' "$@" +} info() { echo "[bump] $*"; } WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT info "cloning $INDEX_REPO" -git clone -q --depth 20 "https://x-access-token:${TOKEN}@github.com/${INDEX_REPO}.git" "$WORK/idx" +auth_git clone -q --single-branch --branch main "https://github.com/${INDEX_REPO}.git" "$WORK/idx" cd "$WORK/idx" git config user.email "ci@xlings.dev" git config user.name "xlings-ci" -git checkout -q -B "$BRANCH" +if auth_git ls-remote --exit-code --heads origin "refs/heads/$BRANCH" > /dev/null; then + auth_git fetch -q origin "refs/heads/$BRANCH:refs/remotes/origin/$BRANCH" + git checkout -q -b "$BRANCH" "origin/$BRANCH" + if ! git merge --no-edit origin/main; then + echo "[bump] FAIL: cannot merge main into $BRANCH; resolve the branch conflict explicitly." >&2 + exit 1 + fi +else + REMOTE_STATUS=$? + if [[ "$REMOTE_STATUS" != 2 ]]; then + echo "[bump] FAIL: cannot inspect remote branch $BRANCH." >&2 + exit "$REMOTE_STATUS" + fi + git checkout -q -b "$BRANCH" origin/main +fi info "version-check.py --apply --only $PROJ" python3 .github/scripts/version-check.py --workspace "$PWD" --apply --only "$PROJ" > "$WORK/report.json" || { @@ -105,11 +109,11 @@ git commit -qm "bump(${PROJ}): track ${VER} as latest Auto-generated by the ${PROJ} release pipeline (bump_index.sh). Applied via version-check.py --apply --only ${PROJ}." -info "pushing $BRANCH (force — bot branch)" -git push -q -f "https://x-access-token:${TOKEN}@github.com/${INDEX_REPO}.git" "$BRANCH" +info "pushing $BRANCH (append-only)" +auth_git push -q origin "$BRANCH" -# Open or UPDATE the PR. With one branch the force-push above genuinely -# supersedes the previous release's diff, so the PR's title and body have to +# Open or UPDATE the PR. With one branch each new commit advances the +# previous release's diff, so the PR's title and body have to # move with it -- otherwise the branch would carry 2026.8.6.1 under a title # saying 2026.8.5.3, and the record would disagree with the change it # describes. That is the same defect class this pipeline exists to avoid, one @@ -118,7 +122,7 @@ export GH_TOKEN="$TOKEN" PR_TITLE="bump(${PROJ}): track ${VER} as latest" PR_BODY="Automated by the ${PROJ} release pipeline. Bumps \`pkgs/*/${PROJ}.lua\` \`latest.ref\` to \`${VER}\` and appends every released version that the index is still missing (via \`version-check.py --apply --only ${PROJ}\`). -This branch is \`${BRANCH}\` — **not** per-version. Each release force-pushes over it, so there is always exactly one open bump PR and it always carries the newest release. Merging is therefore order-free: there is no older PR that could move \`latest\` backwards. +This branch is \`${BRANCH}\`. Each release preserves its history, merges the latest \`main\`, and appends a bump with an ordinary push. The same open PR is updated to carry the newest release; after a PR is merged, the next release opens a new one on this branch. Merge conflicts and concurrent remote updates stop the job for explicit resolution. Merge to publish into the official index — the index repo then republishes its artifact and gitee-sync mirrors the change. Binaries are already live on \`xlings-res/${PROJ}\` (GitHub + GitCode)." diff --git a/tools/lint_layer_deps.sh b/tools/lint_layer_deps.sh new file mode 100755 index 000000000..a079d381f --- /dev/null +++ b/tools/lint_layer_deps.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Lint: the package layers import downward only (SubOS design part 3 §8). +# +# luban/ a SubOS that is a machine may import modules/, never src/ +# modules/ shared by xlings and Luban may import neither luban/ nor src/ +# src/ the xlings frontend may import both +# +# A module name says where it lives: `luban.*` is under luban/, `xlings.core.*` +# (and the frontend's other top-level names) under src/. An import that points +# upward is a build that compiles today and cannot be split tomorrow -- the +# SubOS core reaching for the package manager is exactly what `Ports` exists +# to prevent, and nothing else would notice until the split. +# xtest: covers=LAYOUT-DEPS requires= +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +bad=0 +report() { echo "::error file=$1::$2"; bad=$((bad + 1)); } + +# `import x;` / `export import x;` at the start of a line, comments excluded. +imports() { + grep -rnE --include='*.cppm' --include='*.cpp' '^[[:space:]]*(export[[:space:]]+)?import[[:space:]]+[A-Za-z_.:]+' "$@" 2>/dev/null \ + | sed -E 's/^([^:]+):([0-9]+):[[:space:]]*(export[[:space:]]+)?import[[:space:]]+([A-Za-z_.]+).*/\1:\2 \4/' || true +} + +# The frontend's module roots: everything src/ exports. +frontend_roots=$(grep -rhoE --include='*.cppm' '^export module [A-Za-z_.]+' src 2>/dev/null \ + | awk '{print $3}' | sort -u) +is_frontend() { + local m="$1" + case "$m" in xlings.core|xlings.core.*) return 0 ;; esac + grep -qxF "$m" <<<"$frontend_roots" +} + +while read -r where module; do + [ -n "${module:-}" ] || continue + case "$module" in luban|luban.*) report "${where%%:*}" "modules/ imports $module ($where): modules/ is shared and may not depend on luban/" ;; esac + if is_frontend "$module"; then + report "${where%%:*}" "modules/ imports the frontend module $module ($where): pass what is needed through Ports" + fi +done < <(imports modules) + +if [ -d luban ]; then + while read -r where module; do + [ -n "${module:-}" ] || continue + if is_frontend "$module"; then + report "${where%%:*}" "luban/ imports the frontend module $module ($where): luban/ depends on modules/ only" + fi + done < <(imports luban) +fi + +if [ "$bad" -gt 0 ]; then + echo "layer dependency lint: FAIL ($bad upward import(s))" + exit 1 +fi +echo "layer dependency lint: PASS (luban -> modules, modules -/-> luban, src)" diff --git a/tools/lint_platform_branches.sh b/tools/lint_platform_branches.sh new file mode 100755 index 000000000..117551d17 --- /dev/null +++ b/tools/lint_platform_branches.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Lint: platform selection is `if constexpr`, and headers stay where they are +# used (AGENTS.md, "Platform code lives in xlings.platform"). +# +# Two rules: +# +# 1. Outside modules/platform, a preprocessor branch on the platform or the +# compiler (_WIN32, __linux__, __APPLE__, __unix__, _MSC_VER, __MINGW*) +# needs `platform-if-ok: ` on its line. The default is +# `if constexpr (platform::is_windows)`: every build then compiles every +# branch, so the Linux CI catches a typo in the Windows one. What cannot +# be a constexpr branch -- a declaration that differs per compiler, a +# module below xlings.platform -- says so in one line. +# +# 2. Inside modules/platform, an interface unit (.cppm) includes no system +# header: only the C++-wrapped C library ( and friends) that a +# declaration needs. The system headers belong to the implementation +# unit that makes the call, so they are parsed once, by one unit, and a +# partition's interface reads the same on every platform. +# +# modules/testkit and tests/ are outside the product (see +# lint_platform_headers.sh); modules/libs/src/json is vendored. +set -euo pipefail +cd "$(dirname "$0")/.." + +MARK='platform-if-ok:' +bad=0 + +branch='^[[:space:]]*#[[:space:]]*(if|elif|ifdef|ifndef)\b.*(_WIN32|__linux__|__APPLE__|__unix__|_MSC_VER|__MINGW)' +while IFS= read -r hit; do + case "$hit" in *"$MARK"*) ;; *) + echo "::error::platform #if outside modules/platform (use if constexpr, or mark '$MARK '): $hit" + bad=$((bad + 1)) ;; + esac +done < <(grep -rnE --include='*.cpp' --include='*.cppm' "$branch" src modules apps 2>/dev/null \ + | grep -v '^modules/platform/' | grep -v '^modules/testkit/' \ + | grep -v '^modules/libs/src/json/' || true) + +while IFS= read -r hit; do + echo "::error::system header in a platform interface unit (move it to the .cpp that calls it): $hit" + bad=$((bad + 1)) +done < <(grep -rnE --include='*.cppm' '^[[:space:]]*#[[:space:]]*include[[:space:]]*<' modules/platform/src 2>/dev/null \ + | grep -vE '#[[:space:]]*include[[:space:]]*' || true) + +if [ "$bad" -gt 0 ]; then + echo "platform branch lint: FAIL ($bad finding(s))" + exit 1 +fi +marked=$(grep -rn --include='*.cpp' --include='*.cppm' "$MARK" src modules apps 2>/dev/null | wc -l) +echo "platform branch lint: PASS ($marked marked #if outside modules/platform)" diff --git a/tools/lint_platform_headers.sh b/tools/lint_platform_headers.sh new file mode 100755 index 000000000..ad0c63809 --- /dev/null +++ b/tools/lint_platform_headers.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# System headers live in modules/platform (AGENTS.md "Platform code lives in +# xlings.platform"). Everything else asks it: a header here means a second, +# private copy of an OS call -- the shape this rule removes. +# +# Allowed outside it: +# - modules/testkit: the test harness, deliberately independent of the +# product's modules (a tool that measures the product must not share its +# failure modes); +# - tests/: a test may call the kernel directly to check what it does; +# - the C library headers that only carry `stdout` / `stderr` / time +# functions (, , ) -- standard C++, no OS in them. +set -euo pipefail +cd "$(dirname "$0")/.." + +pattern='#[[:space:]]*include[[:space:]]*<(unistd|fcntl|poll|sched|signal|csignal|cerrno|cstring|cstddef|dirent|pwd|grp|termios|pty|spawn|crt_externs|io|windows|winsock2|ws2tcpip|sys/[^>]+|linux/[^>]+|mach/[^>]+)(\.h)?>|#[[:space:]]*include[[:space:]]*' +hits="$(grep -rnE "$pattern" src modules apps --include='*.cpp' --include='*.cppm' \ + | grep -v '^modules/platform/' | grep -v '^modules/testkit/' || true)" +if [[ -n "$hits" ]]; then + echo "platform headers outside modules/platform:" + echo "$hits" | sed 's/^/ /' + echo "add what is needed to xlings.platform (a partition under modules/platform/src//)" + exit 1 +fi +echo "platform headers lint: PASS (system headers only in modules/platform)" diff --git a/tools/lint_subos_remove_all.sh b/tools/lint_subos_remove_all.sh index 279b62bb1..ade81e4f2 100755 --- a/tools/lint_subos_remove_all.sh +++ b/tools/lint_subos_remove_all.sh @@ -3,16 +3,17 @@ # # SubOS homes hold user data that nothing can put back (see AGENTS.md, "SubOS # user data"). The only function allowed to delete a whole subos is -# subos::userdata::delete_subos, which takes a confirm::UserConfirmed -- a value +# subos::userdata::delete_subos, which takes a guard::UserConfirmed -- a value # only an explicit user answer produces. A raw `remove_all` on a subos path # anywhere else is a second, unconfirmed way in, which is exactly how an # empty name once deleted every subos in a home (#611). # # Two rules: -# 1. in the files that manage subos lifecycles, EVERY `remove_all(` needs a +# 1. in the files that manage subos lifecycles -- including all of +# modules/subos, the SubOS core -- EVERY `remove_all(` needs a # `subos-remove-all-ok: ` marker on its line; -# 2. anywhere under src/, a `remove_all(` on a line that mentions "subos" -# needs the same marker. +# 2. anywhere under src/ or modules/, a `remove_all(` on a line that +# mentions "subos" needs the same marker. # A marker is a claim a reviewer can check, one line from the call it covers. set -euo pipefail @@ -23,12 +24,12 @@ MARK='subos-remove-all-ok:' bad=0 lifecycle_files=( - src/core/subos.cpp + src/core/subos/cmd.cpp src/core/xself/install.cpp src/core/xself/uninstall.cpp ) while IFS= read -r f; do lifecycle_files+=("$f"); done \ - < <(find src/core/subos -name '*.cpp' | sort) + < <(find src/core/subos modules/subos -name '*.cpp' -o -name '*.cppm' | sort) for f in "${lifecycle_files[@]}"; do [ -f "$f" ] || continue @@ -45,7 +46,7 @@ while IFS= read -r hit; do echo "::error::remove_all on a subos path outside the deletion entry point: $hit" bad=$((bad + 1)) ;; esac -done < <(grep -rn --include='*.cpp' --include='*.cppm' 'remove_all(' src \ +done < <(grep -rn --include='*.cpp' --include='*.cppm' 'remove_all(' src modules \ | awk '{ code = $0; sub(/^[^:]*:[0-9]+:/, "", code) if (code ~ /^[ \t]*\/\//) next if (tolower(code) ~ /subos/) print }' || true) diff --git a/tools/lint_tool_resolve.sh b/tools/lint_tool_resolve.sh new file mode 100755 index 000000000..37c45958a --- /dev/null +++ b/tools/lint_tool_resolve.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Lint: the SubOS code reaches external programs through one table +# (xlings.subos.tools) and administrator rights through one door +# (xlings.subos.elevation) -- SubOS design part 3 §6.4. +# +# In modules/subos, src/core/subos, src/core/home and luban/: +# 1. no std::system( -- a shell line quotes a path wrong the day it has a +# quote in it, and names a tool by whatever PATH says; +# 2. no argv that starts with a tool's literal name: resolve it +# (`tools::first("mkfs.ext4", ...)`), so where it came from is one +# decision and `self doctor --isolation --json` can report it; +# 3. an argv that starts with an administrator's command (mount, chown, +# install, apparmor_parser, sudo) only as the argument of +# elevation::run, which records it. +# The table itself (modules/subos/src/sandbox/tools.cpp) names the tools; a +# line that spells a name for another reason (a package list, a JSON key, an +# argv handed to elevation::run below) says so with `tool-ok: `. +# xtest: covers=TOOL-RESOLVE requires= +set -euo pipefail +cd "$(dirname "$0")/.." + +dirs=(modules/subos src/core/subos src/core/home) +[ -d luban ] && dirs+=(luban) +bad=0 +flag() { echo "::error::$1: $2"; bad=$((bad + 1)); } + +while IFS= read -r hit; do flag "shell line" "$hit"; done < <( + grep -rnE --include='*.cpp' --include='*.cppm' 'std::system\(' "${dirs[@]}" \ + | grep -vE '^[^:]+:[0-9]+:[[:space:]]*//' || true) + +tools='bwrap|proot|pasta|tar|cp|mkfs\.ext4|truncate|mountpoint|wsl|wsl\.exe' +while IFS= read -r hit; do flag "unresolved tool (use xlings.subos.tools)" "$hit"; done < <( + grep -rnE --include='*.cpp' --include='*.cppm' "\{[[:space:]]*\"($tools)\"[[:space:]]*," "${dirs[@]}" \ + | grep -v '^modules/subos/src/sandbox/tools.cpp:' | grep -v 'tool-ok:' \ + | grep -vE '^[^:]+:[0-9]+:[[:space:]]*//' || true) + +admin='mount|umount|chown|install|apparmor_parser|sudo' +while IFS= read -r hit; do flag "administrator command outside elevation::run" "$hit"; done < <( + grep -rnE --include='*.cpp' --include='*.cppm' \ + "\{[[:space:]]*\"($admin)\"[[:space:]]*,[[:space:]]*(\"-|[A-Za-z_>.-]+\.string\(\)|[a-z_]+ \+ \")" "${dirs[@]}" \ + | grep -v 'elevation::run' | grep -v 'tool-ok:' \ + | grep -vE '^[^:]+:[0-9]+:[[:space:]]*//' || true) + +if [ "$bad" -gt 0 ]; then + echo "tool resolution lint: FAIL ($bad finding(s))" + exit 1 +fi +echo "tool resolution lint: PASS (one table, one elevation path)" diff --git a/tools/linux_release.sh b/tools/linux_release.sh index 4b76bb00b..aeaed758f 100755 --- a/tools/linux_release.sh +++ b/tools/linux_release.sh @@ -107,6 +107,16 @@ rm -rf "$OUT_DIR" mkdir -p "$OUT_DIR/bin" cp "$BIN_SRC" "$OUT_DIR/bin/xlings" + +# Stage-0 as its own binary (SubOS design part 3 §8): its own package, so it +# links luban and modules/ and nothing of the frontend. +"$MCPP_BIN" build -p luban-init --profile dist --target "$MCPP_TARGET" 2>&1 || fail "mcpp build -p luban-init failed" +INIT_SRC="$(find "$PROJECT_DIR/target/$MCPP_TARGET" -path '*/bin/luban-init/luban-init' -type f -perm -111 -exec ls -t {} + | head -1)" +[[ -f "$INIT_SRC" ]] || fail "luban-init not found" +if command -v file &>/dev/null; then + file "$INIT_SRC" | grep -qi "statically linked" || fail "luban-init is not statically linked" +fi +cp "$INIT_SRC" "$OUT_DIR/bin/luban-init" chmod +x "$OUT_DIR/bin/"* # .xlings.json — release packages default to GLOBAL. Users can switch mirror diff --git a/tools/openkal-coexistence/mcpp.lock b/tools/openkal-coexistence/mcpp.lock new file mode 100644 index 000000000..08d68abf3 --- /dev/null +++ b/tools/openkal-coexistence/mcpp.lock @@ -0,0 +1,19 @@ +# Auto-generated by mcpp. Do not edit by hand. +# Records what this build resolved. It does not yet pin future builds: +# index dependencies are re-resolved from their constraints each time. +# dev-dependencies are excluded: only `mcpp test` resolves them, and this +# file must not change depending on which command ran last. +version = 2 + +[package."mcpplibs.openkal"] +namespace = "mcpplibs" +version = "0.15.0" +source = "index+mcpplibs@0.15.0" +hash = "fnv1a:4d289087428f98af" + +[package."mcpplibs.openkal-linux"] +namespace = "mcpplibs" +version = "0.16.1" +source = "index+mcpplibs@0.16.1" +hash = "fnv1a:c6ac71e5feb64354" + diff --git a/tools/openkal-coexistence/mcpp.toml b/tools/openkal-coexistence/mcpp.toml new file mode 100644 index 000000000..095f63dad --- /dev/null +++ b/tools/openkal-coexistence/mcpp.toml @@ -0,0 +1,20 @@ +# openkal beside xlings' runtimes (SubOS design part 3 §4.3, question 1). +# +# Not a workspace member, and outside tests/ (whose every .cpp is a test of +# the root package): nothing of xlings links openkal yet. CI builds this +# with the toolchain xlings builds with -- glibc, and the static musl target the +# release uses -- and runs it, so a toolchain or openkal change that breaks +# their coexistence is a red lane, not a surprise the day xlings adopts it. +[package] +name = "openkal-coexistence" +version = "0.1.0" + +[targets.openkal-coexistence] +kind = "bin" +main = "src/main.cpp" + +[dependencies.mcpplibs] +openkal = "0.15.0" + +[target.'cfg(os = "linux")'.dependencies.mcpplibs] +openkal-linux = "0.16.1" diff --git a/tools/openkal-coexistence/src/main.cpp b/tools/openkal-coexistence/src/main.cpp new file mode 100644 index 000000000..8f8fd9f2b --- /dev/null +++ b/tools/openkal-coexistence/src/main.cpp @@ -0,0 +1,17 @@ +// Does openkal-linux live beside this toolchain's C and C++ runtime in one +// image (xlings SubOS design part 3 §4.3, question 1)? Both write the same +// stdout; the implementation describes itself; the preopens are enumerated. +import std; +import openkal.stream; +import openkal.version; +import openkal.fs; + +int main() { + // kal::write bypasses the C library's stdio buffer: flush before mixing. + std::println("libstdc++ says: hello"); + std::fflush(nullptr); + const char line[] = "openkal says: hello\n"; + kal::write(kal::out(), line, sizeof(line) - 1); + std::println("kal_version={} preopens={}", kal::version(), kal::fs::preopen_count()); + return 0; +}