From 0a57853154e331cdb02a6a74ec2e13033b843e63 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 02:14:12 +0000 Subject: [PATCH] Harden OOXML gate against reference-integrity breaks Why: A second broken pptx passed the shipped gate despite being unopenable in Office: parts were all present but presentation.xml and the master used an undeclared xmlns:r prefix, sldIdLst listed 12 slides while the rels file wired only one, and [Content_Types].xml overrode only slide1. The prior part-existence checks cannot see any of these; the generating instance also predated the first gate deployment. Evidence: The upgraded gate rejects the real broken file (undeclared xmlns:r) and 16 synthetic break cases spanning both incident classes, while passing the manually repaired package and kit output; python round-trip on the repaired deck extracts all 12 slides. Full-suite receipt follows on the task card. Impact: register_artifact now enforces strict namespace declarations across every XML part, bidirectional consistency between id-lists and relationships (presentation and masters), and per-part content-type overrides. Error messages name the broken reference. run_python prompt v3 adds that failed kit attempts must retry through the kit, never fall back to hand assembly. Refs: Dev task feature/pptx-gate-reference-integrity --- internal/prompts/library/tool-run-python.md | 4 +- internal/tools/ooxmlgate.go | 376 ++++++++++++++++++-- internal/tools/ooxmlgate_test.go | 216 ++++++++--- 3 files changed, 518 insertions(+), 78 deletions(-) diff --git a/internal/prompts/library/tool-run-python.md b/internal/prompts/library/tool-run-python.md index e45b563..bbf465b 100644 --- a/internal/prompts/library/tool-run-python.md +++ b/internal/prompts/library/tool-run-python.md @@ -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`。 diff --git a/internal/tools/ooxmlgate.go b/internal/tools/ooxmlgate.go index d7f5f1c..9bfd839 100644 --- a/internal/tools/ooxmlgate.go +++ b/internal/tools/ooxmlgate.go @@ -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 缺少 (幻灯片母版列表)——母版与主题是必备结构") } + // 严格命名空间先行:后续的 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 缺少 (版式列表)——母版必须声明至少一个版式", 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 中 列表内每个 的 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": diff --git a/internal/tools/ooxmlgate_test.go b/internal/tools/ooxmlgate_test.go index 9ee2957..b6da748 100644 --- a/internal/tools/ooxmlgate_test.go +++ b/internal/tools/ooxmlgate_test.go @@ -30,11 +30,10 @@ func zipOf(t *testing.T, files map[string]string) []byte { } const ( - gateCTXML = `` - gatePres = `` - gateSlide = `` - gateLayout = `` - gateMaster = `` + gateCTXML = `` + gateSlide = `` + gateLayout = `` + gateMaster = `` gateTheme = `` ) @@ -44,42 +43,96 @@ func relXML(rels ...string) string { } const ( - relSlide = `` - relMaster = `` - relLayout = `` - relToMaster = `` - relTheme = `` + relSlide = `` + relMaster = `` + relLayout = `` + relToMaster = `` + relTheme = `` ) -// 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 := `` + for _, p := range parts { + out += `` + } + return out + `` +} + +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": ``, "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 := `` + master := `` + rels := `` + if fixRels { + rels = relXML( + ``, + ``, + ``, + ``) + } + 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": ``, - "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": ``, + "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(``) + 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, ``, "", 1) + }, "缺少 "}, {"pres-no-master-idlst", func(m map[string]string) { - m["ppt/presentation.xml"] = strings.Replace(gatePres, "", "", 1) + m["ppt/presentation.xml"] = strings.Replace( + m["ppt/presentation.xml"], + ``, "", 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, + ``) + 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"] = "" + m["ppt/slides/slide1.xml"] = `` }, "不是合法 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": ``, + }) + 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": ``, @@ -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"}`)