本技能针对 Common Lisp、SBCL 与 Coalton 生态,整理那些“代码能加载、能运行、不报错,但行为错误”的静默失败模式。它假定读者已了解常规的 CLOS、条件系统和 ASDF 用法,专门记录能通过朴素代码审查的陷阱。
一、CLOS:构造边界是类,不是构造函数 导出类即导出其 make-instance。若校验或不变量只写在 make-foo 这类便捷构造函数里,调用方直接写 (make-instance 'foo :slot ...) 就能绕过全部检查,得到系统假定不可能存在的对象。当直接构造必须保持不变量时,应在 initialize-instance :after(或适用的 shared-initialize)方法中强制。槽的 :initform 只提供默认值,不校验传入值。把类保持在包内部只是收窄受支持 API,并非访问控制边界。未校验的构造函数应作为内部约定记录,而非安全保证。
二、条件:format 字符串注入与无界载荷 绝不要把输入派生数据当作 FORMAT 控制字符串。FORMAT 是一门完整语言(~R、~V、深层嵌套 ~{~}、递归 ~? 指令),受攻击者影响的控制字符串就是 Lisp 的 format 字符串漏洞,可直接导致资源耗尽与信息泄露。错误辅助函数是常见引入点,因为 (error msg) 读起来很自然。应写 (error "~A" msg) 或 (error 'my-error :detail msg),并审计所有控制字符串为变量而非字面量的 error、warn、cerror、format 调用。 仅对报告输出做净化不够:公开条件若保留无界载荷,会在条件对象生命周期内一直持有它,任何后续打印、日志或序列化都会重新物化。应在初始化时、在槽中做边界限制与净化,而非渲染时;在分配任何缓冲区前按固定硬上限校验,并在渲染时截断,而不是先构造无界中间字符串再切割。
三、输入校验陷阱 标准字符谓词具备 Unicode 感知。digit-char-p 不是 ASCII 测试:在完整 Unicode 支持的实现上,它接受任意文字的十进制数字(全角、阿拉伯-印度、天城文等)并返回数值权重;alpha-char-p 与 alphanumericp 同样会放宽。任何基于 U+0030–U+0039 的语法若使用 digit-char-p,就悄然扩大了接受语言,且审查时不可见,因为每个 ASCII 测试用例都通过。应一次性定义 ASCII 专用谓词,并在语法的每个阶段(起始检测、数字消费、数值转换、流分帧)使用;只要有一个阶段仍用标准谓词,就会重新引入不一致。应把全角、阿拉伯-印度、天城文数字作为常备回归输入。 先校验,再规范化。当守卫用于拒绝 A 类输入,而规范化强制转换把 A 映射为 B 时,先执行转换会使守卫不可达:它留在源码里、通过审查,却成了空操作。应在函数体第一个形式中对原始参数断言。注意每个 uiop 路径名强制转换(ensure-directory-pathname、ensure-pathname、带默认值的 parse-namestring),它们对守卫通常要区分的差异是有损的。
四、包卫生:桩包污染共享镜像 验证脚本若在加载选定真实源码前定义了部分桩包,就会永久占据镜像全局包命名空间中的该名称;defpackage 不是作用域化的,也不会回退。若同一镜像中后续路径求值规范定义,其 :import-from 会因桩包从未导出的符号而失败,错误却指向无辜的规范文件。定义桩包的测试装置应在独立进程中运行,绝不与规范加载共享镜像。
五、定义可达性验证 “加载正常、调用时爆炸”的一类失败源于混淆三件事:符号存在、符号被导出、符号有绑定。:export 会 intern 符号,但不绑定它,不建立函数绑定、值或类。删除或替换模块时,应显式用 fboundp(以及 boundp / find-class)审计保留的导出,并搜索所有剩余顶层调用者。 括号平衡不等于嵌套正确。结构检查器只证明括号平衡,不证明嵌套符合作者意图。错位的右括号可能把两个 defun 嵌进第三个,或产生 (defparameter (defparameter table ...)),文件仍读作合法 Lisp。应使用完整阶梯:平衡检查 → 顶层形式大纲(每个预期定义是否出现在深度零)→ 对预期导出做 fboundp → 实际系统加载。
六、ASDF 路径解析 在全新进程 ASDF 加载与直接源码加载下正确解析仓库相对文件。核心危险:ASDF 加载编译后的 FASL 时,load-truename 指向 FASL 输出缓存而非源码树,对其做 merge-pathnames 会在缓存下解析并失败。应使用 asdf:system-relative-pathname 或 asdf:system-source-directory,而非 load-truename / merge-pathnames。 先初始化源注册表。全新或继承的 ASDF 会话必须在 asdf:load-system 前把源注册表指向项目根;仅加载 .asd 文件不够,可能在 find-system/load-system 发现过程中停滞。注册目录,而非整棵树:优先用 :directory 条目命名精确项目根,而非以父检出为根的宽泛 :tree,因为递归发现会遍历根下一切,包括无关构建输出和存储后端环境中的根级结果符号链接。
七、ASDF 系统定义陷阱 条件式测试系统定义:用 (unless (asdf:find-system "proj/test" nil) ...) 守卫测试系统定义,会使 asdf:test-system 递归进入同一 .asd 加载路径,并在系统发现时表现为循环依赖。应无条件定义库系统与测试系统。操作符号包:当形式可能在 ASDF-USER 之外读取时,把操作限定为 asdf:test-op。组件路径名:支持 :file "src/foo" 这类相对目录组件;当多个组件共享目录时用 :module。别名 .asd 中定义规范系统会触发 ASDF 警告与全新注册表冒烟缺口;规范 proj/test 系统应留在主 proj.asd 中。
八、依赖变更表面 替换、移除或外置依赖不是带后续杂务的代码变更,而是跨固定表面集合的一次原子编辑,部分应用会使系统无法加载。需检查:.asd 构建清单的 :depends-on 与 :components;包定义的 :import-from、:export、local-nicknames;依赖锁文件与固定修订;开发 shell 与源注册表配置;CI 运行脚本与覆盖率配置;调用点与测试辅助(包括仅构造夹具的辅助);README 与变更日志声明。整个表面清单应作为单次审查与提交单元落地。
九、SBCL 操作 文件末尾部分涵盖 SBCL 程序的运行、调试、性能分析与发布。