Why: The registration gate classified Word packages from a hard-coded ZIP member path, allowing alternate main-part paths to bypass the gate and rejecting unrelated ZIP archives. Evidence: Focused OPC registration tests; go test -count=1 ./internal/tools; go test -count=1 ./...; go test -race -count=1 ./internal/tools ./internal/runmgr; go vet ./...; go build ./...; git diff --check. Impact: Resolve package-level officeDocument targets safely, classify all conventional Word main content types through OPC defaults or overrides, and fail closed on malformed, ambiguous, external, or traversing metadata while preserving non-Word artifacts. Refs: Task 10; /tmp/task10-backend-final-closure-last.md
911 lines
28 KiB
Go
911 lines
28 KiB
Go
// OOXML 交付物结构自检(登记口 L1):纯 Go 静态校验,零外部依赖。
|
||
// 背景:Agent 绕开 housestyle kit 用 zipfile 手拼 OOXML 会产出 Office 拒开的
|
||
// 残缺包。已知的两类事故形态:①部件整体缺失(缺母版/主题/docProps);②部件
|
||
// 齐全但引用断裂(根元素漏声明 xmlns:r、sldIdLst 与关系表不一致、
|
||
// [Content_Types] 缺 Override)——②能骗过"部件存在性"检查,Office 依旧打不开。
|
||
// 这里按「容器合法 + 必备部件 + 引用链完整 + 引用一致 + 内容类型覆盖」在登记前
|
||
// 拦截,失败信息直接指向 kit 重建路径。结构级评分(housestyle.lint / 终轮门禁)
|
||
// 不在本层:本层只保证「Office 打得开」这条底线。
|
||
package tools
|
||
|
||
import (
|
||
"archive/zip"
|
||
"bytes"
|
||
"encoding/xml"
|
||
"fmt"
|
||
"io"
|
||
"net/url"
|
||
"path"
|
||
"path/filepath"
|
||
"strings"
|
||
)
|
||
|
||
const (
|
||
nsPackageRel = "http://schemas.openxmlformats.org/package/2006/relationships"
|
||
nsContentTypes = "http://schemas.openxmlformats.org/package/2006/content-types"
|
||
nsPresentationML = "http://schemas.openxmlformats.org/presentationml/2006/main"
|
||
nsOfficeDocRel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||
relOfficeDoc = nsOfficeDocRel + "/officeDocument"
|
||
relStrictOffice = "http://purl.oclc.org/ooxml/officeDocument/relationships/officeDocument"
|
||
ctWordDocument = "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"
|
||
ctWordTemplate = "application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml"
|
||
ctWordMacroDoc = "application/vnd.ms-word.document.macroEnabled.main+xml"
|
||
ctWordMacroTpl = "application/vnd.ms-word.template.macroEnabledTemplate.main+xml"
|
||
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 {
|
||
switch strings.ToLower(filepath.Ext(name)) {
|
||
case ".docx":
|
||
return ooxmlCheckParts(content, "word/document.xml")
|
||
case ".xlsx":
|
||
return ooxmlCheckParts(content, "xl/workbook.xml")
|
||
case ".pptx":
|
||
return ooxmlCheckPptx(content)
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// isWordprocessingMLPackage identifies Word OPC packages from the package-level
|
||
// officeDocument relationship and the resolved main part's content type. A
|
||
// purported officeDocument package with malformed, ambiguous, or unsafe
|
||
// metadata is treated as Word so the registration boundary fails closed.
|
||
func isWordprocessingMLPackage(content []byte) bool {
|
||
zr, err := zip.NewReader(bytes.NewReader(content), int64(len(content)))
|
||
if err != nil {
|
||
return false
|
||
}
|
||
isWord, purportsOfficeDocument, err := ooxmlClassifyWordPackage(zr)
|
||
if err != nil {
|
||
return purportsOfficeDocument
|
||
}
|
||
return isWord
|
||
}
|
||
|
||
func ooxmlClassifyWordPackage(zr *zip.Reader) (isWord, purportsOfficeDocument bool, err error) {
|
||
relsFile, found, err := ooxmlUniquePart(zr, "_rels/.rels")
|
||
if err != nil {
|
||
return false, true, err
|
||
}
|
||
if !found {
|
||
return false, false, nil
|
||
}
|
||
rels, purportsOfficeDocument, err := ooxmlPackageRelationships(relsFile)
|
||
if err != nil {
|
||
return false, purportsOfficeDocument, err
|
||
}
|
||
|
||
var officeRels []ooxmlRel
|
||
for _, rel := range rels {
|
||
if rel.typ == relOfficeDoc || rel.typ == relStrictOffice {
|
||
officeRels = append(officeRels, rel)
|
||
}
|
||
}
|
||
if len(officeRels) == 0 {
|
||
return false, false, nil
|
||
}
|
||
if len(officeRels) != 1 {
|
||
return false, true, fmt.Errorf("package relationships contain %d officeDocument relationships", len(officeRels))
|
||
}
|
||
|
||
mainPart, err := ooxmlSafePackageTarget(officeRels[0].target, officeRels[0].targetMode)
|
||
if err != nil {
|
||
return false, true, err
|
||
}
|
||
if _, found, err := ooxmlUniquePartFold(zr, mainPart); err != nil || !found {
|
||
if err != nil {
|
||
return false, true, err
|
||
}
|
||
return false, true, fmt.Errorf("officeDocument target %q is missing", mainPart)
|
||
}
|
||
|
||
contentTypesFile, found, err := ooxmlUniquePart(zr, "[Content_Types].xml")
|
||
if err != nil {
|
||
return false, true, err
|
||
}
|
||
if !found {
|
||
return false, true, fmt.Errorf("officeDocument package is missing [Content_Types].xml")
|
||
}
|
||
contentTypes, err := ooxmlParseContentTypes(contentTypesFile)
|
||
if err != nil {
|
||
return false, true, err
|
||
}
|
||
contentType, found := contentTypes.contentType(mainPart)
|
||
if !found {
|
||
return false, true, fmt.Errorf("officeDocument target %q has no content type", mainPart)
|
||
}
|
||
return ooxmlIsWordMainContentType(contentType), true, nil
|
||
}
|
||
|
||
type ooxmlContentTypeTable struct {
|
||
defaults map[string]string
|
||
overrides map[string]string
|
||
}
|
||
|
||
func (t ooxmlContentTypeTable) contentType(part string) (string, bool) {
|
||
if contentType, ok := t.overrides[strings.ToLower(part)]; ok {
|
||
return contentType, true
|
||
}
|
||
extension := strings.TrimPrefix(path.Ext(part), ".")
|
||
contentType, ok := t.defaults[strings.ToLower(extension)]
|
||
return contentType, ok
|
||
}
|
||
|
||
func ooxmlIsWordMainContentType(contentType string) bool {
|
||
switch contentType {
|
||
case ctWordDocument, ctWordTemplate, ctWordMacroDoc, ctWordMacroTpl:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func ooxmlPackageRelationships(file *zip.File) ([]ooxmlRel, bool, error) {
|
||
rc, err := file.Open()
|
||
if err != nil {
|
||
return nil, false, fmt.Errorf("_rels/.rels cannot be read: %w", err)
|
||
}
|
||
defer rc.Close()
|
||
|
||
dec := xml.NewDecoder(rc)
|
||
var rels []ooxmlRel
|
||
seenIDs := map[string]bool{}
|
||
rootSeen := false
|
||
rootValid := false
|
||
depth := 0
|
||
purportsOfficeDocument := false
|
||
var metadataErr error
|
||
for {
|
||
tok, err := dec.Token()
|
||
if err == io.EOF {
|
||
break
|
||
}
|
||
if err != nil {
|
||
return nil, purportsOfficeDocument, fmt.Errorf("_rels/.rels is not valid XML: %w", err)
|
||
}
|
||
switch node := tok.(type) {
|
||
case xml.EndElement:
|
||
depth--
|
||
continue
|
||
case xml.StartElement:
|
||
depth++
|
||
if !rootSeen {
|
||
rootSeen = true
|
||
rootValid = node.Name.Space == nsPackageRel && node.Name.Local == "Relationships"
|
||
if !rootValid {
|
||
metadataErr = fmt.Errorf("_rels/.rels has an invalid Relationships root")
|
||
}
|
||
continue
|
||
}
|
||
|
||
var rel ooxmlRel
|
||
seenAttrs := map[string]bool{}
|
||
for _, attr := range node.Attr {
|
||
if attr.Name.Space != "" {
|
||
continue
|
||
}
|
||
if seenAttrs[attr.Name.Local] {
|
||
metadataErr = fmt.Errorf("_rels/.rels contains duplicate %s attributes", attr.Name.Local)
|
||
}
|
||
seenAttrs[attr.Name.Local] = true
|
||
switch attr.Name.Local {
|
||
case "Id":
|
||
rel.id = attr.Value
|
||
case "Type":
|
||
rel.typ = attr.Value
|
||
case "Target":
|
||
rel.target = attr.Value
|
||
case "TargetMode":
|
||
rel.targetMode = attr.Value
|
||
}
|
||
}
|
||
if rel.typ == relOfficeDoc || rel.typ == relStrictOffice {
|
||
purportsOfficeDocument = true
|
||
}
|
||
if depth != 2 || node.Name.Space != nsPackageRel || node.Name.Local != "Relationship" {
|
||
metadataErr = fmt.Errorf("_rels/.rels contains an invalid relationship element")
|
||
continue
|
||
}
|
||
if rel.id == "" || rel.typ == "" || rel.target == "" {
|
||
metadataErr = fmt.Errorf("_rels/.rels contains an incomplete Relationship")
|
||
continue
|
||
}
|
||
if seenIDs[rel.id] {
|
||
metadataErr = fmt.Errorf("_rels/.rels contains duplicate relationship Id %q", rel.id)
|
||
continue
|
||
}
|
||
seenIDs[rel.id] = true
|
||
rels = append(rels, rel)
|
||
}
|
||
}
|
||
if !rootSeen {
|
||
return nil, false, fmt.Errorf("_rels/.rels is empty")
|
||
}
|
||
if !rootValid || metadataErr != nil {
|
||
return nil, purportsOfficeDocument, metadataErr
|
||
}
|
||
return rels, purportsOfficeDocument, nil
|
||
}
|
||
|
||
func ooxmlParseContentTypes(file *zip.File) (ooxmlContentTypeTable, error) {
|
||
table := ooxmlContentTypeTable{
|
||
defaults: map[string]string{},
|
||
overrides: map[string]string{},
|
||
}
|
||
rc, err := file.Open()
|
||
if err != nil {
|
||
return table, fmt.Errorf("[Content_Types].xml cannot be read: %w", err)
|
||
}
|
||
defer rc.Close()
|
||
|
||
dec := xml.NewDecoder(rc)
|
||
rootSeen := false
|
||
for {
|
||
tok, err := dec.Token()
|
||
if err == io.EOF {
|
||
break
|
||
}
|
||
if err != nil {
|
||
return table, fmt.Errorf("[Content_Types].xml is not valid XML: %w", err)
|
||
}
|
||
se, ok := tok.(xml.StartElement)
|
||
if !ok {
|
||
continue
|
||
}
|
||
if !rootSeen {
|
||
rootSeen = true
|
||
if se.Name.Space != nsContentTypes || se.Name.Local != "Types" {
|
||
return table, fmt.Errorf("[Content_Types].xml has an invalid Types root")
|
||
}
|
||
continue
|
||
}
|
||
if se.Name.Space != nsContentTypes {
|
||
continue
|
||
}
|
||
attrs := map[string]string{}
|
||
for _, attr := range se.Attr {
|
||
if attr.Name.Space == "" {
|
||
attrs[attr.Name.Local] = attr.Value
|
||
}
|
||
}
|
||
switch se.Name.Local {
|
||
case "Default":
|
||
extension := strings.ToLower(attrs["Extension"])
|
||
contentType := attrs["ContentType"]
|
||
if extension == "" || strings.ContainsAny(extension, ".\\/ \t\r\n") || contentType == "" {
|
||
return table, fmt.Errorf("[Content_Types].xml contains an invalid Default")
|
||
}
|
||
if _, exists := table.defaults[extension]; exists {
|
||
return table, fmt.Errorf("[Content_Types].xml contains duplicate Default for %q", extension)
|
||
}
|
||
table.defaults[extension] = contentType
|
||
case "Override":
|
||
partName, err := ooxmlSafePartName(attrs["PartName"])
|
||
if err != nil || attrs["ContentType"] == "" {
|
||
return table, fmt.Errorf("[Content_Types].xml contains an invalid Override")
|
||
}
|
||
key := strings.ToLower(partName)
|
||
if _, exists := table.overrides[key]; exists {
|
||
return table, fmt.Errorf("[Content_Types].xml contains duplicate Override for %q", partName)
|
||
}
|
||
table.overrides[key] = attrs["ContentType"]
|
||
}
|
||
}
|
||
if !rootSeen {
|
||
return table, fmt.Errorf("[Content_Types].xml is empty")
|
||
}
|
||
return table, nil
|
||
}
|
||
|
||
func ooxmlSafePackageTarget(target, targetMode string) (string, error) {
|
||
if targetMode != "" && !strings.EqualFold(targetMode, "Internal") {
|
||
return "", fmt.Errorf("officeDocument target must be internal")
|
||
}
|
||
return ooxmlSafePartPath(target, strings.HasPrefix(target, "/"))
|
||
}
|
||
|
||
func ooxmlSafePartName(partName string) (string, error) {
|
||
if !strings.HasPrefix(partName, "/") {
|
||
return "", fmt.Errorf("OPC part name must be absolute")
|
||
}
|
||
return ooxmlSafePartPath(partName, true)
|
||
}
|
||
|
||
func ooxmlSafePartPath(raw string, absolute bool) (string, error) {
|
||
if raw == "" || strings.ContainsAny(raw, "\\\x00") {
|
||
return "", fmt.Errorf("unsafe OPC part path %q", raw)
|
||
}
|
||
parsed, err := url.Parse(raw)
|
||
if err != nil || parsed.IsAbs() || parsed.Host != "" || parsed.Opaque != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||
return "", fmt.Errorf("unsafe OPC part path %q", raw)
|
||
}
|
||
decoded, err := url.PathUnescape(parsed.EscapedPath())
|
||
if err != nil || strings.ContainsAny(decoded, "\\\x00") {
|
||
return "", fmt.Errorf("unsafe OPC part path %q", raw)
|
||
}
|
||
if absolute != strings.HasPrefix(decoded, "/") {
|
||
return "", fmt.Errorf("unsafe OPC part path %q", raw)
|
||
}
|
||
decoded = strings.TrimPrefix(decoded, "/")
|
||
if decoded == "" || strings.HasSuffix(decoded, "/") {
|
||
return "", fmt.Errorf("unsafe OPC part path %q", raw)
|
||
}
|
||
for _, segment := range strings.Split(decoded, "/") {
|
||
if segment == "" || segment == "." || segment == ".." {
|
||
return "", fmt.Errorf("unsafe OPC part path %q", raw)
|
||
}
|
||
}
|
||
return decoded, nil
|
||
}
|
||
|
||
func ooxmlUniquePart(zr *zip.Reader, name string) (*zip.File, bool, error) {
|
||
var found *zip.File
|
||
for _, file := range zr.File {
|
||
if file.Name != name {
|
||
continue
|
||
}
|
||
if found != nil {
|
||
return nil, false, fmt.Errorf("duplicate OPC metadata part %s", name)
|
||
}
|
||
found = file
|
||
}
|
||
return found, found != nil, nil
|
||
}
|
||
|
||
func ooxmlUniquePartFold(zr *zip.Reader, name string) (*zip.File, bool, error) {
|
||
var found *zip.File
|
||
for _, file := range zr.File {
|
||
if !strings.EqualFold(file.Name, name) {
|
||
continue
|
||
}
|
||
if found != nil {
|
||
return nil, false, fmt.Errorf("duplicate OPC part %s", name)
|
||
}
|
||
found = file
|
||
}
|
||
return found, found != nil, nil
|
||
}
|
||
|
||
// ooxmlCheckParts 校验容器与必备正文部件存在、XML 合法、命名空间前缀齐备
|
||
// (docx/xlsx 底线)。
|
||
func ooxmlCheckParts(content []byte, parts ...string) error {
|
||
zr, err := zip.NewReader(bytes.NewReader(content), int64(len(content)))
|
||
if err != nil {
|
||
return fmt.Errorf("不是有效的 OOXML 容器(%v)——Office 交付物必须由 housestyle kit 生成", err)
|
||
}
|
||
if err := ooxmlWellFormed(zr, "[Content_Types].xml"); err != nil {
|
||
return err
|
||
}
|
||
for _, part := range parts {
|
||
if err := ooxmlWellFormed(zr, part); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return ooxmlNamespaceStrict(zr)
|
||
}
|
||
|
||
// 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 {
|
||
return fmt.Errorf("不是有效的 OOXML 容器(%v)——Office 交付物必须由 housestyle kit 生成", err)
|
||
}
|
||
for _, part := range []string{"[Content_Types].xml", "ppt/presentation.xml"} {
|
||
if err := ooxmlWellFormed(zr, part); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
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
|
||
}
|
||
// sldIdLst / sldMasterIdLst 是幻灯片与母版的权威清单,必须与关系表一致。
|
||
masterIDs, err := ooxmlIdListOf(zr, "ppt/presentation.xml", "sldMasterIdLst", "sldMasterId")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
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)——母版与主题是必备结构")
|
||
}
|
||
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
|
||
}
|
||
}
|
||
masterSet := map[string]bool{}
|
||
for _, m := range masterTargets {
|
||
masterSet[m] = true
|
||
}
|
||
layouts := map[string]bool{}
|
||
for _, slide := range slideTargets {
|
||
if err := ooxmlWellFormed(zr, slide); err != nil {
|
||
return err
|
||
}
|
||
rels, err := ooxmlRels(zr, slide)
|
||
if err != nil {
|
||
return fmt.Errorf("幻灯片 %s:%v", slide, err)
|
||
}
|
||
layout := ""
|
||
for _, rel := range rels {
|
||
if strings.HasSuffix(rel.typ, "/slideLayout") {
|
||
layout = ooxmlResolve(slide, rel.target)
|
||
break
|
||
}
|
||
}
|
||
if layout == "" {
|
||
return fmt.Errorf("幻灯片 %s 没有指向 slideLayout 的关系——每张 slide 必须经关系表引用一个版式", slide)
|
||
}
|
||
if layouts[layout] {
|
||
continue
|
||
}
|
||
layouts[layout] = true
|
||
if err := ooxmlWellFormed(zr, layout); err != nil {
|
||
return err
|
||
}
|
||
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, themes map[string]bool) error {
|
||
if err := ooxmlWellFormed(zr, master); err != nil {
|
||
return err
|
||
}
|
||
rels, err := ooxmlRels(zr, master)
|
||
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") {
|
||
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 校验版式的关系表引用了存在的、且被 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") {
|
||
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()
|
||
// xml 前缀由 XML 规范预绑定。Go 解码器把它展开成 URI,而不是留下前缀名。
|
||
out := map[string]bool{"xml": true, "xmlns": true, "http://www.w3.org/XML/1998/namespace": 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)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rc, err := f.Open()
|
||
if err != nil {
|
||
return fmt.Errorf("部件 %s 无法读取(%v)", part, err)
|
||
}
|
||
defer rc.Close()
|
||
dec := xml.NewDecoder(rc)
|
||
for {
|
||
_, err := dec.Token()
|
||
if err == io.EOF {
|
||
return nil
|
||
}
|
||
if err != nil {
|
||
return fmt.Errorf("部件 %s 不是合法 XML(%v)", part, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ooxmlRequireElement 确认部件内出现指定局部名的元素(不校验位置与属性)。
|
||
func ooxmlRequireElement(zr *zip.Reader, part, local string) error {
|
||
f, err := ooxmlPart(zr, part)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rc, err := f.Open()
|
||
if err != nil {
|
||
return fmt.Errorf("部件 %s 无法读取(%v)", part, err)
|
||
}
|
||
defer rc.Close()
|
||
dec := xml.NewDecoder(rc)
|
||
for {
|
||
tok, err := dec.Token()
|
||
if err == io.EOF {
|
||
break
|
||
}
|
||
if err != nil {
|
||
return fmt.Errorf("部件 %s 不是合法 XML(%v)", part, err)
|
||
}
|
||
if se, ok := tok.(xml.StartElement); ok && se.Name.Local == local {
|
||
return nil
|
||
}
|
||
}
|
||
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 {
|
||
if f.Name == name {
|
||
return f, nil
|
||
}
|
||
}
|
||
return nil, fmt.Errorf("缺少必备部件 %s", name)
|
||
}
|
||
|
||
// ooxmlRel 是 rels 部件里的一条关系:Id、类型 URI 与目标(原样保留相对形式)。
|
||
type ooxmlRel struct {
|
||
id string
|
||
typ string
|
||
target string
|
||
targetMode string
|
||
}
|
||
|
||
// ooxmlRels 解析 part 的关系表(<dir>/_rels/<base>.rels)。
|
||
func ooxmlRels(zr *zip.Reader, part string) ([]ooxmlRel, error) {
|
||
dir, base := path.Split(part)
|
||
relsPart := path.Join(dir, "_rels", base+".rels")
|
||
f, err := ooxmlPart(zr, relsPart)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%s 的关系表缺失(%v)", part, err)
|
||
}
|
||
rc, err := f.Open()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%s 无法读取(%v)", relsPart, err)
|
||
}
|
||
defer rc.Close()
|
||
var rels []ooxmlRel
|
||
dec := xml.NewDecoder(rc)
|
||
for {
|
||
tok, err := dec.Token()
|
||
if err == io.EOF {
|
||
break
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%s 不是合法 XML(%v)", relsPart, err)
|
||
}
|
||
se, ok := tok.(xml.StartElement)
|
||
if !ok || se.Name.Local != "Relationship" {
|
||
continue
|
||
}
|
||
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":
|
||
rel.target = attr.Value
|
||
}
|
||
}
|
||
rels = append(rels, rel)
|
||
}
|
||
return rels, nil
|
||
}
|
||
|
||
// ooxmlResolve 把关系目标解析为包内部件路径:绝对目标去掉前导 /,
|
||
// 相对目标基于源部件目录解析(path.Join 自带 .. 清理)。
|
||
func ooxmlResolve(srcPart, target string) string {
|
||
if strings.HasPrefix(target, "/") {
|
||
return strings.TrimPrefix(target, "/")
|
||
}
|
||
return path.Join(path.Dir(srcPart), target)
|
||
}
|