diff options
Diffstat (limited to 'internal/extract/zipxml_test.go')
| -rw-r--r-- | internal/extract/zipxml_test.go | 205 |
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&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) + } +} |
