package parsing
import (
"archive/zip"
"bytes"
"errors"
"strings"
"testing"
"github.com/andydixon/relata/internal/domain"
)
func hasWarning(warns []domain.ParseWarning, code string) bool {
for _, w := range warns {
if w.Code == code {
return true
}
}
return false
}
// --- MIME sniffing ---------------------------------------------------------
func TestSniffPrefersContentOverDeclaredType(t *testing.T) {
// A real PDF declared as plain text must still be parsed as a PDF.
res, err := Sniff([]byte("%PDF-1.7\n%âãÏÓ\n"), "text/plain", "notes.txt")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.MIME != "application/pdf" {
t.Fatalf("content-based sniffing failed: got %q", res.MIME)
}
if !res.FromContent {
t.Fatal("result should be marked as content-derived")
}
}
func TestSniffRejectsArchivesDisguisedAsDocuments(t *testing.T) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
w, _ := zw.Create("payload.sh")
_, _ = w.Write([]byte("rm -rf /"))
_ = zw.Close()
// Claimed as a DOCX, with a .docx name, but it has no OOXML part.
_, err := Sniff(buf.Bytes(), docxMIME, "report.docx")
if !errors.Is(err, ErrArchiveRejected) {
t.Fatalf("archive masquerading as docx was accepted: %v", err)
}
}
func TestSniffAcceptsGenuineDOCX(t *testing.T) {
data := buildDOCX(t, `Hello`)
res, err := Sniff(data, "application/octet-stream", "anything.bin")
if err != nil {
t.Fatalf("genuine docx rejected: %v", err)
}
if res.MIME != docxMIME {
t.Fatalf("got %q, want docx", res.MIME)
}
}
func TestSniffRejectsExecutables(t *testing.T) {
for name, payload := range map[string][]byte{
"elf": {0x7f, 'E', 'L', 'F', 2, 1, 1, 0},
"pe": []byte("MZ\x90\x00\x03"),
"script": []byte("#!/bin/sh\necho hi\n"),
"macho": {0xCA, 0xFE, 0xBA, 0xBE, 0, 0, 0, 2},
} {
if _, err := Sniff(payload, "text/plain", name+".txt"); !errors.Is(err, ErrExecutableRejected) {
t.Fatalf("%s payload was not rejected: %v", name, err)
}
}
}
func TestSniffRejectsCompressedFormats(t *testing.T) {
cases := map[string][]byte{
"gzip": {0x1f, 0x8b, 0x08},
"bzip2": []byte("BZh9"),
"xz": {0xfd, '7', 'z', 'X', 'Z', 0x00},
"rar": []byte("Rar!\x1a\x07"),
"7z": {0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C},
}
for name, payload := range cases {
if _, err := Sniff(payload, "application/octet-stream", name); !errors.Is(err, ErrArchiveRejected) {
t.Fatalf("%s was not rejected: %v", name, err)
}
}
}
func TestSniffRejectsBinaryPosingAsText(t *testing.T) {
binary := append([]byte("looks like text at first"), 0x00, 0x01, 0x02, 0xff, 0xfe)
binary = append(binary, bytes.Repeat([]byte{0x00}, 200)...)
if _, err := Sniff(binary, "text/plain", "notes.txt"); !errors.Is(err, ErrUnsupportedType) {
t.Fatalf("binary content accepted as text: %v", err)
}
}
func TestSniffDetectsJSONByContent(t *testing.T) {
res, err := Sniff([]byte(`{"cost": 42000, "vendor": "Example Ltd"}`), "text/plain", "data.txt")
if err != nil {
t.Fatal(err)
}
if res.MIME != "application/json" {
t.Fatalf("got %q, want application/json", res.MIME)
}
}
func TestSniffTreatsInvalidJSONAsText(t *testing.T) {
// Declared JSON that does not parse must still be stored, as text.
res, err := Sniff([]byte(`{"broken": `), "application/json", "data.json")
if err != nil {
t.Fatal(err)
}
if res.MIME != "text/plain" {
t.Fatalf("got %q, want text/plain fallback", res.MIME)
}
}
// --- Text ------------------------------------------------------------------
func TestTextParagraphOffsetsAddressTheirOwnText(t *testing.T) {
body := "First paragraph here.\n\nSecond paragraph here.\n\nThird one."
doc, err := Parse(Input{Data: []byte(body), MIMEType: "text/plain"})
if err != nil {
t.Fatal(err)
}
if len(doc.Blocks) != 3 {
t.Fatalf("expected 3 paragraphs, got %d", len(doc.Blocks))
}
for i, b := range doc.Blocks {
if doc.Text[b.Start:b.End] != b.Text {
t.Fatalf("block %d offsets do not match its text", i)
}
}
}
func TestTextPreservesContentExactly(t *testing.T) {
body := "Line one\nLine two\n\nAfter blank"
doc, _ := Parse(Input{Data: []byte(body), MIMEType: "text/plain"})
if doc.Text != body {
t.Fatalf("text was rewritten:\n got %q\nwant %q", doc.Text, body)
}
}
func TestEmailHeadersBecomeTheirOwnBlock(t *testing.T) {
body := "From: alice@example.com\nTo: bob@example.com\nSubject: Delivery date\nDate: Mon, 3 Mar 2025 09:00:00 +0000\n\nThe date has moved to 14 March."
doc, err := Parse(Input{Data: []byte(body), MIMEType: "text/plain"})
if err != nil {
t.Fatal(err)
}
if len(doc.Blocks) == 0 || doc.Blocks[0].Section != "headers" {
t.Fatal("email headers were not recognised as a header block")
}
if !strings.Contains(doc.Blocks[0].Text, "Subject: Delivery date") {
t.Fatal("header block lost the subject line")
}
}
// --- Markdown --------------------------------------------------------------
func TestMarkdownBuildsSectionPaths(t *testing.T) {
body := "# Policy\n\nIntro text.\n\n## Delivery\n\nDelivery text.\n\n### Exceptions\n\nException text.\n"
doc, err := Parse(Input{Data: []byte(body), MIMEType: "text/markdown"})
if err != nil {
t.Fatal(err)
}
var deepest string
for _, b := range doc.Blocks {
if strings.Contains(b.Text, "Exception text") {
deepest = b.Section
}
}
if deepest != "Policy > Delivery > Exceptions" {
t.Fatalf("section path wrong: %q", deepest)
}
}
func TestMarkdownIsNeverConvertedToHTML(t *testing.T) {
// Markdown containing raw HTML must come out as literal text; Relata never
// renders uploaded markup.
body := "# Title\n\n\n\n[link](javascript:alert(2))\n"
doc, err := Parse(Input{Data: []byte(body), MIMEType: "text/markdown"})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(doc.Text, "") {
t.Fatal("content was altered by the parser")
}
for _, b := range doc.Blocks {
if b.Kind == BlockCode {
continue
}
if strings.Contains(b.Text, "") {
t.Fatal("parser produced markup")
}
}
}
func TestMarkdownListGroupsStayTogether(t *testing.T) {
body := "Intro.\n\n- first item\n- second item\n- third item\n\nOutro.\n"
doc, err := Parse(Input{Data: []byte(body), MIMEType: "text/markdown"})
if err != nil {
t.Fatal(err)
}
var list *Block
for i := range doc.Blocks {
if doc.Blocks[i].Kind == BlockList {
list = &doc.Blocks[i]
}
}
if list == nil {
t.Fatal("no list block produced")
}
for _, want := range []string{"first item", "second item", "third item"} {
if !strings.Contains(list.Text, want) {
t.Fatalf("list block missing %q", want)
}
}
}
// --- JSON ------------------------------------------------------------------
func TestJSONFlattensToReadablePaths(t *testing.T) {
body := `{"project":{"name":"Atlas","cost":42000},"vendor":"Example Ltd"}`
doc, err := Parse(Input{Data: []byte(body), MIMEType: "application/json"})
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"project.name: Atlas", "project.cost: 42000", "vendor: Example Ltd"} {
if !strings.Contains(doc.Text, want) {
t.Fatalf("flattened output missing %q:\n%s", want, doc.Text)
}
}
for i, b := range doc.Blocks {
if doc.Text[b.Start:b.End] != b.Text {
t.Fatalf("json block %d offsets do not match its text", i)
}
}
}
func TestJSONDepthIsBounded(t *testing.T) {
// Deeply nested input must not recurse without limit.
deep := strings.Repeat(`{"a":`, 500) + `1` + strings.Repeat(`}`, 500)
doc, err := Parse(Input{Data: []byte(deep), MIMEType: "application/json", MaxJSONDepth: 10})
if err != nil {
t.Fatalf("deep json should parse with a warning, not fail: %v", err)
}
if !hasWarning(doc.Warnings, domain.WarnJSONDepthExceeded) {
t.Fatal("depth limit was hit but not reported to the user")
}
}
func TestJSONRejectsTrailingData(t *testing.T) {
_, err := Parse(Input{Data: []byte(`{"a":1} {"b":2}`), MIMEType: "application/json"})
if err == nil {
t.Fatal("two concatenated documents were accepted as one")
}
}
func TestJSONOutputIsDeterministic(t *testing.T) {
body := `{"z":1,"a":2,"m":3}`
first, _ := Parse(Input{Data: []byte(body), MIMEType: "application/json"})
second, _ := Parse(Input{Data: []byte(body), MIMEType: "application/json"})
if first.Text != second.Text {
t.Fatal("flattening is not deterministic, so content hashes would be unstable")
}
}
func TestJSONKeysCannotInjectLineBreaks(t *testing.T) {
body := "{\"evil\\nkey: injected\": \"x\"}"
doc, err := Parse(Input{Data: []byte(body), MIMEType: "application/json"})
if err != nil {
t.Fatal(err)
}
// The crafted key must not create a second logical line.
if strings.Count(doc.Text, "\n") != 0 {
t.Fatalf("newline injected via object key:\n%q", doc.Text)
}
}
// --- PDF -------------------------------------------------------------------
func TestMalformedPDFFailsCleanlyWithoutPanic(t *testing.T) {
cases := map[string][]byte{
"header only": []byte("%PDF-1.4\n"),
"truncated": []byte("%PDF-1.4\n1 0 obj\n<< /Type /Catalog"),
"garbage body": append([]byte("%PDF-1.4\n"), bytes.Repeat([]byte{0xff, 0x00, 0xAB}, 500)...),
"fake xref": []byte("%PDF-1.4\ntrailer\n<< /Size 99999999 >>\nstartxref\n999999\n%%EOF"),
"negative length": []byte("%PDF-1.5\n1 0 obj\n<< /Length -5 >>\nstream\nxx\nendstream\nendobj\n"),
}
for name, data := range cases {
t.Run(name, func(t *testing.T) {
doc, err := Parse(Input{Data: data, MIMEType: "application/pdf"})
// Either outcome is acceptable; a panic or hang is not.
if err != nil {
if doc == nil || !hasWarning(doc.Warnings, domain.WarnParseFailed) {
t.Fatal("parse failure was not recorded as a warning")
}
return
}
if doc == nil {
t.Fatal("nil document with nil error")
}
})
}
}
// --- DOCX ------------------------------------------------------------------
func buildDOCX(t *testing.T, bodyXML string) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
w, err := zw.Create("word/document.xml")
if err != nil {
t.Fatal(err)
}
xml := `
` +
bodyXML + ``
if _, err := w.Write([]byte(xml)); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func TestDOCXExtractsParagraphsAndHeadings(t *testing.T) {
body := `Delivery policy` +
`The revised date is 14 March 2025.` +
`Exceptions` +
`Contact operations.`
doc, err := Parse(Input{Data: buildDOCX(t, body), MIMEType: docxMIME})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(doc.Text, "14 March 2025") {
t.Fatalf("body text missing:\n%s", doc.Text)
}
var headings int
var deepSection string
for _, b := range doc.Blocks {
if b.Kind == BlockHeading {
headings++
}
if strings.Contains(b.Text, "Contact operations") {
deepSection = b.Section
}
}
if headings != 2 {
t.Fatalf("expected 2 headings, got %d", headings)
}
if deepSection != "Delivery policy > Exceptions" {
t.Fatalf("section path wrong: %q", deepSection)
}
for i, b := range doc.Blocks {
if doc.Text[b.Start:b.End] != b.Text {
t.Fatalf("docx block %d offsets do not match its text", i)
}
}
}
func TestDOCXSplitRunsAreJoined(t *testing.T) {
// Word routinely splits a sentence across runs; they must rejoin.
body := `The cost is £42,000 excluding VAT.`
doc, err := Parse(Input{Data: buildDOCX(t, body), MIMEType: docxMIME})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(doc.Text, "The cost is £42,000 excluding VAT.") {
t.Fatalf("runs were not joined: %q", doc.Text)
}
}
func TestDOCXWithoutMainPartIsRejected(t *testing.T) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
w, _ := zw.Create("word/other.xml")
_, _ = w.Write([]byte(""))
_ = zw.Close()
if _, err := Parse(Input{Data: buf.Bytes(), MIMEType: docxMIME}); err == nil {
t.Fatal("a zip without word/document.xml was accepted as a docx")
}
}
func TestDOCXDoesNotResolveExternalEntities(t *testing.T) {
// An XXE attempt must not read a local file or make a network call.
body := `&xxe;`
xml := `]>` +
`` + body + ``
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
w, _ := zw.Create("word/document.xml")
_, _ = w.Write([]byte(xml))
_ = zw.Close()
doc, err := Parse(Input{Data: buf.Bytes(), MIMEType: docxMIME})
if err == nil && doc != nil {
if strings.Contains(doc.Text, "root:") || strings.Contains(doc.Text, "/bin/") {
t.Fatal("external entity was resolved: local file contents leaked into extracted text")
}
}
}
// --- Limits and quality ----------------------------------------------------
func TestTextTruncationIsReportedAndOffsetsStayValid(t *testing.T) {
body := strings.Repeat("Long content that goes on and on. ", 500)
doc, err := Parse(Input{Data: []byte(body), MIMEType: "text/plain", MaxTextBytes: 500})
if err != nil {
t.Fatal(err)
}
if len(doc.Text) > 500 {
t.Fatalf("text not truncated: %d bytes", len(doc.Text))
}
if !hasWarning(doc.Warnings, domain.WarnTruncatedText) {
t.Fatal("truncation was not reported")
}
for i, b := range doc.Blocks {
if b.End > len(doc.Text) || doc.Text[b.Start:b.End] != b.Text {
t.Fatalf("block %d survived truncation with bad offsets", i)
}
}
}
func TestLowExtractableTextIsReported(t *testing.T) {
// A large file yielding almost no letters looks like a scan.
doc := &Document{Text: " \n x \n "}
checkExtractionQuality(doc, 500_000)
if !hasWarning(doc.Warnings, domain.WarnLowExtractableText) {
t.Fatal("a scan-like document was not flagged")
}
}
func TestUnsupportedTypeIsRejected(t *testing.T) {
_, err := Parse(Input{Data: []byte("data"), MIMEType: "image/png"})
if !errors.Is(err, ErrUnsupportedType) {
t.Fatalf("expected ErrUnsupportedType, got %v", err)
}
}
// --- Fuzzing ---------------------------------------------------------------
// FuzzParseText exercises the text and markdown paths for panics and for
// offsets that do not address their own text.
func FuzzParseText(f *testing.F) {
f.Add("hello world")
f.Add("# heading\n\n- a\n- b\n\n```\ncode\n```\n")
f.Add("From: a@b\nTo: c@d\n\nbody")
f.Add("\x00\x01binary-ish")
f.Add(strings.Repeat("a\n\n", 100))
f.Fuzz(func(t *testing.T, s string) {
for _, mime := range []string{"text/plain", "text/markdown"} {
doc, err := Parse(Input{Data: []byte(s), MIMEType: mime, MaxTextBytes: 4096})
if err != nil {
continue
}
for _, b := range doc.Blocks {
if b.Start < 0 || b.End > len(doc.Text) || b.Start > b.End {
t.Fatalf("block offsets [%d,%d) out of range for %d bytes (mime %s)",
b.Start, b.End, len(doc.Text), mime)
}
if doc.Text[b.Start:b.End] != b.Text {
t.Fatalf("block text/offset mismatch (mime %s)", mime)
}
}
}
})
}
// FuzzParseJSON exercises the JSON flattener, which handles the most
// structurally adversarial input Relata accepts as a first-class format.
func FuzzParseJSON(f *testing.F) {
f.Add(`{"a":1}`)
f.Add(`[1,[2,[3,[4]]]]`)
f.Add(`{"":{"":{"":null}}}`)
f.Add(`{"a":"\u0000\n\r"}`)
f.Fuzz(func(t *testing.T, s string) {
doc, err := Parse(Input{Data: []byte(s), MIMEType: "application/json", MaxJSONDepth: 8, MaxTextBytes: 8192})
if err != nil {
return
}
for _, b := range doc.Blocks {
if b.Start < 0 || b.End > len(doc.Text) || b.Start > b.End {
t.Fatalf("json block offsets [%d,%d) out of range for %d bytes", b.Start, b.End, len(doc.Text))
}
if doc.Text[b.Start:b.End] != b.Text {
t.Fatal("json block text/offset mismatch")
}
}
})
}
// FuzzSniff checks that type detection never panics and never accepts an
// archive or executable, whatever the input.
func FuzzSniff(f *testing.F) {
f.Add([]byte("%PDF-1.4"), "application/pdf", "a.pdf")
f.Add([]byte("PK\x03\x04"), "application/zip", "a.zip")
f.Add([]byte("{}"), "application/json", "a.json")
f.Fuzz(func(t *testing.T, data []byte, declared, name string) {
res, err := Sniff(data, declared, name)
if err != nil {
return
}
if res.MIME != "" && !Supported(res.MIME) {
t.Fatalf("sniff accepted an unsupported type: %q", res.MIME)
}
})
}