// Package federalsignal mines public federal solicitation attachments for the deterministic mission and // labor classifier. This file is the server side SAM attachment fetch client: it downloads a notice // description and its resource links, runs each body through the pure Go docextract extractor, and // returns the concatenated plain text. The SAM api_key is held here and only ever appended as a query // parameter to a SAM host. It is NEVER logged, NEVER placed in an error string, and NEVER returned in // the extracted text. The client is best effort: one bad attachment is skipped, never fatal. package federalsignal import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "io" "log" "net/http" "net/url" "strings" "time" "github.com/missionworkforce/missionmatch/internal/platform/docextract" "github.com/missionworkforce/missionmatch/internal/platform/storage" ) const ( defaultTimeout = 45 * time.Second defaultMaxRetries = 2 maxBodyBytes = 8 << 20 // cap one download so a huge attachment cannot exhaust memory maxTotalBytes = 4 << 20 // cap the concatenated text across all attachments for one record separator = "\n\n----\n\n" backoffStep = 200 * time.Millisecond ) // Fetcher downloads SAM solicitation attachments and extracts their text. Build one with New. It is safe // for concurrent use, the http.Client and the option fields are read only after construction. type Fetcher struct { key string // SAM api_key, server side only, never logged or returned http *http.Client // bounded by Timeout maxRetry int // additional attempts on a transient failure samHosts map[string]struct{} // hosts the key may be appended to, lowercased store storage.Storer // optional, nil skips retention storePref string // key prefix for retained raw bytes } // Option configures a Fetcher. type Option func(*Fetcher) // WithTimeout sets the per download timeout. func WithTimeout(d time.Duration) Option { return func(f *Fetcher) { if d > 0 { f.http.Timeout = d } } } // WithMaxRetries sets the number of additional attempts on a transient download failure. func WithMaxRetries(n int) Option { return func(f *Fetcher) { if n >= 0 { f.maxRetry = n } } } // WithSAMHosts registers the hosts the api_key may be appended to. The key is sent ONLY to these hosts, // so a foreign resource link in a notice can never receive the secret. Hosts are matched case // insensitively on the URL host (with any port). Production registers api.sam.gov and sam.gov; the test // registers the httptest host. func WithSAMHosts(hosts ...string) Option { return func(f *Fetcher) { for _, h := range hosts { h = strings.ToLower(strings.TrimSpace(h)) if h != "" { f.samHosts[h] = struct{}{} } } } } // WithStore wires an optional object store for raw attachment retention under the given key prefix. A // nil store (or not calling this option) skips retention entirely. func WithStore(s storage.Storer, prefix string) Option { return func(f *Fetcher) { f.store = s f.storePref = strings.TrimSpace(prefix) } } // New builds a Fetcher holding the SAM api_key. Defaults: a 45 second timeout, two retries, the two // production SAM hosts, and no retention. Options override these. func New(samKey string, opts ...Option) *Fetcher { f := &Fetcher{ key: samKey, http: &http.Client{Timeout: defaultTimeout}, maxRetry: defaultMaxRetries, samHosts: map[string]struct{}{ "api.sam.gov": {}, "sam.gov": {}, }, } for _, o := range opts { o(f) } return f } // FetchSolicitationText downloads the notice description URL and each resource link, extracts the plain // text of each, and returns the concatenated text capped at maxTotalBytes. It is best effort: a per // attachment failure is counted and logged (never with the URL or the key) and skipped, it never fails // the whole record. The api_key is [credential removed] only to a registered SAM host and never appears in the // returned string. func (f *Fetcher) FetchSolicitationText(ctx context.Context, descURL string, resourceLinks []string) (string, error) { urls := make([]string, 0, 1+len(resourceLinks)) if strings.TrimSpace(descURL) != "" { urls = append(urls, descURL) } for _, l := range resourceLinks { if strings.TrimSpace(l) != "" { urls = append(urls, l) } } if len(urls) == 0 { return "", nil } var sb strings.Builder failures := 0 for _, raw := range urls { if err := ctx.Err(); err != nil { return sb.String(), err // a cancelled context is the caller's signal, not a per attachment skip } text, err := f.fetchOne(ctx, raw) if err != nil { // Count the failure without echoing the URL (it carries the api_key once appended) or the cause // detail that could include it. Best effort: continue to the next attachment. failures++ continue } if text == "" { continue } if sb.Len() > 0 { sb.WriteString(separator) } remaining := maxTotalBytes - sb.Len() if remaining <= 0 { break } if len(text) > remaining { text = truncateOnRune(text, remaining) } sb.WriteString(text) if sb.Len() >= maxTotalBytes { break } } if failures > 0 { log.Printf("federalsignal: skipped %d unreadable attachment(s) of %d on one record", failures, len(urls)) } return sb.String(), nil } // fetchOne downloads a single URL (appending the api_key only for a SAM host), optionally retains the // raw bytes, and returns the extracted text. It is the only place the key touches a URL, and it returns // errors that never embed the key bearing URL. func (f *Fetcher) fetchOne(ctx context.Context, raw string) (string, error) { parsed, err := url.Parse(raw) if err != nil { return "", errors.New("federalsignal: invalid attachment url") } reqURL := raw if f.isSAMHost(parsed.Host) { // Append the key to the existing query. Build it from the parsed URL so the key is never string // concatenated into a malformed URL, and so it is added once even if the URL already has a query. q := parsed.Query() q.Set("api_key", f.key) parsed.RawQuery = q.Encode() reqURL = parsed.String() } body, err := f.download(ctx, reqURL) if err != nil { return "", err // already key safe, see download } if f.store != nil { // Retain under a deterministic key derived from the ORIGINAL url (without the key), so retention is // idempotent and the key never lands in the object key. A storage failure is not fatal to mining. if perr := f.store.Put(ctx, rawKey(f.storePref, raw), body); perr != nil { log.Printf("federalsignal: retain attachment failed: %v", perr) } } // Name the extractor's dispatch off the URL path so the extension drives the format choice. return docextract.ExtractText(ctx, parsed.Path, body) } // download GETs url with bounded retries on a transient failure and returns the body bytes capped at // maxBodyBytes. Every error string is key safe: a *url.Error embeds the full request URL (which carries // the api_key), so it is unwrapped to the transport cause which carries host:port at most. func (f *Fetcher) download(ctx context.Context, target string) ([]byte, error) { var lastErr error for attempt := 0; attempt <= f.maxRetry; attempt++ { if attempt > 0 { select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(time.Duration(attempt) * backoffStep): } } body, err := f.attempt(ctx, target) if err == nil { return body, nil } lastErr = err } return nil, lastErr } func (f *Fetcher) attempt(ctx context.Context, target string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) if err != nil { // A build error would echo the full URL incl. api_key, keep it generic. return nil, errors.New("federalsignal: failed to build request") } resp, err := f.http.Do(req) if err != nil { // *url.Error.Error() embeds the full request URL incl. ?api_key=... NEVER wrap it whole: ue.Err is // the transport cause and carries host:port at most, never the query string. var ue *url.Error if errors.As(err, &ue) { return nil, fmt.Errorf("federalsignal: %s request failed: %v", ue.Op, ue.Err) } return nil, errors.New("federalsignal: request failed") } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { // Drain a small bounded snippet so the connection can be reused, but do not include it: a SAM error // body can echo the request URL with the key. _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1024)) return nil, fmt.Errorf("federalsignal: status %d", resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes)) if err != nil { return nil, errors.New("federalsignal: read body failed") } return body, nil } // isSAMHost reports whether host (which may include a port) is a registered SAM host. func (f *Fetcher) isSAMHost(host string) bool { _, ok := f.samHosts[strings.ToLower(host)] return ok } // rawKey is the deterministic object key for a retained attachment: the prefix, then the lowercase hex // sha256 of the original URL, with a .bin suffix. The api_key is [credential removed] part of it because the original // URL (pre key) is hashed. Two runs of the same URL collide on one object. func rawKey(prefix, originalURL string) string { sum := sha256.Sum256([]byte(originalURL)) h := hex.EncodeToString(sum[:]) p := strings.Trim(prefix, "/") if p == "" { p = "federalsignal" } return p + "/attachments/" + h + ".bin" } // truncateOnRune cuts s to at most n bytes on a UTF-8 rune boundary so the cap never splits a rune. func truncateOnRune(s string, n int) string { if n >= len(s) { return s } cut := n for cut > 0 && s[cut]&0xC0 == 0x80 { cut-- } return s[:cut] }