aboutsummaryrefslogtreecommitdiff
path: root/internal/extract/zipxml_test.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 01:22:12 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 01:22:12 +0200
commit3b36a48b7ce5a53a9366f3b31f94311f178e2553 (patch)
treeecbb277ff916b719f2ee45fba017792b85d5faf9 /internal/extract/zipxml_test.go
parent42b02c47be9b285099203e44a2570636d4ca6f03 (diff)
downloadkrino-3b36a48b7ce5a53a9366f3b31f94311f178e2553.tar.gz
krino-3b36a48b7ce5a53a9366f3b31f94311f178e2553.zip
krino: matching — scan, ignore, conditions, extraction, duplicates, explain, dry run
Diffstat (limited to 'internal/extract/zipxml_test.go')
-rw-r--r--internal/extract/zipxml_test.go205
1 files changed, 205 insertions, 0 deletions
diff --git a/internal/extract/zipxml_test.go b/internal/extract/zipxml_test.go
new file mode 100644
index 0000000..14ead80
--- /dev/null
+++ b/internal/extract/zipxml_test.go
@@ -0,0 +1,205 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package extract
+
+import (
+ "archive/zip"
+ "bytes"
+ "errors"
+ "strings"
+ "testing"
+)
+
+// zipFile builds an archive from name -> content pairs and writes it to disk.
+func zipFile(t *testing.T, name string, entries map[string]string) string {
+ t.Helper()
+ var buf bytes.Buffer
+ w := zip.NewWriter(&buf)
+ for n, c := range entries {
+ f, err := w.Create(n)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.Write([]byte(c)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := w.Close(); err != nil {
+ t.Fatal(err)
+ }
+ return file(t, name, buf.Bytes())
+}
+
+func TestZipFormats(t *testing.T) {
+ e := newWithPath("")
+ tests := []struct {
+ name string
+ entries map[string]string
+ want []string
+ }{
+ {"inv.docx", map[string]string{
+ "word/document.xml": `<w:document xmlns:w="w"><w:body><w:p><w:r><w:t>Ac</w:t></w:r><w:r><w:t>me Ltd</w:t></w:r></w:p><w:p><w:r><w:t>Faktura</w:t><w:tab/><w:t>VAT</w:t></w:r></w:p></w:body></w:document>`,
+ "word/footer1.xml": `<w:ftr xmlns:w="w"><w:p><w:r><w:t>NIP 0000000000</w:t></w:r></w:p></w:ftr>`,
+ "word/styles.xml": `<w:styles xmlns:w="w"><w:t>NOT-INCLUDED</w:t></w:styles>`,
+ }, []string{"Acme Ltd", "Faktura VAT", "NIP 0000000000"}},
+ {"sheet.xlsx", map[string]string{
+ "xl/sharedStrings.xml": `<sst><si><t>Invoice</t></si><si><t>acme ltd</t></si></sst>`,
+ "xl/worksheets/sheet1.xml": `<worksheet><sheetData><row><c t="s"><v>0</v></c><c><v>1234567890</v></c><c t="inlineStr"><is><t>inline text</t></is></c></row></sheetData></worksheet>`,
+ }, []string{"Invoice", "acme ltd", "1234567890", "inline text"}},
+ {"deck.pptx", map[string]string{
+ "ppt/slides/slide1.xml": `<p:sld xmlns:p="p" xmlns:a="a"><a:p><a:r><a:t>Quarterly report</a:t></a:r></a:p></p:sld>`,
+ }, []string{"Quarterly report"}},
+ {"letter.odt", map[string]string{
+ "content.xml": `<office:document-content xmlns:office="o" xmlns:text="t"><text:p>Faktura<text:s/>VAT</text:p></office:document-content>`,
+ "styles.xml": `<office:document-styles xmlns:office="o" xmlns:text="t"><text:p>header acme</text:p></office:document-styles>`,
+ }, []string{"Faktura VAT", "header acme"}},
+ {"book.epub", map[string]string{
+ "OEBPS/ch1.xhtml": `<html xmlns="h"><body><p>Chapter one&amp;two</p></body></html>`,
+ "mimetype": `application/epub+zip`,
+ }, []string{"Chapter one&two"}},
+ }
+ for _, tt := range tests {
+ got, err := text(t, e, zipFile(t, tt.name, tt.entries), 0)
+ if err != nil {
+ t.Errorf("%s: %v", tt.name, err)
+ continue
+ }
+ for _, w := range tt.want {
+ if !strings.Contains(got, w) {
+ t.Errorf("%s: text %q lacks %q", tt.name, got, w)
+ }
+ }
+ if strings.Contains(got, "NOT-INCLUDED") {
+ t.Errorf("%s: read an entry it should skip: %q", tt.name, got)
+ }
+ }
+}
+
+func TestSharedStringIndexNotText(t *testing.T) {
+ e := newWithPath("")
+ got, err := text(t, e, zipFile(t, "s.xlsx", map[string]string{
+ "xl/sharedStrings.xml": `<sst><si><t>alpha</t></si></sst>`,
+ "xl/worksheets/sheet1.xml": `<worksheet><sheetData><row><c t="s"><v>987654</v></c></row></sheetData></worksheet>`,
+ }), 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(got, "987654") {
+ t.Fatalf("shared-string index leaked into text: %q", got)
+ }
+}
+
+func TestZipCorruptAndBudget(t *testing.T) {
+ e := newWithPath("")
+ if _, err := text(t, e, file(t, "broken.docx", []byte("not a zip at all")), 0); err == nil || errors.Is(err, ErrUnsupported) {
+ t.Errorf("corrupt docx: %v, want a zip error", err)
+ }
+ old := zipBudget
+ zipBudget = 1 << 10
+ defer func() { zipBudget = old }()
+ big := `<w:document xmlns:w="w"><w:p><w:t>` + strings.Repeat("x", 4096) + `</w:t></w:p></w:document>`
+ if _, err := text(t, e, zipFile(t, "huge.docx", map[string]string{"word/document.xml": big}), 0); !errors.Is(err, ErrTooLarge) {
+ t.Errorf("over budget: %v, want ErrTooLarge", err)
+ }
+}
+
+// TestOdfEmbeddedObjectIncluded: an ODF embedded object (a chart, a
+// formula) keeps its own content.xml inside a subdirectory such as
+// "Object 1/"; matchEntry's base-name fallback picks it up alongside the
+// document's own content.xml, deliberately — that embedded text is text
+// the document shows its reader.
+func TestOdfEmbeddedObjectIncluded(t *testing.T) {
+ e := newWithPath("")
+ got, err := text(t, e, zipFile(t, "embed.odt", map[string]string{
+ "content.xml": `<office:document-content xmlns:office="o" xmlns:text="t"><text:p>cover page</text:p></office:document-content>`,
+ "Object 1/content.xml": `<office:document-content xmlns:office="o" xmlns:text="t"><text:p>embedded chart acme</text:p></office:document-content>`,
+ }), 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(got, "embedded chart acme") {
+ t.Errorf("embedded object text missing: %q", got)
+ }
+}
+
+// TestZipLenientOnMalformedEntry: a malformed entry must not blank out
+// text already read from a good entry in the same archive, and the text
+// the malformed entry itself yielded before its own error is kept too.
+// The footer's mismatched end tag (</w:xyz> where </w:ftr> was open)
+// genuinely fails to parse under Strict=false — unlike a merely missing
+// end tag, which the decoder synthesises and swallows — but only after
+// "broken" and the newline for </w:p> have already been written; the
+// XML decoder itself confirms this token by token (see fix round 2's
+// report for the trace): CharData "broken", EndElement p, then the
+// error "unexpected end element </xyz>".
+func TestZipLenientOnMalformedEntry(t *testing.T) {
+ e := newWithPath("")
+ got, err := text(t, e, zipFile(t, "partial.docx", map[string]string{
+ "word/document.xml": `<w:document xmlns:w="w"><w:body><w:p><w:r><w:t>good body text</w:t></w:r></w:p></w:body></w:document>`,
+ "word/footer1.xml": `<w:ftr xmlns:w="w"><w:p><w:r><w:t>broken</w:t></w:r></w:p></w:xyz>`,
+ }), 0)
+ if err != nil {
+ t.Fatalf("good entry alongside a malformed one: %v", err)
+ }
+ if !strings.Contains(got, "good body text") {
+ t.Errorf("text from the good entry was discarded: %q", got)
+ }
+ if !strings.Contains(got, "broken") {
+ t.Errorf("text the malformed entry yielded before its own error was discarded: %q", got)
+ }
+}
+
+// TestZipMalformedOnlyEntryErrors: when the only matched entry is
+// malformed, no text survives to return, so the error is reported instead
+// — named after the entry it came from. The malformed attribute syntax
+// breaks the decode before any character data is ever emitted, so there
+// is nothing for the lenient path (TestZipLenientOnMalformedEntry) to
+// keep.
+func TestZipMalformedOnlyEntryErrors(t *testing.T) {
+ e := newWithPath("")
+ _, err := text(t, e, zipFile(t, "bad.docx", map[string]string{
+ "word/document.xml": `<w:document><w:body attr="unterminated><w:p><w:t>oops</w:t></w:p></w:body></w:document>`,
+ }), 0)
+ if err == nil {
+ t.Fatal("malformed only entry: want an error, got nil")
+ }
+ if !strings.Contains(err.Error(), "word/document.xml") {
+ t.Errorf("error %q does not name the entry", err.Error())
+ }
+}
+
+// TestZipNoTextAndFailingSiblingErrors: a well-formed entry that simply
+// has no character data (an empty <w:body/>) must not count as "text was
+// found" and mask a failing sibling's error — the separator newline
+// zipText appends after every successful entry means b.Len() alone
+// cannot answer "did anything write text"; only word/footer1.xml's own
+// contribution (zero, since it fails while still inside its opening tag,
+// before any token is emitted) may be counted, and it contributed
+// nothing either, so the archive as a whole produced no text and the
+// wrapped error must surface.
+func TestZipNoTextAndFailingSiblingErrors(t *testing.T) {
+ e := newWithPath("")
+ _, err := text(t, e, zipFile(t, "empty.docx", map[string]string{
+ "word/document.xml": `<w:document xmlns:w="w"><w:body/></w:document>`,
+ "word/footer1.xml": `<w:ftr xmlns:w="w" a="unterminated><w:p/></w:ftr>`,
+ }), 0)
+ if err == nil {
+ t.Fatal("textless entry plus a failing sibling: want an error, got nil")
+ }
+ if !strings.Contains(err.Error(), "word/footer1.xml") {
+ t.Errorf("error %q does not name the failing entry", err.Error())
+ }
+}
+
+// TestMaxReadCapsZipOutput: B1. A directory's max-read, when smaller than
+// the fixed zipBudget default, caps a single archive's extracted text on
+// its own — zipBudget is left at its default, so only budget(zipBudget,
+// maxRead) picking the smaller maxRead explains the result.
+func TestMaxReadCapsZipOutput(t *testing.T) {
+ e := newWithPath("")
+ big := `<w:document xmlns:w="w"><w:p><w:t>` + strings.Repeat("x", 4096) + `</w:t></w:p></w:document>`
+ p := zipFile(t, "huge.docx", map[string]string{"word/document.xml": big})
+ if _, err := text(t, e, p, 1024); !errors.Is(err, ErrTooLarge) {
+ t.Fatalf("got %v, want ErrTooLarge (max-read 1024 should have capped a 4096-byte entry)", err)
+ }
+}