Files
relata/internal/parsing/sniff.go
T
2026-08-11 10:40:10 +01:00

199 lines
5.7 KiB
Go

package parsing
import (
"archive/zip"
"bytes"
"errors"
"net/http"
"path/filepath"
"regexp"
"strings"
"unicode/utf8"
)
// Errors returned by MIME resolution.
var (
// ErrArchiveRejected is returned for archive formats, which are out of scope
// for the MVP. Accepting them would mean walking untrusted archive entries,
// with the zip-slip and decompression-bomb handling that implies.
ErrArchiveRejected = errors.New("archive formats are not accepted")
// ErrExecutableRejected is returned for executable content.
ErrExecutableRejected = errors.New("executable content is not accepted")
)
// SupportedMIMETypes are the types Relata will ingest.
var SupportedMIMETypes = []string{
"text/plain",
"text/markdown",
"application/json",
"application/pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
}
const docxMIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
// SniffResult is the outcome of content-based type detection.
type SniffResult struct {
// MIME is the resolved type, decided primarily from file content.
MIME string
// Declared is what the client claimed, retained for auditing.
Declared string
// FromContent reports whether MIME came from magic bytes rather than a hint.
FromContent bool
}
// Sniff resolves the MIME type of data, preferring content inspection over the
// client-supplied type and filename. A browser-supplied Content-Type is a hint,
// never an authority: a .pdf named file containing a zip must be seen as a zip.
func Sniff(data []byte, declared, filename string) (SniffResult, error) {
res := SniffResult{Declared: declared}
// 1. Unambiguous magic numbers win outright.
switch {
case bytes.HasPrefix(data, []byte("%PDF-")):
res.MIME, res.FromContent = "application/pdf", true
return res, nil
case isELFOrPE(data):
return res, ErrExecutableRejected
case bytes.HasPrefix(data, []byte{0x1f, 0x8b}): // gzip
return res, ErrArchiveRejected
case bytes.HasPrefix(data, []byte("BZh")): // bzip2
return res, ErrArchiveRejected
case bytes.HasPrefix(data, []byte{0xfd, '7', 'z', 'X', 'Z', 0x00}): // xz
return res, ErrArchiveRejected
case bytes.HasPrefix(data, []byte("Rar!")): // rar
return res, ErrArchiveRejected
case bytes.HasPrefix(data, []byte{0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C}): // 7z
return res, ErrArchiveRejected
}
// 2. ZIP containers: DOCX is a zip, but so is every archive we reject.
// Distinguish by looking for the OOXML word part, not by extension.
if bytes.HasPrefix(data, []byte("PK\x03\x04")) || bytes.HasPrefix(data, []byte("PK\x05\x06")) {
if isDOCXZip(data) {
res.MIME, res.FromContent = docxMIME, true
return res, nil
}
return res, ErrArchiveRejected
}
// 3. Textual formats. Require valid UTF-8 without NUL bytes before treating
// anything as text, so binary content cannot masquerade as a note.
if !looksTextual(data) {
detected := http.DetectContentType(data)
res.MIME = detected
res.FromContent = true
return res, ErrUnsupportedType
}
trimmed := bytes.TrimSpace(data)
if looksJSON(trimmed) {
res.MIME, res.FromContent = "application/json", true
return res, nil
}
// Between plain text and markdown the distinction is stylistic, so the
// filename and declared type are allowed to decide.
ext := strings.ToLower(filepath.Ext(filename))
switch {
case ext == ".md" || ext == ".markdown":
res.MIME = "text/markdown"
case declared == "text/markdown" || declared == "text/x-markdown":
res.MIME = "text/markdown"
case ext == ".json" || declared == "application/json":
// Declared JSON that does not parse is still stored, but as text, with
// the mismatch visible to the user.
res.MIME = "text/plain"
case looksMarkdown(trimmed):
res.MIME, res.FromContent = "text/markdown", true
default:
res.MIME = "text/plain"
}
return res, nil
}
// Supported reports whether Relata has a parser for mime.
func Supported(mime string) bool {
for _, m := range SupportedMIMETypes {
if m == mime {
return true
}
}
return mime == "text/x-markdown" || mime == "text/json"
}
func isELFOrPE(data []byte) bool {
return bytes.HasPrefix(data, []byte{0x7f, 'E', 'L', 'F'}) ||
bytes.HasPrefix(data, []byte("MZ")) ||
bytes.HasPrefix(data, []byte("#!")) ||
bytes.HasPrefix(data, []byte{0xCA, 0xFE, 0xBA, 0xBE})
}
// isDOCXZip reports whether the zip contains the OOXML main document part.
// It reads only the central directory, so a malformed archive costs little.
func isDOCXZip(data []byte) bool {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return false
}
for _, f := range zr.File {
if f.Name == "word/document.xml" {
return true
}
}
return false
}
// looksTextual reports whether data is plausibly text: valid UTF-8, no NULs,
// and few control characters.
func looksTextual(data []byte) bool {
sample := data
if len(sample) > 8192 {
sample = sample[:8192]
// Trim to a rune boundary so a split multi-byte rune is not misread.
for len(sample) > 0 && !utf8.RuneStart(sample[len(sample)-1]) {
sample = sample[:len(sample)-1]
}
}
if bytes.IndexByte(sample, 0) >= 0 {
return false
}
if !utf8.Valid(sample) {
return false
}
ctrl := 0
for _, b := range sample {
if b < 0x09 || (b > 0x0d && b < 0x20) {
ctrl++
}
}
return len(sample) == 0 || ctrl*100/max(len(sample), 1) < 2
}
func looksJSON(t []byte) bool {
if len(t) == 0 {
return false
}
if t[0] != '{' && t[0] != '[' {
return false
}
return validJSON(t)
}
var mdSignals = regexp.MustCompile(`(?m)^(#{1,6}\s+\S|[-*+]\s+\S|\d+\.\s+\S|>\s+\S|` + "```" + `)`)
func looksMarkdown(t []byte) bool {
sample := t
if len(sample) > 4096 {
sample = sample[:4096]
}
return len(mdSignals.FindAll(sample, 3)) >= 2
}
func max(a, b int) int {
if a > b {
return a
}
return b
}