// 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": `Acme LtdFakturaVAT`,
"word/footer1.xml": `NIP 0000000000`,
"word/styles.xml": `NOT-INCLUDED`,
}, []string{"Acme Ltd", "Faktura VAT", "NIP 0000000000"}},
{"sheet.xlsx", map[string]string{
"xl/sharedStrings.xml": `Invoiceacme ltd`,
"xl/worksheets/sheet1.xml": `01234567890inline text
`,
}, []string{"Invoice", "acme ltd", "1234567890", "inline text"}},
{"deck.pptx", map[string]string{
"ppt/slides/slide1.xml": `Quarterly report`,
}, []string{"Quarterly report"}},
{"letter.odt", map[string]string{
"content.xml": `FakturaVAT`,
"styles.xml": `header acme`,
}, []string{"Faktura VAT", "header acme"}},
{"book.epub", map[string]string{
"OEBPS/ch1.xhtml": `
Chapter one&two
`,
"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": `alpha`,
"xl/worksheets/sheet1.xml": `987654
`,
}), 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 := `` + strings.Repeat("x", 4096) + ``
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": `cover page`,
"Object 1/content.xml": `embedded chart acme`,
}), 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 ( where 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 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 ".
func TestZipLenientOnMalformedEntry(t *testing.T) {
e := newWithPath("")
got, err := text(t, e, zipFile(t, "partial.docx", map[string]string{
"word/document.xml": `good body text`,
"word/footer1.xml": `broken`,
}), 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": `) 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": ``,
"word/footer1.xml": `` + strings.Repeat("x", 4096) + ``
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)
}
}