Repository navigation
Description
范围与执行约束
2026-11-10 默认分支代码审计。以下报告含基线、证据、复现边界、价值排序和详细计划。首批事项经过独立复核后按 sub-issue 实施,其他事项保留研究计划。协作检查只覆盖已推送工作,实施前再次核对。
FastPPT 源码审计与实施建议
审计日期:2026-11-10。私有仓库:Q:/FullStack/fastrd/fast-ppt。本文行号相对基线 f6bf21f13445c2507719928fa68550552a8fcab2。独立复核结论见 REVIEW-fast-ppt.md。
1. 结论、基线与协作状态
优先交付“文本写入前拒绝 NUL + 编辑器异步响应保护”。 两者均已通过原始生产实现复现,分别造成磁盘内容被破坏和未保存草稿丢失,改动可收敛到已有服务/store。并发 revision 检查失效同样成立,须另立具有明确写入者边界的方案。
| 检查项 | 本次观测 |
|---|---|
| 本地 HEAD、远端 main | f6bf21f13445c2507719928fa68550552a8fcab2 |
远端 codex/refactor-web |
0dc841b7c6181562512a49ed7b543c638349292e |
| 两个 tip 的 tree | 均为 ef125884e0fac589ede470f079e2028eee7e3a61;git diff main origin/codex/refactor-web --stat 为空 |
| PR #1 | refactor: web,CLOSED,mergedAt=null,关闭于 2026-10-28T05:59:04Z |
| 开放 PR / issue | gh pr list --state all 仅上述 CLOSED PR;gh issue list --state open 为 [] |
| 工作树 | 验证前后 git status --porcelain=v1 为空;验证后工作树和暂存区 diff 为空 |
| 工具 | Windows Node v22.23.4、pnpm 10.33.0 |
tree 一致说明最终内容相同;不据此把 CLOSED PR 记为 GitHub 已合并。远端用 git ls-remote --heads origin 核实,状态仅代表本次查询时点,实施前应重查。
2. 架构与阅读边界
项目为 pnpm/Turbo monorepo。Vue/Pinia Web 通过 HTTP/WebSocket 调用本地 Gateway;Gateway 编排工作区文件、Agent harness、Slidev 和导出;packages/workspace 管理文件路径、内容、revision;协议 schema 在 packages/protocol。文件保存链路:
MarkdownEditor → files store → Gateway client
→ PUT /api/v1/workspace/files/content
→ WriteFileRequestSchema → WorkspaceService.writeTextFile
→ 临时文件 + sync + rename → readTextFile → store 回填
实际完整阅读:packages/workspace/src/workspace-service.ts(509 行)、path-policy.ts、revision.ts、packages/workspace/tests/workspace-service.test.ts、apps/web/src/stores/files.ts、apps/web/src/components/editor/MarkdownEditor.vue、根 package.json、workspace 包 package.json、pnpm-workspace.yaml。
定向阅读:README 1–170;apps/gateway/src/app.ts:1750–1789;packages/protocol/src/index.ts:105–134;apps/web/src/api/useGatewayClient.ts:1–130。搜索定位 Gateway 的文件服务调用与 Vue 编辑器接线,未把所有命中文件记为完整审计。没有审计整个 Agent/导出/鉴权子系统。
3. 发现与价值排序
P1 表示应优先修复的用户数据/安全问题;排序同时考虑收益、前提和实施成本。
| ID | 问题、证据 | 影响与前提 | 可信度 / 价值排序 |
|---|---|---|---|
| PPT-1 | NUL 文本先落盘后报错;service 450–457、494–507,revision 7–14 | 一次合法 JSON 字符串即可损坏原文件,随后文本读取失败 | 生产方法动态复现;P1,首批第 1 |
| PPT-2 | 保存响应无条件覆盖当前文件/草稿;store 75–98 | 保存期间继续输入或切文件,较新内容消失 | 原 Pinia store 动态复现;P1,首批第 2 |
| PPT-3 | expectedRevision 检查与提交非原子;service 470–507 | 两个写请求同旧 revision 均可成功,最后写入覆盖前者 | 生产方法受控交错复现;P1,后续独立方案 |
| PPT-4 | 敏感/ignore 策略只检查请求别名;service 209–218,path-policy 81–106 | 工作区已有可访问 symlink/junction 时,可读工作区内敏感目标 | .env 文件 symlink、.ssh 目录 junction 均复现;P1,条件性安全修复 |
| PPT-5 | containment 之前递归 mkdir;service 382–390、459–467 | 工作区已有指向外部目录的链接,拒绝请求前仍越界创建目录 | 文本、图片两个入口均复现;P1,条件性安全修复 |
PPT-1:写入失败并不意味着文件未改变
缺陷类别是持久化前后使用不同内容合法性规则。规则应为:文本写入接口在首次文件系统副作用前验证内容可由同一文本读取接口读取。
WriteFileRequestSchema(protocol 117–121)接受任意字符串。服务只在写前检查字节长度;写入并 rename 后调用 readTextFile,后者以 isBinaryContent 检查 NUL 并抛出 INVALID_REQUEST。这不是路径里的 NUL:路径 NUL 已在 normalizeRelativePath:50–56 拒绝,问题是 content 中的 U+0000。
实测:原文件 before,使用原 revision 写 bad\0text,方法抛 INVALID_REQUEST,磁盘 hex 为 6261640074657874。原文件已被替换,后续普通文本读取也拒绝。Gateway 1776–1778 直接调用该服务,schema 没有更早拦截。
PPT-2:旧异步结果拥有了当前编辑状态
缺陷类别是异步结果没有绑定发起时的编辑上下文。规则应为:响应只能更新它对应的文件会话和已提交基线,不能把请求发出后的编辑当作已保存内容。
store 80–84 在发出时读取 path/content/revision,85–87 却无条件替换当前选择、draft 和 externalChange。saving 只防第二次 save;编辑器 102–105 仍发送输入,136–145 仅禁用格式化/保存按钮,没有把编辑器设为只读。
原 store 实测:
- A 文件草稿
sent-A发出保存,等待期间改为newer-draft;完成后 draft 退回sent-A,dirty=false。 - A 保存中打开 B 并编辑
B-draft;A 回包后 selectedFile 跳回 A,draft 变为sent-A,dirty=false。
同类位置:openFile:59–68 也无请求身份校验,存在读请求乱序/读取中输入的静态风险;resyncWorkspace:117–133 等待后只比较 revision/dirty,没有核对当前选择身份。首批应审查这三个异步回填点。本次动态探针只覆盖 save 的两种交错,没有把另外两处记为已动态复现。
PPT-3:rename 原子性不等于 revision CAS
服务先 readTextFile,再比较 expectedRevision,随后多次 await 写临时文件、sync、rename。代码中没有包住“读取—比较—提交”的锁或事务。原子 rename 保证目标切换完整,无法保证旧 revision 只被成功消费一次。
探针只在实例的首次两次 readTextFile 完成后设置屏障,让两个调用确实读到相同 revision;写入/比较/rename 仍使用原方法。A/B 两请求均 fulfilled,返回内容均为 B,最终磁盘 B。返回 B/B 还说明 507 的重新读取可能返回另一个请求提交的内容。
该实验是单服务实例、同进程并发。没有执行多进程 CAS 实验,也没有证明任意调度都会 B/B;双成功已足以否定 CAS 保证。仅加进程内 mutex 最多覆盖遵守该 mutex 的写入者,不能覆盖多 Gateway、Agent CLI 或外部编辑器。修复规划须先列出支持的写入者和文件系统语义。
PPT-4:边界内敏感目标通过普通别名可读
readTextFile 对 relativePath 执行 secret/ignore 策略后,resolveExistingPath 仅做 realpath containment,不对解析后的工作区相对路径再次应用 secret/ignore 策略。
使用纯合成内容,safe.md → .env 返回 SYNTHETIC_ENV;public → .ssh junction 下的 public/notes.md 返回 SYNTHETIC_SECRET。目标均在 workspace 内,因此 containment 合法,敏感规则却被绕过。
这需要已存在或可创建链接,不能写成“任意远程请求读任意系统文件”。assertFileAvailable:251–269、resolveImageAttachment:272–305 具有同样“先别名策略、后 realpath”结构,应统一审查;图片入口还有扩展名/大小条件,本次未动态验证它的敏感目标别名场景。没有据此声称可经文件 symlink 覆盖 .env;rename 的目标与 read 的解析语义还需分别处理。
PPT-5:拒绝之前已发生外部目录创建
对 escape → outside junction 请求 escape/new-text/slides.md,方法最终报 PATH_OUTSIDE_WORKSPACE,但 outside/new-text 已创建。图片写入 escape/new-image/f.png 同样报错且外部目录已创建。
证据只证明越界 mkdir;两个调用均在写文件前被拒绝,本次未观察到外部文件写入。最小修复需在创建每个新目录前确认已有祖先位于允许边界,覆盖文本与图片两处。存在可并发替换链接的攻击者时,单次 realpath 检查仍有 TOCTOU;不能把“提前一次检查”宣传为完整文件系统沙箱。
4. 实施计划与验收
批次 A:NUL 前置验证 + 异步草稿保护(推荐先实施)
- 文件范围:
packages/workspace/src/workspace-service.ts、apps/web/src/stores/files.ts,以及已有 workspace 测试/最贴近实际编辑器的行为测试位置。 - NUL:沿用
isBinaryContent(bytes)的既有规则,在 mkdir/open/rename 之前返回既有INVALID_REQUEST。不新增配置、不改变协议字段。 - store:捕获发起保存的文件会话和内容快照;同一会话只推进已保存基线,若草稿已变则保留草稿、保持 dirty;切换会话后旧响应不能更改当前文件、草稿或错误状态。读取/resync 的回填也使用同一上下文规则。若允许 A→B→A,应区分编辑会话,单独比较 path 不足。
- 最小设计门槛:修复缺陷类别;调用方零新增字段;上下文由 store 自行确定;不引入通用任务框架;适用于所有文件而非固定
slides.md。只比较 path/内容值无法可靠表达 A→B→A,因此允许最小的 store 内编辑会话标识,不需要跨端协议版本字段。 - 验收:已有文件 NUL 写失败后字节/revision 不变;新路径 NUL 写失败后不产生父目录/文件;普通 Unicode 成功。保存中继续输入保留最新草稿、更新已保存 revision、dirty 仍真,再保存成功。切 B 及 A→B→A 后,旧 A 回包/409 均不能污染当前会话。逆序 openFile 与 resync 返回不覆盖当前选择。正常保存最终 clean,真实当前会话的 409 仍保留草稿并提示冲突。
- 不包含 PPT-3 的并发提交保证,交付说明不得称为“多进程 CAS 已解决”。
批次 B:路径策略覆盖解析目标及创建副作用
- 文件范围:
path-policy.ts、workspace service 的 read/availability/attachment/write 两种入口与现有测试。 - 统一检查允许别名与 canonical 工作区相对目标;创建目录前检查已存在祖先,并明确链接替换竞态的支持边界。优先复用现有策略函数。
- 验收:
.env文件链接、敏感目录链接、ignore 目标链接被拒绝;普通边界内非敏感链接按既有产品约定工作;边界外 text/image 路径失败后外部目录及文件均无变化;正常新建多层目录、图片不覆盖语义仍成立。
批次 C:明确并发写协议后处理 revision
- 先列写入者:Web/Gateway、内部服务、MCP/Agent 与外部编辑器;决定保证范围。
- 验收在所声明范围内必须让两个同 revision 写入恰好一个成功、另一个返回 409;响应内容对应成功提交。若声明跨进程,必须运行独立进程实验。若声明涵盖任意外部写入者,必须提供相应文件系统/协议依据,不能以进程锁替代证明。
- 成本高于批次 A,单独设计和复核,不捆绑新数据库、锁服务或后台队列作为默认方案。
后续实施验证命令:pnpm --filter @fastppt/workspace test、受影响 Web 行为测试、pnpm --filter @fastppt/web typecheck,再按修改范围运行 lint/仓库检查;这些是待执行验收,不是本次已通过项。
5. 本次实际验证、复现材料和限制
外部材料目录:D:/Temp/opencode/fastrd-audit-20261110/。ppt-probe.mjs 用 esbuild 直接打包基线 TS,不复制重写业务逻辑;服务使用真实 Node 文件系统;Pinia/Vue 运行原 store,仅替换 Gateway、事件流和重试外部边界。临时 workspace/外部目录均在探针 finally 清理。
node D:/Temp/opencode/fastrd-audit-20261110/ppt-probe.mjs
NUL {"error":"INVALID_REQUEST","diskHex":"6261640074657874"}
MKDIR text PATH_OUTSIDE_WORKSPACE true
MKDIR image PATH_OUTSIDE_WORKSPACE true
SECRET_ALIAS SYNTHETIC_SECRET
ENV_ALIAS SYNTHETIC_ENV
CAS {"results":[{"status":"fulfilled","content":"B"},{"status":"fulfilled","content":"B"}],"disk":"B"}
STORE typing {"path":"a.md","draft":"sent-A","dirty":false}
STORE switch {"path":"a.md","draft":"sent-A","dirty":false}
外部临时依赖:esbuild 0.25.12、picomatch 4.0.3、Vue 3.5.24、Pinia 3.0.4,安装在上述材料目录。它们用于独立探针,不等于仓库 lockfile 的完整构建环境。未安装仓库依赖、未运行 pnpm 全套测试/typecheck、未启动浏览器/Gateway 端到端验证;不把探针通过解释为整个产品已验证。symlink/junction 实测基于 Windows,其他文件系统需在后续支持平台验收。
远端检查实际使用 gh pr list --state all --json ...、gh pr view 1 --json state,mergedAt,closedAt,files,commits、gh issue list --state open --json ...、git ls-remote --heads origin。首次未给 ^{tree} 参数加引号的 PowerShell 调用失败,随后以 git rev-parse 'main^{tree}' 'origin/codex/refactor-web^{tree}' 成功取得上表结果。
本阶段仅新增工作区根目录报告;仓库源码、测试、依赖清单未修改,未创建 issue、branch、commit 或 PR。
独立复核
FastPPT 独立复核结论
日期:2026-11-10;基线 f6bf21f13445c2507719928fa68550552a8fcab2。本次为独立取证复核:重新读取生产实现、查询 GitHub/远端、运行原服务及原 Pinia store;没有直接以输入中的既有判断作证据,也没有委派第二名审阅者。
详细架构、定位、计划和执行记录见 AUDIT-fast-ppt.md。
1. 复核裁决
| 待核结论 | 裁决及证据 |
|---|---|
| refactor-web 最终 tree 与 main 相同 | 确认:两者 tree 均 ef125884e0fac589ede470f079e2028eee7e3a61,tip SHA 不同 |
| PR #1 closed,无 open PR | 确认:GitHub state=CLOSED、mergedAt=null;本次 all 查询仅该 PR,open issue=[] |
| 同 revision 并发双成功、可能返回 B/B | 确认:原 writeTextFile,通过两次初始真实读取后的屏障受控交错,两个 fulfilled/content=B,disk=B |
| 别名可绕过敏感 realpath 目标策略 | 确认:合成 .env 文件 symlink、.ssh 目录 junction 均返回内容 |
| content NUL 先写后 INVALID_REQUEST | 确认:错误后磁盘 hex=6261640074657874,原 before 已丢失 |
| mkdir 在 containment 前产生越界目录 | 确认:文本与图片各一个原服务调用,均报 PATH_OUTSIDE_WORKSPACE,外部新目录仍存在 |
| 保存中输入/切文件被旧响应覆盖 | 确认:原 Pinia store,输入回退;切 B 后跳回 A;均 dirty=false |
上述“确认”均有本次实测;另外的 openFile/resync 响应身份风险只做静态检查,不与动态证明混列。
2. 对结论措辞的限制
- 原子 rename 不提供 revision CAS;本次并发实验只在单实例内运行。进程锁无法自动约束 Agent/外部编辑器,任何多进程保证均待单独验证。
.env读取绕过需要链接前提;没有证明匿名远程任意文件读取,也没有证明可经相同文件 symlink 覆盖 secret。- 越界目录创建已证实;外部文件写入未在该实验发生。
- store 使用真实 Vue/Pinia 执行,但 API/事件流是受控边界替身;没有运行浏览器端到端。不能将两个 store 实验宣传为全部编辑器生命周期已覆盖。
- 临时探针依赖独立安装,未跑仓库 pnpm 测试/全量 typecheck。验收清单是后续实施要求。
3. 首批方案复核
通过进入实施准备:NUL 写前检查 + 编辑会话绑定的响应回填。 这是对具体副作用的最小修复组合;无需新增 API 字段、配置或通用锁框架。服务内容验证放在所有 mkdir/open 之前;store 同时区分“已保存基线”和“当前草稿”,避免以禁用输入代替状态正确性。
实施前必须满足的验收点:
- NUL 新建/覆盖失败都零文件系统副作用,正常 Unicode 和合法文本保存不回归。
- 保存中输入、切 B、A→B→A、旧 409 均不覆盖当前会话;同一会话的新草稿以新 revision 再保存可成功。
- 读请求乱序与 resync 回填采用相同上下文约束;不是只在 save 的一行前加 path 判断。
- 不在首批成果中宣称解决 PPT-3 或多进程 CAS。
路径修复应覆盖文本/图片两处 mkdir,以及 read/availability/attachment 的 canonical 目标策略;这属于另一边界问题,可以独立交付。revision 原子比较提交须先定义写入者范围,再选机制。
4. 最小设计与审阅覆盖
方案五问:结构上解决副作用前验证和旧响应覆盖;调用方无新负担;上下文由 store 确定;不引入额外服务/协议;覆盖所有文件会话。允许最小编辑会话标识,因为仅 path 无法区分 A→B→A,内容相等也不代表同一会话。
完整阅读覆盖:workspace-service、path-policy、revision、workspace-service.test、files store、MarkdownEditor、根/workspace package.json、pnpm-workspace.yaml。定向阅读覆盖:Gateway 文件路由、protocol 写 schema、client 请求实现及 README;具体区间在主报告。其余模块未作完整审阅。
仓库验证前后 clean;工作树/暂存区 diff 为空。交付只有根目录 AUDIT/REVIEW 文档,源码净行变更为 0;未创建 issue、branch、commit、PR。建议实施前再次核对远端工作,以本报告验收而非本次探针代替修复后验证。
Activity
HsukqiLee commentedon Nov 10, 2026
本地实施与最终独立评审记录
基线:
f6bf21f13445c2507719928fa68550552a8fcab2;本地分支:audit/2026-11-reviewed-fixes。源码已在隔离工作树完成首批实现与评审,尚未 commit/push/提交 PR。 因此以下是本地验证交付,不代表已合入、部署或远端 CI 通过;issues 保持开放。后续审计项仍按父报告计划排队。
FastPPT issues #3 / #4 实施记录(已独立评审)
日期:2026-11-10。工作树:
Q:/FullStack/fastrd/worktrees/fast-ppt。1. 状态与交接
REVIEW-fast-ppt-implementation.md;Gateway HTTP 验收仍被既有 Windows 初始化问题阻断,不能称全部端到端验收通过。issues 未关闭。AUDIT-fast-ppt.md、REVIEW-fast-ppt.md。保留接手时的 workspace 测试改动和 Web 新测试文件。audit/2026-11-reviewed-fixes;HEAD 为f6bf21f13445c2507719928fa68550552a8fcab2。gh pr list --state open --json number,title,headRefName,baseRefName,url返回[]。git ls-remote --heads origin:main 仍为上述 HEAD,codex/refactor-web 为0dc841b7c6181562512a49ed7b543c638349292e。apply_patch编辑。没有 commit、push、创建 PR、修改远端 issue 或委派评审。v22.23.4、pnpm10.33.0、Vitest4.1.10。现有依赖可直接运行,无需安装或修改 lockfile。2. 缺陷类别与最小方案
#3:持久化前后内容契约不一致
规则:文本写入的内容合法性必须在第一次目录/文件副作用前确认,采用与文本读取相同的规则。
WorkspaceService.writeTextFile将字符串编码为 UTF-8 后立即调用已有isBinaryContent(bytes),不合法时抛既有INVALID_REQUEST/ HTTP 400。检查位于 mkdir、open、临时文件写入及 rename 之前。读取规则继续保留,因为磁盘文件也可能来自外部写入者。沿用现有数据结构和判定函数,不新增 schema、配置或 Gateway 特判。普通 Unicode 文本仍按原流程写入并读取。
#4:异步结果与当前编辑状态混用
规则:异步结果只能更新发起时所属的编辑会话;已保存基线和用户当前草稿分别拥有自己的更新条件。
在现有 files store 内加入三个私有状态:
session = { opening }:每次显式/事件触发的 open 都建立新对象身份;同 path 重载及 A→B→A 也是新会话。opening 表示读取基线期间,避免使用未完成重载的旧 revision 保存。draftVersion:仅内容确实变化的updateDraft推进。open/save 捕获编辑版本,仅未发生后续输入时回填 draft;输入后即使值回到原文也仍识别为后续编辑。readVersion:每次 resync 及保存开始时推进,使较早 resync 不能回填到较新读取/写入之后。save 捕获文件、提交内容、revision 和会话,成功时同会话推进 selectedFile 基线,保留后续草稿。旧会话的成功、409、500 及 finally 均不能改当前状态;新会话有自己的 saving 状态。当前会话的 409 仍保留草稿并提示冲突。
open 的成功和失败均核对会话;不同路径打开时清除旧选择和草稿,避免旧文件作为新会话编辑基线;同路径重载保留可见草稿,重载期间新输入得到保留。读取未完成时 save 返回,完成后使用新 revision 保存。
resync 在等待文件树之前捕获会话、选择对象和读取版本;读前、成功/失败回填前均核对归属,并避开 opening/saving。脏草稿只标记 externalChange,clean 当前会话正常更新。工作区事件在保存中不触发自动 reopen,即使用户刚好将草稿回退到旧基线。
最小设计五问:
交付复查:没有测试专用生产选项、兼容路径、单调用通用抽象或新配置。resync 的局部
ownsRead复用同一次请求在多处 await 后的归属条件。opening 由 save/resync 的生产逻辑读取,并非测试状态。3. 同类位置扫描与处置
使用专用 Grep,工作树范围
include=*.ts,正则:selectedFile\.value\s*=|draft\.value\s*=|writeTextFile\(|isBinaryContent。另在 Web Vue 源码搜索files\.(openFile|save|updateDraft)|filesStore\.(openFile|save|updateDraft),在 Gateway 测试搜索workspace/files/content|writeTextFile。图片入口本来就是二进制契约,不能加入文本 NUL 规则;已有 PNG 含零字节写入测试仍通过。
4. 测试设计、接手内容与红绿证据
先跑接手测试、再修改生产实现。服务使用生产 WorkspaceService 和真实临时文件系统。store 使用真实 Pinia/Vue、Gateway client、schema 和事件流;只替换 fetch/WebSocket 传输边界,以 deferred 响应控制交错。
实现前已有失败模式矩阵来自根审计/复核和接手测试:NUL 覆盖/新路径、保存后输入、切文件、A→B→A、重载、旧成功/409/500、当前冲突、逆序 open、reload 中输入、resync 的切换/编辑/保存/较新读取及正常刷新。
pnpm --filter @fastppt/workspace testpnpm --filter @fastppt/web test -- tests/stores/files.test.tspnpm --filter @fastppt/web testpnpm --filter @fastppt/workspace typecheck/lintpnpm --filter @fastppt/web typecheck/lintpnpm --filter @fastppt/gateway typecheck/lintgit diff --check接手的 workspace 两项测试原样保留。接手的 24 项 store 测试全部保留;修正请求体类型检查,并将错误 fixture 补为带 retryable/requestId 的
ApiErrorBody类型,保证通过真实错误 schema。本次另补 4 项 store 场景:重载/切文件未完成时重连(2 项)、重载基线读取完成前不发送保存(1 项)、保存中将 draft 回退后收到文件事件仍保留保存会话(1 项)。这些来自第一版实现的交错复查;没有将其声称为修改前已逐一红测的场景。
Gateway 复用已有 “lists, reads and revision-protects workspace files” 场景,在合法保存前加入覆盖/新路径 NUL 请求与磁盘/读取基线不变断言。该路由验收当前阻塞,见下一节。
5. 净行数与变更所有权
相对本地 HEAD,
git diff --numstat加未跟踪文件的git diff --no-index --numstat -- /dev/null apps/web/tests/stores/files.test.ts(后者正常以差异退出 1)得到:接手时已有净 +294(workspace +38、Web 新文件 256);本次继续实施净加 +122。增长主要来自必须控制交错的真实传输边界测试;生产代码只增加两处所需规则与私有上下文,没有另建框架。
根
IMPLEMENTATION-fast-ppt.md为仓库工作树外独立新增报告(+122 / -0,净 +122 行),不混入上述源码/测试统计;连同本文总净增 +538 行。AUDIT/REVIEW 保持为历史审计记录。6. 限制、阻塞与评审重点
pnpm --filter @fastppt/gateway test -- tests/app.test.ts -t "lists, reads and revision-protects workspace files":1 failed / 66 filtered skipped。抛ThemeRegistryError: layout must be a normalized POSIX relative path,栈为 theme-registryassertRelativePosixPath:208 → resolveOwnedPath:232 → loadTheme:408 → createGateway:679 → makeApp:279,尚未执行本场景文件读写请求。loadTheme用平台join('layouts', ...)生成路径,然后交给 POSIX 路径校验;Windows 反斜杠触发拒绝。用未修改的已有健康场景复核:pnpm --filter @fastppt/gateway test -- tests/app.test.ts -t "exposes health without authentication"同样 1 failed / 66 filtered skipped,同一初始化栈。两次清理钩子另报 SQLiteEBUSY;涉及临时目录D:/Temp/fastppt-gateway-r7EZzS、D:/Temp/fastppt-gateway-IShXUM,本次未确认它们最终被移除。没有扩大生产修复到主题注册器/初始化资源清理。完整逐行阅读:两份根审计/复核、workspace-service.ts、workspace-service.test.ts、revision.ts、files.ts、新 files.test.ts、MarkdownEditor.vue、useEventStream.ts、useGatewayClient.ts、根及受影响包 package.json。Gateway app.test.ts 仅完整审阅本次变更片段和相邻初始化/文件路由场景,未声称逐行审阅其 4,000 多行;Gateway app.ts、MCP server、sessions store、WorkspaceView、protocol、theme-registry 均为定向阅读。本次仅做实施者自查,独立评审待执行。
最终独立评审
FastPPT 实施 diff 独立只读评审
日期:2026-11-10
对象:
worktrees/fast-ppt相对f6bf21f的全部未提交 diff 与未跟踪apps/web/tests/stores/files.test.ts。裁决
NUL 写前零副作用和编辑会话归属修复通过静态逐行评审;定向 workspace/Web 门禁通过。Gateway 新路由验收受既有 Windows 初始化问题阻断,不能宣称端到端全绿。 未发现 save/open/resync 竞态会把旧响应重新写入当前会话的代码错误。
阻断验收项
P1:Gateway 路由测试未进入新增断言
复跑:
结果为 1 failed / 66 skipped,失败发生在请求前的
ThemeRegistryError: layout must be a normalized POSIX relative path,栈经loadTheme → createGateway → makeApp;清理阶段另有 SQLiteEBUSY。因此新增的覆盖/新路径 NUL HTTP 断言没有被执行。该失败看起来是既有 Windows 路径初始化/清理环境问题,不归因于本 diff,但合并验收仍需在可启动 Gateway 的环境重跑。逐行审查结论
NUL 写前检查
packages/workspace/src/workspace-service.ts:450-457在mkdir、临时文件open、写入、sync和rename前调用已有isBinaryContent(Buffer.from(...))。因此覆盖已有文件和新建多级路径都在首次文件系统副作用前拒绝,错误码保持INVALID_REQUEST/400。新增 workspace 测试同时检查原字节、revision、目录项和 Unicode round-trip;没有把图片二进制入口错误套入文本规则。save/open/resync 竞态
openFile每次建立新对象身份;同路径 reload 和 A→B→A 都不会复用旧 session。切换路径先清空旧 selection/draft,读取完成后只有当前 session 能回填。draftVersion保留;即使内容值曾回到旧值,版本仍能阻止旧响应无条件覆盖。session.opening也使 reload 基线完成前的 save 不发送。dirty计算。旧 session 的成功、409、500 和 finally 都不能改当前选择、draft、错误或 saving 状态。resyncWorkspace在等待 tree 和读取文件前后都检查 session、selected 对象、readVersion、opening/saving。较早 resync 不会覆盖较新的 resync、open 或 save;脏草稿只标记 externalChange,clean session 才回填磁盘内容。readVersion不能提供服务端多进程 CAS;实现也没有声称修复 PPT-3,这个边界正确。非阻断项
apps/web/tests/stores/files.test.ts未跟踪文件有 28 个测试,覆盖保存中输入、B/A→B→A/reload 旧响应、旧 200/409/500、当前冲突、resync 切换/输入/保存/较新读取/错误、重连 clean refresh 和新草稿 dirty。测试使用真实 client/schema/store,仅替换 fetch/WebSocket 传输边界,足以验证 store 状态机,但不是浏览器 E2E。isGatewayError的实际判定只检查instanceof Error,沿用 client 错误对象形态;本 diff 没有扩大错误协议。实际验证
pnpm --filter @fastppt/workspace test:16/16 通过。pnpm --filter @fastppt/web test -- tests/stores/files.test.ts:28/28 通过。git diff --check:通过;工作树仅报告记录中的 4 个 tracked 修改和 1 个 untracked store test。限制与复验门槛
在 POSIX 或修正主题 registry 路径归一化的 Windows 测试环境重跑 Gateway 目标场景,并确认清理完成。验收措辞应限定为两个首批问题已由定向 store/workspace 测试覆盖,不能写成 Gateway 全链路或多进程 CAS 已通过。