// Package docextract turns a downloaded federal attachment into plain text for the deterministic // mission and labor classifier. It is pure Go with no cgo and no network, so it builds under // CGO_ENABLED=0 and GOFIPS140=v1.0.0 and never reaches a Python tool. It dispatches on the file // extension: PDF through a pure Go reader, DOCX through the standard library zip and xml packages, // HTML through the x/net tokenizer, and plain text (or any unknown extension) as the raw bytes. // // This package is imported ONLY by the federalsignal sync path, never by cmd/api. The PDF reader drags // in stdlib crypto for encrypted PDF support (a dead path here, the attachments are public), so keeping // it out of the serving binary keeps the API SBOM clean. See docs/STACK_DECISIONS.md. package docextract import ( "bytes" "context" "errors" "fmt" "io" "strings" "archive/zip" "encoding/xml" "golang.org/x/net/html" "github.com/ledongthuc/pdf" ) // maxTextBytes caps the extracted text so one oversized attachment can never blow up memory or the // classifier. One megabyte of text is far more than any labor mix or mission keyword needs. const maxTextBytes = 1 << 20 // ErrUnsupported names the unsupported file type condition. ExtractText itself is permissive: an // unknown extension falls back to treating the bytes as plain text rather than failing, because a // federal attachment with an odd extension is more useful mined than dropped. ErrUnsupported is // exported so a stricter caller (or a future strict mode) can construct and branch on the typed error // without redefining it. var ErrUnsupported = errors.New("docextract: unsupported file type") // ErrParse wraps any failure to parse a structurally broken file (a corrupt PDF, a non zip DOCX, and so // on). Detect with errors.Is(err, ErrParse). The underlying cause is wrapped for diagnostics, the file // bytes themselves are never echoed so no attachment content leaks into a log. var ErrParse = errors.New("docextract: parse failed") // ExtractText returns the plain text of data, dispatched on the lowercased extension of filename. It // caps the result at maxTextBytes. On a corrupt or unsupported file it returns a typed error wrapping // ErrParse or ErrUnsupported. func ExtractText(ctx context.Context, filename string, data []byte) (string, error) { ext := lowerExt(filename) switch ext { case ".pdf": return capText(extractPDF(ctx, data)) case ".docx": return capText(extractDOCX(data)) case ".htm", ".html": return capText(extractHTML(data)) case ".txt", "": return capText(string(data), nil) default: // Unknown extensions fall back to treating the bytes as text rather than failing, since a federal // attachment with an odd extension (for example a .rtf saved as plain text) is more useful mined // than dropped. A caller that needs strictness can compare the extension before calling. return capText(string(data), nil) } } // lowerExt returns the lowercased extension of name including the leading dot, or empty if none. func lowerExt(name string) string { i := strings.LastIndexByte(name, '.') if i < 0 || i == len(name)-1 { return "" } // Reject a dot that is part of a path segment with no real extension (for example a trailing slash). if strings.ContainsAny(name[i+1:], "/\\") { return "" } return strings.ToLower(name[i:]) } // cap truncates s to maxTextBytes, on a UTF-8 rune boundary, and passes any error through unchanged. func capText(s string, err error) (string, error) { if err != nil { return "", err } if len(s) <= maxTextBytes { return s, nil } cut := maxTextBytes // Back up to a rune boundary so the cap never splits a multibyte rune. for cut > 0 && !utf8Start(s[cut]) { cut-- } return s[:cut], nil } // utf8Start reports whether b is the first byte of a UTF-8 sequence (not a continuation byte). func utf8Start(b byte) bool { return b&0xC0 != 0x80 } // extractPDF reads every page's plain text from a PDF held entirely in memory. func extractPDF(ctx context.Context, data []byte) (string, error) { if len(data) == 0 { return "", fmt.Errorf("%w: empty pdf", ErrParse) } r, err := pdf.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { // Wrap with ErrParse only, the cause may carry structural detail but never the file bytes. return "", fmt.Errorf("%w: pdf reader: %v", ErrParse, err) } rd, err := r.GetPlainText() if err != nil { return "", fmt.Errorf("%w: pdf text: %v", ErrParse, err) } // Bound the copy so a hostile or malformed PDF that claims a huge text length cannot exhaust memory. var sb strings.Builder if _, err := io.Copy(&sb, io.LimitReader(rd, maxTextBytes+1)); err != nil { return "", fmt.Errorf("%w: pdf read: %v", ErrParse, err) } _ = ctx return sb.String(), nil } // extractDOCX reads word/document.xml from the DOCX zip and joins the text runs with spaces. func extractDOCX(data []byte) (string, error) { zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { return "", fmt.Errorf("%w: docx zip: %v", ErrParse, err) } var doc *zip.File for _, f := range zr.File { if f.Name == "word/document.xml" { doc = f break } } if doc == nil { return "", fmt.Errorf("%w: docx missing word/document.xml", ErrParse) } rc, err := doc.Open() if err != nil { return "", fmt.Errorf("%w: docx open part: %v", ErrParse, err) } defer func() { _ = rc.Close() }() dec := xml.NewDecoder(io.LimitReader(rc, 64<<20)) // bound the part so a zip bomb cannot run away var sb strings.Builder inText := false for { tok, err := dec.Token() if err == io.EOF { break } if err != nil { return "", fmt.Errorf("%w: docx xml: %v", ErrParse, err) } switch t := tok.(type) { case xml.StartElement: // A WordprocessingML text run is the element. Match on the local name so the namespace // prefix (w) does not have to be hard coded. if t.Name.Local == "t" { inText = true } case xml.EndElement: if t.Name.Local == "t" { inText = false sb.WriteByte(' ') // join adjacent runs with a space so words do not collide } case xml.CharData: if inText { sb.Write(t) } } if sb.Len() > maxTextBytes { break } } return strings.TrimSpace(sb.String()), nil } // extractHTML returns the visible text of an HTML document, dropping the content of script and style // elements and collapsing tags to whitespace. func extractHTML(data []byte) (string, error) { z := html.NewTokenizer(bytes.NewReader(data)) var sb strings.Builder skipDepth := 0 // > 0 while inside a script or style subtree for { tt := z.Next() if tt == html.ErrorToken { if err := z.Err(); err != nil && err != io.EOF { return "", fmt.Errorf("%w: html: %v", ErrParse, err) } break } switch tt { case html.StartTagToken: name, _ := z.TagName() if isSkippable(name) { skipDepth++ } case html.EndTagToken: name, _ := z.TagName() if isSkippable(name) && skipDepth > 0 { skipDepth-- } case html.TextToken: if skipDepth == 0 { txt := strings.TrimSpace(string(z.Text())) if txt != "" { if sb.Len() > 0 { sb.WriteByte(' ') } sb.WriteString(txt) } } } if sb.Len() > maxTextBytes { break } } return sb.String(), nil } // isSkippable reports whether an element's text content must be dropped (script and style carry code, // never visible prose). func isSkippable(name []byte) bool { switch string(bytes.ToLower(name)) { case "script", "style": return true default: return false } }