summaryrefslogtreecommitdiff
path: root/internal/extract/zipxml_test.go
blob: 5b7ba54475a8a86b148f0b75f98bea23966ec254 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
// 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: 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)
	// Partly read: the text is kept, and ErrPartial says some of it is
	// missing, so a keyword not found in it is unknown.
	if !errors.Is(err, ErrPartial) {
		t.Fatalf("good entry alongside a malformed one: err %v, want ErrPartial", 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: 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)
	}
}