From 3b36a48b7ce5a53a9366f3b31f94311f178e2553 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Sat, 12 Sep 2026 01:22:12 +0200 Subject: krino: matching — scan, ignore, conditions, extraction, duplicates, explain, dry run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/extract/zipxml_test.go | 205 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 internal/extract/zipxml_test.go (limited to 'internal/extract/zipxml_test.go') 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": `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) + } +} -- cgit v1.3