Harden OOXML gate against reference-integrity breaks #202

Merged
weichen merged 1 commits from feature/pptx-gate-reference-integrity into main 2026-10-15 02:33:10 +00:00
3 changed files with 518 additions and 78 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
---
id: tool.run_python
layer: tool
version: 2
version: 3
title: run_python 工具描述
description: Python 执行原语的模型侧说明文案
---
执行完整 Python 脚本(python3,工作目录=会话工作区)。适用:数据处理、生成 Excel/PPT/图片(housestyle kit)、批量操作。Word 交付物优先写 .md 源后用 run_shell 跑 `python3 -m housestyle.build`。任何 Office 交付物(docx/xlsx/pptx)都禁止用 zipfile 或字符串手工拼 OOXML——手拼的包缺母版与主题,register_artifact 的结构自检会直接拒绝;一律走 housestyle kit 构件。缺库先 `pip install --user python-pptx python-docx openpyxl`,装不上就如实告知用户当前环境无法生成本类交付物,不要退回手拼。报错先读退出码与 traceback 定位行号再改,同一错误不要原样重跑。生成带排版的文件(xlsx/pptx)先读 `$AGENT_SKILLS_DIR/house-style/SKILL.md` 的构件表,不确定签名先 `inspect.signature`。
执行完整 Python 脚本(python3,工作目录=会话工作区)。适用:数据处理、生成 Excel/PPT/图片(housestyle kit)、批量操作。Word 交付物优先写 .md 源后用 run_shell 跑 `python3 -m housestyle.build`。任何 Office 交付物(docx/xlsx/pptx)都禁止用 zipfile 或字符串手工拼 OOXML——手拼的包缺母版与主题,register_artifact 的结构自检会直接拒绝;一律走 housestyle kit 构件。缺库先 `pip install --user python-pptx python-docx openpyxl`,装不上就如实告知用户当前环境无法生成本类交付物,不要退回手拼。kit 路线执行失败(报错、超时、后台任务溢出)时,修复后仍走 kit 路线重试——任何失败都不构成退回手工拼 OOXML 的理由。报错先读退出码与 traceback 定位行号再改,同一错误不要原样重跑。生成带排版的文件(xlsx/pptx)先读 `$AGENT_SKILLS_DIR/house-style/SKILL.md` 的构件表,不确定签名先 `inspect.signature`。
+343 -33
View File
@@ -1,8 +1,10 @@
// OOXML 交付物结构自检(登记口 L1):纯 Go 静态校验,零外部依赖。
// 背景:Agent 绕开 housestyle kit 用 zipfile 手拼 OOXML 会产出缺母版/主题/文档
// 属性的残缺包——正文抽取照常成功,直到 Office 按 OPC 完整性校验打开时才报
// 损坏。这里按必备部件与 slide→layout→master→theme 引用链在登记前拦截,
// 失败信息直接指向 kit 重建路径。结构级评分(housestyle.lint / 终轮门禁)
// 背景:Agent 绕开 housestyle kit 用 zipfile 手拼 OOXML 会产出 Office 拒开的
// 残缺包。已知的两类事故形态:①部件整体缺失(缺母版/主题/docProps);②部件
// 齐全但引用断裂(根元素漏声明 xmlns:r、sldIdLst 与关系表不一致、
// [Content_Types] 缺 Override)——②能骗过"部件存在性"检查,Office 依旧打不开。
// 这里按「容器合法 + 必备部件 + 引用链完整 + 引用一致 + 内容类型覆盖」在登记前
// 拦截,失败信息直接指向 kit 重建路径。结构级评分(housestyle.lint / 终轮门禁)
// 不在本层:本层只保证「Office 打得开」这条底线。
package tools
@@ -17,6 +19,16 @@ import (
"strings"
)
const (
nsPresentationML = "http://schemas.openxmlformats.org/presentationml/2006/main"
nsOfficeDocRel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
ctPresentation = "application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"
ctSlide = "application/vnd.openxmlformats-officedocument.presentationml.slide+xml"
ctSlideLayout = "application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"
ctSlideMaster = "application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"
ctTheme = "application/vnd.openxmlformats-officedocument.theme+xml"
)
// ooxmlStructuralCheck 校验 name 指向的 Office 交付物是否具备可被 Office 打开
// 的最小 OPC 结构。不在管控扩展名内的文件直接放行。
func ooxmlStructuralCheck(name string, content []byte) error {
@@ -32,7 +44,8 @@ func ooxmlStructuralCheck(name string, content []byte) error {
}
}
// ooxmlCheckParts 校验容器与必备正文部件存在且为合法 XML(docx/xlsx 底线)。
// ooxmlCheckParts 校验容器与必备正文部件存在、XML 合法、命名空间前缀齐备
// (docx/xlsx 底线)。
func ooxmlCheckParts(content []byte, parts ...string) error {
zr, err := zip.NewReader(bytes.NewReader(content), int64(len(content)))
if err != nil {
@@ -46,11 +59,11 @@ func ooxmlCheckParts(content []byte, parts ...string) error {
return err
}
}
return nil
return ooxmlNamespaceStrict(zr)
}
// ooxmlCheckPptx 校验演示稿的完整引用链:presentation 声明母版列表,
// 每张 slide 可达 slideLayout,版式可达 slideMaster,母版可达 theme。
// ooxmlCheckPptx 校验演示稿:引用链完整(slide→layout→master→theme)、
// sldIdLst/sldMasterIdLst 与关系表一致、[Content_Types] 覆盖全部结构部件。
func ooxmlCheckPptx(content []byte) error {
zr, err := zip.NewReader(bytes.NewReader(content), int64(len(content)))
if err != nil {
@@ -64,32 +77,58 @@ func ooxmlCheckPptx(content []byte) error {
if err := ooxmlRequireElement(zr, "ppt/presentation.xml", "sldMasterIdLst"); err != nil {
return fmt.Errorf("presentation.xml 缺少 <sldMasterIdLst>(幻灯片母版列表)——母版与主题是必备结构")
}
// 严格命名空间先行:后续的 r:id 属性解析依赖前缀正确声明。
if err := ooxmlNamespaceStrict(zr); err != nil {
return err
}
presRels, err := ooxmlRels(zr, "ppt/presentation.xml")
if err != nil {
return err
}
var slides, masters []string
for _, rel := range presRels {
switch {
case strings.HasSuffix(rel.typ, "/slide"):
slides = append(slides, ooxmlResolve("ppt/presentation.xml", rel.target))
case strings.HasSuffix(rel.typ, "/slideMaster"):
masters = append(masters, ooxmlResolve("ppt/presentation.xml", rel.target))
}
// sldIdLst / sldMasterIdLst 是幻灯片与母版的权威清单,必须与关系表一致。
masterIDs, err := ooxmlIdListOf(zr, "ppt/presentation.xml", "sldMasterIdLst", "sldMasterId")
if err != nil {
return err
}
if len(masters) == 0 {
slideIDs, err := ooxmlIdListOf(zr, "ppt/presentation.xml", "sldIdLst", "sldId")
if err != nil {
return err
}
relsByType := func(suffix string) map[string]string {
out := map[string]string{}
for _, rel := range presRels {
if strings.HasSuffix(rel.typ, suffix) {
out[rel.id] = ooxmlResolve("ppt/presentation.xml", rel.target)
}
}
return out
}
masterTargets, err := ooxmlConsistent(masterIDs, relsByType("/slideMaster"), "sldMasterIdLst", "slideMaster")
if err != nil {
return err
}
slideTargets, err := ooxmlConsistent(slideIDs, relsByType("/slide"), "sldIdLst", "slide")
if err != nil {
return err
}
if len(masterTargets) == 0 {
return fmt.Errorf("presentation 未引用任何幻灯片母版(slideMaster)——母版与主题是必备结构")
}
for _, master := range masters {
if err := ooxmlMasterChain(zr, master); err != nil {
if len(slideTargets) == 0 {
return fmt.Errorf("presentation 未引用任何幻灯片")
}
themes := map[string]bool{}
for _, master := range masterTargets {
if err := ooxmlMasterChain(zr, master, themes); err != nil {
return err
}
}
if len(slides) == 0 {
return fmt.Errorf("presentation 未引用任何幻灯片")
masterSet := map[string]bool{}
for _, m := range masterTargets {
masterSet[m] = true
}
seenLayouts := map[string]bool{}
for _, slide := range slides {
layouts := map[string]bool{}
for _, slide := range slideTargets {
if err := ooxmlWellFormed(zr, slide); err != nil {
return err
}
@@ -107,22 +146,50 @@ func ooxmlCheckPptx(content []byte) error {
if layout == "" {
return fmt.Errorf("幻灯片 %s 没有指向 slideLayout 的关系——每张 slide 必须经关系表引用一个版式", slide)
}
if seenLayouts[layout] {
if layouts[layout] {
continue
}
seenLayouts[layout] = true
layouts[layout] = true
if err := ooxmlWellFormed(zr, layout); err != nil {
return err
}
if err := ooxmlLayoutMaster(zr, layout); err != nil {
if err := ooxmlLayoutMaster(zr, layout, masterSet); err != nil {
return err
}
}
// [Content_Types].xml 必须对每个结构部件给出 Override;缺了会被 Office
// 当成未知类型拒开(真实事故:只写了 slide1 的 Override)。
overrides, err := ooxmlCTOverrides(zr)
if err != nil {
return err
}
want := map[string]string{"ppt/presentation.xml": ctPresentation}
for _, t := range masterTargets {
want[t] = ctSlideMaster
}
for _, t := range slideTargets {
want[t] = ctSlide
}
for t := range layouts {
want[t] = ctSlideLayout
}
for t := range themes {
want[t] = ctTheme
}
for part, ct := range want {
got, ok := overrides["/"+part]
if !ok {
return fmt.Errorf("[Content_Types].xml 缺少 %s 的 Override(%s)——手拼包常见缺陷,请用 housestyle kit 重建", part, ct)
}
if got != ct {
return fmt.Errorf("[Content_Types].xml 中 %s 的内容类型不符:got %s want %s", part, got, ct)
}
}
return nil
}
// ooxmlMasterChain 校验母版存在且其关系表引用了存在的 theme。
func ooxmlMasterChain(zr *zip.Reader, master string) error {
// ooxmlMasterChain 校验母版存在、声明版式列表且与关系表一致、引用存在的 theme。
func ooxmlMasterChain(zr *zip.Reader, master string, themes map[string]bool) error {
if err := ooxmlWellFormed(zr, master); err != nil {
return err
}
@@ -130,28 +197,258 @@ func ooxmlMasterChain(zr *zip.Reader, master string) error {
if err != nil {
return err
}
if err := ooxmlRequireElement(zr, master, "sldLayoutIdLst"); err != nil {
return fmt.Errorf("母版 %s 缺少 <sldLayoutIdLst>(版式列表)——母版必须声明至少一个版式", master)
}
layoutIDs, err := ooxmlIdListOf(zr, master, "sldLayoutIdLst", "sldLayoutId")
if err != nil {
return err
}
layoutTargets := map[string]string{}
for _, rel := range rels {
if strings.HasSuffix(rel.typ, "/slideLayout") {
layoutTargets[rel.id] = ooxmlResolve(master, rel.target)
}
}
resolved, err := ooxmlConsistent(layoutIDs, layoutTargets, "sldLayoutIdLst", "slideLayout")
if err != nil {
return err
}
for _, target := range resolved {
if err := ooxmlWellFormed(zr, target); err != nil {
return err
}
}
for _, rel := range rels {
if strings.HasSuffix(rel.typ, "/theme") {
return ooxmlWellFormed(zr, ooxmlResolve(master, rel.target))
theme := ooxmlResolve(master, rel.target)
if err := ooxmlWellFormed(zr, theme); err != nil {
return err
}
themes[theme] = true
return nil
}
}
return fmt.Errorf("母版 %s 没有指向 theme 的关系", master)
}
// ooxmlLayoutMaster 校验版式的关系表引用了存在的 slideMaster。
func ooxmlLayoutMaster(zr *zip.Reader, layout string) error {
// ooxmlLayoutMaster 校验版式的关系表引用了存在的、且被 presentation 列出的
// slideMaster。
func ooxmlLayoutMaster(zr *zip.Reader, layout string, masters map[string]bool) error {
rels, err := ooxmlRels(zr, layout)
if err != nil {
return err
}
for _, rel := range rels {
if strings.HasSuffix(rel.typ, "/slideMaster") {
return ooxmlWellFormed(zr, ooxmlResolve(layout, rel.target))
master := ooxmlResolve(layout, rel.target)
if !masters[master] {
return fmt.Errorf("版式 %s 指向的母版 %s 未在 presentation 的 sldMasterIdLst 中声明", layout, master)
}
return nil
}
}
return fmt.Errorf("版式 %s 没有指向 slideMaster 的关系", layout)
}
// ooxmlIdListOf 提取 part 中 <listLocal> 列表内每个 <itemLocal> 的 r:id 属性。
func ooxmlIdListOf(zr *zip.Reader, part, listLocal, itemLocal string) ([]string, error) {
f, err := ooxmlPart(zr, part)
if err != nil {
return nil, err
}
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("部件 %s 无法读取(%v)", part, err)
}
defer rc.Close()
dec := xml.NewDecoder(rc)
var ids []string
inList := false
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("部件 %s 不是合法 XML(%v)", part, err)
}
switch se := tok.(type) {
case xml.StartElement:
if se.Name.Space == nsPresentationML && se.Name.Local == listLocal {
inList = true
continue
}
if inList && se.Name.Space == nsPresentationML && se.Name.Local == itemLocal {
if rid, ok := ooxmlAttrNS(se, nsOfficeDocRel, "id"); ok {
ids = append(ids, rid)
} else {
return nil, fmt.Errorf("%s 的 <%s> 缺少 r:id 属性——引用链断裂,请用 housestyle kit 重建", listLocal, itemLocal)
}
}
case xml.EndElement:
if inList && se.Name.Space == nsPresentationML && se.Name.Local == listLocal {
inList = false
}
}
}
return ids, nil
}
// ooxmlConsistent 校验 ID 清单与关系表双向一致:清单里每个 r:id 都要命中
// 同类型关系,关系表也不得多出或缺失。返回按清单顺序解析出的目标部件。
func ooxmlConsistent(ids []string, targets map[string]string, listName, what string) ([]string, error) {
seen := map[string]bool{}
var out []string
for _, id := range ids {
if seen[id] {
return nil, fmt.Errorf("%s 重复引用 %s", listName, id)
}
seen[id] = true
target, ok := targets[id]
if !ok {
return nil, fmt.Errorf("%s 引用的 %s 在关系表中不存在(rId=%s 断链)——请用 housestyle kit 重建", listName, what, id)
}
out = append(out, target)
}
if len(targets) != len(ids) {
return nil, fmt.Errorf("%s 与关系表数量不一致(清单 %d 条,关系表 %d 条)——存在未引用或缺失的 %s 关系,请用 housestyle kit 重建", listName, len(ids), len(targets), what)
}
return out, nil
}
// ooxmlNamespaceStrict 扫描包内全部 XML 部件:元素或属性使用了未声明的
// 命名空间前缀即报错。判定基于声明集合而非 URI 形态(Go 解码器对未声明
// 前缀保留原始前缀名作为 Space;已声明则解析为完整 URI,urn: 等 scheme
// 同样正确解析),避免对非 http URI 的误伤。
func ooxmlNamespaceStrict(zr *zip.Reader) error {
for _, f := range zr.File {
if !strings.HasSuffix(f.Name, ".xml") && !strings.HasSuffix(f.Name, ".rels") {
continue
}
declared, err := ooxmlDeclaredNamespaces(f)
if err != nil {
return err
}
rc, err := f.Open()
if err != nil {
return fmt.Errorf("部件 %s 无法读取(%v)", f.Name, err)
}
dec := xml.NewDecoder(rc)
var scanErr error
scan:
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
scanErr = fmt.Errorf("部件 %s 不是合法 XML(%v)", f.Name, err)
break
}
se, ok := tok.(xml.StartElement)
if !ok {
continue
}
if sp := ooxmlUnboundPrefix(se.Name.Space, declared); sp != "" {
scanErr = fmt.Errorf("部件 %s 使用了未声明的命名空间前缀 xmlns:%s(元素 <%s:%s>)——根元素补 xmlns:%s 声明或用 housestyle kit 重建", f.Name, sp, sp, se.Name.Local, sp)
break
}
for _, a := range se.Attr {
if sp := ooxmlUnboundPrefix(a.Name.Space, declared); sp != "" {
scanErr = fmt.Errorf("部件 %s 使用了未声明的命名空间前缀 xmlns:%s(属性 %s:%s)——根元素补 xmlns:%s 声明或用 housestyle kit 重建", f.Name, sp, sp, a.Name.Local, sp)
break scan
}
}
}
rc.Close()
if scanErr != nil {
return scanErr
}
}
return nil
}
// ooxmlDeclaredNamespaces 收集部件内全部 xmlns 声明(含默认命名空间与任意
// 位置的限定声明)。前缀 xmlns 为保留名:Go 把 xmlns:r 声明报为
// Space="xmlns"、Local="r";默认声明 xmlns="…" 报为 Space=""、Local="xmlns"。
func ooxmlDeclaredNamespaces(f *zip.File) (map[string]bool, error) {
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("部件 %s 无法读取(%v)", f.Name, err)
}
defer rc.Close()
out := map[string]bool{"xml": true, "xmlns": true}
dec := xml.NewDecoder(rc)
for {
tok, err := dec.Token()
if err == io.EOF {
return out, nil
}
if err != nil {
return nil, fmt.Errorf("部件 %s 不是合法 XML(%v)", f.Name, err)
}
se, ok := tok.(xml.StartElement)
if !ok {
continue
}
for _, a := range se.Attr {
switch {
case a.Name.Space == "xmlns":
out[a.Value] = true
case a.Name.Space == "" && a.Name.Local == "xmlns":
out[a.Value] = true
}
}
}
}
// ooxmlUnboundPrefix 返回未绑定的前缀名;已声明、无前缀与内建 xml 前缀返回空串。
func ooxmlUnboundPrefix(space string, declared map[string]bool) string {
if space == "" || declared[space] {
return ""
}
return space
}
// ooxmlCTOverrides 解析 [Content_Types].xml 的 Override 清单(部件路径 → 内容类型)。
func ooxmlCTOverrides(zr *zip.Reader) (map[string]string, error) {
f, err := ooxmlPart(zr, "[Content_Types].xml")
if err != nil {
return nil, err
}
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("[Content_Types].xml 无法读取(%v)", err)
}
defer rc.Close()
out := map[string]string{}
dec := xml.NewDecoder(rc)
for {
tok, err := dec.Token()
if err == io.EOF {
return out, nil
}
if err != nil {
return nil, fmt.Errorf("[Content_Types].xml 不是合法 XML(%v)", err)
}
se, ok := tok.(xml.StartElement)
if !ok || se.Name.Local != "Override" {
continue
}
partName, contentType := "", ""
for _, a := range se.Attr {
switch a.Name.Local {
case "PartName":
partName = a.Value
case "ContentType":
contentType = a.Value
}
}
out[partName] = contentType
}
}
// ooxmlWellFormed 确认部件存在且可被 XML 解析器完整读取。
func ooxmlWellFormed(zr *zip.Reader, part string) error {
f, err := ooxmlPart(zr, part)
@@ -202,6 +499,16 @@ func ooxmlRequireElement(zr *zip.Reader, part, local string) error {
return fmt.Errorf("部件 %s 缺少 <%s> 声明", part, local)
}
// ooxmlAttrNS 取指定命名空间下的属性值。
func ooxmlAttrNS(se xml.StartElement, space, local string) (string, bool) {
for _, a := range se.Attr {
if a.Name.Space == space && a.Name.Local == local {
return a.Value, true
}
}
return "", false
}
// ooxmlPart 按部件路径精确查找(ZIP 内无目录条目,直接比对名字)。
func ooxmlPart(zr *zip.Reader, name string) (*zip.File, error) {
for _, f := range zr.File {
@@ -212,8 +519,9 @@ func ooxmlPart(zr *zip.Reader, name string) (*zip.File, error) {
return nil, fmt.Errorf("缺少必备部件 %s", name)
}
// ooxmlRel 是 rels 部件里的一条关系:类型 URI 与目标(原样保留相对形式)。
// ooxmlRel 是 rels 部件里的一条关系:Id、类型 URI 与目标(原样保留相对形式)。
type ooxmlRel struct {
id string
typ string
target string
}
@@ -248,6 +556,8 @@ func ooxmlRels(zr *zip.Reader, part string) ([]ooxmlRel, error) {
var rel ooxmlRel
for _, attr := range se.Attr {
switch attr.Name.Local {
case "Id":
rel.id = attr.Value
case "Type":
rel.typ = attr.Value
case "Target":
+173 -43
View File
@@ -30,11 +30,10 @@ func zipOf(t *testing.T, files map[string]string) []byte {
}
const (
gateCTXML = `<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/></Types>`
gatePres = `<?xml version="1.0"?><p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:sldMasterIdLst><p:sldMasterId id="1"/></p:sldMasterIdLst><p:sldIdLst><p:sldId id="2"/></p:sldIdLst></p:presentation>`
gateSlide = `<?xml version="1.0"?><p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"/>`
gateLayout = `<?xml version="1.0"?><p:sldLayout xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"/>`
gateMaster = `<?xml version="1.0"?><p:sldMaster xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"/>`
gateCTXML = `<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/></Types>`
gateSlide = `<?xml version="1.0"?><p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:spTree/></p:cSld></p:sld>`
gateLayout = `<?xml version="1.0"?><p:sldLayout xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:spTree/></p:cSld></p:sldLayout>`
gateMaster = `<?xml version="1.0"?><p:sldMaster xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:cSld><p:spTree/></p:cSld><p:sldLayoutIdLst><p:sldLayoutId id="2147483647" r:id="rId1"/></p:sldLayoutIdLst></p:sldMaster>`
gateTheme = `<?xml version="1.0"?><a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"/>`
)
@@ -44,42 +43,96 @@ func relXML(rels ...string) string {
}
const (
relSlide = `<Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/>`
relMaster = `<Relationship Id="r2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>`
relLayout = `<Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>`
relToMaster = `<Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster1.xml"/>`
relTheme = `<Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/>`
relSlide = `<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/>`
relMaster = `<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>`
relLayout = `<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>`
relToMaster = `<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster1.xml"/>`
relTheme = `<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/>`
)
// gateValidPptx 是结构合法的最小演示稿:引用链 slide→layout→master→theme 齐备。
// gateCT 内容类型表:传入要 Override 的结构部件(缺了的用例自己构造残缺形态)。
func gateCT(parts ...string) string {
cts := map[string]string{
"ppt/presentation.xml": ctPresentation,
"ppt/slides/slide1.xml": ctSlide,
"ppt/slides/slide2.xml": ctSlide,
"ppt/slides/slide3.xml": ctSlide,
"ppt/slideLayouts/slideLayout1.xml": ctSlideLayout,
"ppt/slideMasters/slideMaster1.xml": ctSlideMaster,
"ppt/theme/theme1.xml": ctTheme,
}
out := `<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>`
for _, p := range parts {
out += `<Override PartName="/` + p + `" ContentType="` + cts[p] + `"/>`
}
return out + `</Types>`
}
var gateAllParts = []string{
"ppt/presentation.xml", "ppt/slides/slide1.xml",
"ppt/slideLayouts/slideLayout1.xml", "ppt/slideMasters/slideMaster1.xml", "ppt/theme/theme1.xml",
}
// gateValidPptx 是结构合法的最小演示稿:r:id 引用、ID 清单与关系表一致、
// 内容类型全量覆盖——与 python-pptx 产物的关键形态一致。
func gateValidPptx(t *testing.T) []byte {
t.Helper()
return zipOf(t, map[string]string{
"[Content_Types].xml": gateCTXML,
"[Content_Types].xml": gateCT(gateAllParts...),
"_rels/.rels": relXML(),
"ppt/presentation.xml": gatePres,
"ppt/presentation.xml": `<?xml version="1.0"?><p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId2"/></p:sldMasterIdLst><p:sldIdLst><p:sldId id="256" r:id="rId1"/></p:sldIdLst></p:presentation>`,
"ppt/_rels/presentation.xml.rels": relXML(relSlide, relMaster),
"ppt/slides/slide1.xml": gateSlide,
"ppt/slides/_rels/slide1.xml.rels": relXML(relLayout),
"ppt/slideLayouts/slideLayout1.xml": gateLayout,
"ppt/slideLayouts/_rels/slideLayout1.xml.rels": relXML(relToMaster),
"ppt/slideMasters/slideMaster1.xml": gateMaster,
"ppt/slideMasters/_rels/slideMaster1.xml.rels": relXML(relTheme),
"ppt/slideMasters/_rels/slideMaster1.xml.rels": relXML(relLayout, relTheme),
"ppt/theme/theme1.xml": gateTheme,
})
}
// gateBrokenPptx 复刻事故包:只有 presentation + slides + 幽灵 layout,
// 无母版/主题/docProps,presentation 缺 sldMasterIdLst,slide 无关系表。
func gateBrokenPptx(t *testing.T) []byte {
// gateIncident2Pptx 复刻第二起事故(部件齐全但引用断裂,上版门禁放行的形态):
// presentation/母版根元素漏 xmlns:r;sldIdLst 声明 3 页但关系表只有 rId3→slide1;
// [Content_Types] 缺 slide2/3 的 Override。三个 fix 开关逐项修复以隔离断言。
func gateIncident2Pptx(t *testing.T, fixNS, fixRels, fixCT bool) []byte {
t.Helper()
nsDecl := ` xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"`
pns, mns := "", ""
if fixNS {
pns, mns = nsDecl, nsDecl
}
pres := `<?xml version="1.0"?><p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"` + pns + `><p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst><p:sldIdLst><p:sldId id="256" r:id="rId3"/><p:sldId id="257" r:id="rId4"/><p:sldId id="258" r:id="rId5"/></p:sldIdLst></p:presentation>`
master := `<?xml version="1.0"?><p:sldMaster xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"` + mns + `><p:cSld><p:spTree/></p:cSld><p:sldLayoutIdLst><p:sldLayoutId id="2147483647" r:id="rId1"/></p:sldLayoutIdLst></p:sldMaster>`
rels := `<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/></Relationships>`
if fixRels {
rels = relXML(
`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>`,
`<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/>`,
`<Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide2.xml"/>`,
`<Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide3.xml"/>`)
}
ctParts := []string{"ppt/presentation.xml", "ppt/slideMasters/slideMaster1.xml",
"ppt/slideLayouts/slideLayout1.xml", "ppt/theme/theme1.xml", "ppt/slides/slide1.xml"}
if fixCT {
ctParts = append(ctParts, "ppt/slides/slide2.xml", "ppt/slides/slide3.xml")
}
return zipOf(t, map[string]string{
"[Content_Types].xml": gateCTXML,
"_rels/.rels": relXML(),
"ppt/presentation.xml": `<?xml version="1.0"?><p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:sldIdLst><p:sldId id="2"/></p:sldIdLst></p:presentation>`,
"ppt/_rels/presentation.xml.rels": relXML(relSlide),
"ppt/slides/slide1.xml": gateSlide,
"ppt/slideLayouts/slideLayout1.xml": gateLayout,
"[Content_Types].xml": gateCT(ctParts...),
"_rels/.rels": relXML(),
"ppt/presentation.xml": pres,
"ppt/_rels/presentation.xml.rels": rels,
"ppt/slides/slide1.xml": gateSlide,
"ppt/slides/slide2.xml": gateSlide,
"ppt/slides/slide3.xml": gateSlide,
"ppt/slides/_rels/slide1.xml.rels": relXML(relLayout),
"ppt/slides/_rels/slide2.xml.rels": relXML(relLayout),
"ppt/slides/_rels/slide3.xml.rels": relXML(relLayout),
"ppt/slideLayouts/slideLayout1.xml": gateLayout,
"ppt/slideLayouts/_rels/slideLayout1.xml.rels": relXML(relToMaster),
"ppt/slideMasters/slideMaster1.xml": master,
"ppt/slideMasters/_rels/slideMaster1.xml.rels": relXML(relLayout, relTheme),
"ppt/theme/theme1.xml": gateTheme,
})
}
@@ -89,8 +142,17 @@ func TestOoxmlGateValidPptx(t *testing.T) {
}
}
func TestOoxmlGateBrokenPptxIncident(t *testing.T) {
err := ooxmlStructuralCheck("机械臂腹部超声设计方案.pptx", gateBrokenPptx(t))
func TestOoxmlGateIncident1PartsMissing(t *testing.T) {
// 第一类事故:部件整体缺失(无母版/主题/docProps、slide 无关系表)。
broken := zipOf(t, map[string]string{
"[Content_Types].xml": gateCTXML,
"_rels/.rels": relXML(),
"ppt/presentation.xml": `<?xml version="1.0"?><p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:sldIdLst><p:sldId id="2"/></p:sldIdLst></p:presentation>`,
"ppt/_rels/presentation.xml.rels": relXML(relSlide),
"ppt/slides/slide1.xml": gateSlide,
"ppt/slideLayouts/slideLayout1.xml": gateLayout,
})
err := ooxmlStructuralCheck("事故一.pptx", broken)
if err == nil {
t.Fatal("incident-shaped pptx must be rejected")
}
@@ -101,22 +163,49 @@ func TestOoxmlGateBrokenPptxIncident(t *testing.T) {
}
}
// 第二类事故:部件齐全但引用断裂——上一版门禁放行、Office 拒开的形态。
func TestOoxmlGateIncident2ReferenceBreaks(t *testing.T) {
cases := []struct {
name string
fixNS, fixRels, fixCT bool
want string
}{
{"undeclared-ns", false, true, true, "未声明的命名空间前缀 xmlns:r"},
{"sldid-dangling-rid", true, false, true, "sldIdLst 引用的 slide 在关系表中不存在"},
{"ct-missing-slides", true, true, false, "Content_Types"},
}
for _, tc := range cases {
err := ooxmlStructuralCheck("事故二.pptx", gateIncident2Pptx(t, tc.fixNS, tc.fixRels, tc.fixCT))
if err == nil {
t.Fatalf("%s: must be rejected", tc.name)
}
if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("%s: error %q must mention %q", tc.name, err, tc.want)
}
}
// 三处缺陷全部修复后必须放行(对应 P4 修复产物的形态)。
if err := ooxmlStructuralCheck("修复后.pptx", gateIncident2Pptx(t, true, true, true)); err != nil {
t.Fatalf("fully repaired incident-2 package must pass: %v", err)
}
}
func TestOoxmlGatePptxChainBreaks(t *testing.T) {
base := func(t *testing.T) map[string]string {
t.Helper()
return map[string]string{
"[Content_Types].xml": gateCTXML,
"_rels/.rels": relXML(),
"ppt/presentation.xml": gatePres,
"ppt/_rels/presentation.xml.rels": relXML(relSlide, relMaster),
"ppt/slides/slide1.xml": gateSlide,
"ppt/slides/_rels/slide1.xml.rels": relXML(relLayout),
"ppt/slideLayouts/slideLayout1.xml": gateLayout,
"ppt/slideLayouts/_rels/slideLayout1.xml.rels": relXML(relToMaster),
"ppt/slideMasters/slideMaster1.xml": gateMaster,
"ppt/slideMasters/_rels/slideMaster1.xml.rels": relXML(relTheme),
"ppt/theme/theme1.xml": gateTheme,
valid := gateValidPptx(t)
zr, err := zip.NewReader(bytes.NewReader(valid), int64(len(valid)))
if err != nil {
t.Fatal(err)
}
m := map[string]string{}
for _, f := range zr.File {
b, err := readZipFile(f)
if err != nil {
t.Fatal(err)
}
m[f.Name] = string(b)
}
return m
}
cases := []struct {
name string
@@ -135,21 +224,40 @@ func TestOoxmlGatePptxChainBreaks(t *testing.T) {
{"layout-no-master-rel", func(m map[string]string) {
m["ppt/slideLayouts/_rels/slideLayout1.xml.rels"] = relXML()
}, "没有指向 slideMaster"},
{"layout-master-not-listed", func(m map[string]string) {
m["ppt/slideLayouts/_rels/slideLayout1.xml.rels"] = relXML(`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster2.xml"/>`)
m["ppt/slideMasters/slideMaster2.xml"] = gateMaster
m["ppt/slideMasters/_rels/slideMaster2.xml.rels"] = relXML(relLayout, relTheme)
}, "未在 presentation 的 sldMasterIdLst 中声明"},
{"master-no-theme-rel", func(m map[string]string) {
m["ppt/slideMasters/_rels/slideMaster1.xml.rels"] = relXML()
m["ppt/slideMasters/_rels/slideMaster1.xml.rels"] = relXML(relLayout)
}, "没有指向 theme"},
{"theme-missing", func(m map[string]string) {
delete(m, "ppt/theme/theme1.xml")
}, "缺少必备部件"},
{"master-no-layout-idlst", func(m map[string]string) {
m["ppt/slideMasters/slideMaster1.xml"] = strings.Replace(gateMaster, `<p:sldLayoutIdLst><p:sldLayoutId id="2147483647" r:id="rId1"/></p:sldLayoutIdLst>`, "", 1)
}, "缺少 <sldLayoutIdLst>"},
{"pres-no-master-idlst", func(m map[string]string) {
m["ppt/presentation.xml"] = strings.Replace(gatePres, "<p:sldMasterIdLst><p:sldMasterId id=\"1\"/></p:sldMasterIdLst>", "", 1)
m["ppt/presentation.xml"] = strings.Replace(
m["ppt/presentation.xml"],
`<p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId2"/></p:sldMasterIdLst>`, "", 1)
}, "sldMasterIdLst"},
{"pres-no-slide-rels", func(m map[string]string) {
{"pres-slide-rel-dangling", func(m map[string]string) {
m["ppt/_rels/presentation.xml.rels"] = relXML(relMaster)
}, "未引用任何幻灯片"},
}, "sldIdLst 引用的 slide 在关系表中不存在"},
{"pres-extra-slide-rel", func(m map[string]string) {
m["ppt/_rels/presentation.xml.rels"] = relXML(relSlide, relMaster,
`<Relationship Id="rId9" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide2.xml"/>`)
m["ppt/slides/slide2.xml"] = gateSlide
m["ppt/slides/_rels/slide2.xml.rels"] = relXML(relLayout)
}, "数量不一致"},
{"xml-malformed", func(m map[string]string) {
m["ppt/slides/slide1.xml"] = "<p:sld>"
m["ppt/slides/slide1.xml"] = `<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">`
}, "不是合法 XML"},
{"ct-slide-mismatch", func(m map[string]string) {
m["[Content_Types].xml"] = strings.Replace(gateCT(gateAllParts...), ctSlide, "application/xml", 1)
}, "内容类型不符"},
}
for _, tc := range cases {
m := base(t)
@@ -164,6 +272,19 @@ func TestOoxmlGatePptxChainBreaks(t *testing.T) {
}
}
func readZipFile(f *zip.File) ([]byte, error) {
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
var buf bytes.Buffer
if _, err := buf.ReadFrom(rc); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func TestOoxmlGateDocxXlsxAndPassthrough(t *testing.T) {
validDocx := zipOf(t, map[string]string{
"[Content_Types].xml": gateCTXML,
@@ -175,6 +296,15 @@ func TestOoxmlGateDocxXlsxAndPassthrough(t *testing.T) {
if err := ooxmlStructuralCheck("方案.docx", zipOf(t, map[string]string{"[Content_Types].xml": gateCTXML})); err == nil {
t.Fatal("docx without document.xml must be rejected")
}
// 命名空间严格化同样覆盖 docx:正文用了未声明前缀要拦。
badDocx := zipOf(t, map[string]string{
"[Content_Types].xml": gateCTXML,
"word/document.xml": `<?xml version="1.0"?><w:document><w:p r:id="r1"/></w:document>`,
})
err := ooxmlStructuralCheck("方案.docx", badDocx)
if err == nil || !strings.Contains(err.Error(), "未声明") {
t.Fatalf("docx with undeclared prefix must be rejected: %v", err)
}
validXlsx := zipOf(t, map[string]string{
"[Content_Types].xml": gateCTXML,
"xl/workbook.xml": `<?xml version="1.0"?><workbook xmlns="urn:x"/>`,
@@ -203,7 +333,7 @@ func TestRegisterArtifactStructuralGate(t *testing.T) {
t.Fatal(err)
}
broken := filepath.Join(sandbox, "坏.pptx")
if err := os.WriteFile(broken, gateBrokenPptx(t), 0o644); err != nil {
if err := os.WriteFile(broken, gateIncident2Pptx(t, false, false, false), 0o644); err != nil {
t.Fatal(err)
}
res := interpCall(t, r, rc, "register_artifact", `{"path":"坏.pptx"}`)