Files
hanzo-dev beefb70009 reference: bound one request, bound one tenant's bytes, keep the version a decision cites
resolve could spend the whole process on one authenticated request. Two
amplifiers composed. A key had a count bound and no BYTE bound, and the domain
matcher split a host into labels and re-joined every tail, so an L-label host
allocated a copy of each of its L suffixes: one 8 KB dotted key materialised
16.8 MB and 100 of them 1.7 GB, measured over the router. And `sets` had no
bound and no dedupe, so naming one set N times ran N times the answers.

  - maxKey bounds one key in bytes at every door it crosses — looked up,
    written, removed — and REFUSES rather than truncating, because a shortened
    key is a different key.
  - a domain suffix is now a slice of the host, not a join of its labels: the
    same answers in O(L) headers over one backing array.
  - a call may name each published set once, and a set named twice is consulted
    once.

The override write had the same hole from the other side: maxOverrides bounded
rows and nothing bounded a row, so 10,000 entries x 11 sets was gigabytes of
attacker-chosen bytes on the one volume every other organisation's store lives
on. The same maxKey closes it; an over-long note is refused rather than trimmed;
and what one organisation may occupy on that volume is now a figure the code
computes and a test pins, so raising rows, a key, a note or the catalog is an act
with its consequence next to it.

An override is a record, so it is now shipped before it is acknowledged, the way
apps/research and apps/books do it — this deployment is one replica with a
recreate rollout, and an unshipped write is a control an operator believes is in
force and is not.

prune spared ONE version: the call site passed the current version for both of
the statement's two placeholders, deleting the rows behind every citation taken
in the window before a refresh. What a take supersedes is now decided by
sweepOld over what the plane held before it, and proved against a warehouse
rather than against the text of the statement.

The publisher's end of the same amplifier is closed with the same door. A take
is refused whole if it carries a member longer than maxKey — one no lookup could
ever reach, so it is only weight in the warehouse, in every hydrate and in the
snapshot every request reads — or more members than a published set holds: swing
measures GROWTH against the version a take replaces, and a first take has nothing
to measure against, which after a cold start is every take. maxBody comes down to
six times the largest source in the catalog (measured: 2.6 MB), because the parse
allocates before any later gate can look at what it made. A publisher's redirect
must keep the two properties its origin already had, TLS and a destination
outside this network: this process runs in the cluster, where "wherever the
publisher says" reaches the pod network and the metadata address.

Also: a take whose size swings past 4x is refused and the previous version
stands (force is the operator lever); the disposable list is refused whole if it
names a mailbox provider, which is the one-row attack the size gate cannot see
on the one unpinned source; an attest receipt with no version or no designations
is recorded as a refusal instead of a current, fresh list; the plane sweeps at
cold start instead of refusing every set for six hours after a deploy; the one
cross-fleet aggregation states its own memory and time budget; every source
states a typed redistribution Basis from a closed vocabulary, on the wire, so
the licence position is an audit rather than a sentence; refresh reads the ONE
SuperAdmin predicate rather than restating it, and an admin of their own org is
refused there, because this route writes the baseline every org reads; and the
bridge sits on this app's own leaf, not on the /v1/ml parent two other products
answer under.

65 tests green under -race (one skipped: it dials the real publishers). Every fix
has a regression test that fails when that fix alone is reverted: 24 mutants, 24
killed.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 11:45:33 -07:00

180 lines
6.7 KiB
Go

package reference
// fetch.go takes one publisher's bytes. It is the same shape luxfi/aml
// pkg/screen arrived at for the sanctions lists, for the same reasons, and the
// reasons are worth restating because each one is a measured failure:
//
// - RETRY TRANSPORT, NEVER A PARSE. A publisher that just refused a TLS
// handshake will serve the file correctly seconds later; a publisher whose
// schema changed will fail identically on every attempt, so retrying it only
// delays the refusal.
// - A TRUNCATED LIST IS THE DANGEROUS FAILURE. It parses. It yields a shorter
// list of members, every one of them correct, and nothing anywhere reports a
// problem — so a response that reaches the read limit is an error rather
// than a shorter set.
// - THE DIGEST IS NOT DECORATION. It is what tells a refresh that changed
// nothing from a refresh that did not run, and it is what makes ingest
// idempotent: the version IS the content.
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/netip"
"strings"
"time"
)
const (
// maxBody bounds one download, and it is the bound that decides how much the
// PARSE may cost — a parser turns bytes into entries one at a time, so
// whatever arrives here is allocated before any later gate can look at it.
//
// Measured: the largest source in the catalog is AWS's ip-ranges.json at 2.6
// MB, and the next is under a tenth of that. Sixteen is six times the largest
// and leaves room for years of growth; the previous sixty-four was twenty-five
// times it, and twenty-five times a few megabytes of adversarial one-token
// lines is a heap this one-replica deployment does not have. A publisher that
// outgrows this refuses on its own row and ages out visibly, which is a
// condition an operator can see and raise — unlike an OOM.
maxBody = 16 << 20
// attempts is how many times a publisher is asked before its source is
// recorded failed.
attempts = 3
// backoff grows per attempt: a publisher that just failed under load is not
// helped by being asked again immediately.
backoff = 5 * time.Second
// fetchTimeout bounds one attempt end to end.
fetchTimeout = 2 * time.Minute
)
// download takes one URL and returns its bytes. It is a value rather than a
// direct call so the retry behaviour — which decides whether a blip costs a day
// of freshness — is testable without a network.
type download func(ctx context.Context, url string) ([]byte, error)
// wire is the real downloader.
func wire(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "hanzo-reference/1")
client := &http.Client{Timeout: fetchTimeout, CheckRedirect: hop}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, url)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
if err != nil {
return nil, err
}
if len(body) == maxBody {
return nil, fmt.Errorf("%s reached the %d byte limit and may be truncated", url, maxBody)
}
return body, nil
}
// hop decides whether a publisher's redirect may be followed.
//
// Every Origin in the catalog is an HTTPS constant, so the ONE thing about the
// address this process cannot state in code is where a redirect goes. This runs
// inside the cluster, so "wherever the publisher says" reaches the pod network
// and the instance metadata address; and a hop to http:// hands the whole
// baseline every tenant's decisions read to anyone on the path.
//
// So a hop keeps the two properties the origin already had — TLS, and a
// destination outside this network — and is refused otherwise. The literal
// address forms are what a redirect can carry without a lookup; a name that
// RESOLVES inward is not caught here and does not need to be, because it is the
// publisher's own DNS and the same trust as the bytes themselves.
func hop(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("%s redirected %d times", via[0].URL, len(via))
}
if req.URL.Scheme != "https" {
return fmt.Errorf("%s redirected to %s, and a reference source is fetched over TLS or not at all", via[0].URL, req.URL.Scheme)
}
if inward(req.URL.Hostname()) {
return fmt.Errorf("%s redirected to %s, which is inside this network", via[0].URL, req.URL.Host)
}
return nil
}
// inward reports whether a host is a literal address this process should never
// be sent to: private, loopback, link-local (the instance metadata address is
// one), unspecified or multicast. A name is not judged here — resolving it is the
// publisher's own DNS.
func inward(host string) bool {
a, err := netip.ParseAddr(strings.Trim(host, "[]"))
if err != nil {
return false
}
a = a.Unmap()
return a.IsPrivate() || !a.IsGlobalUnicast()
}
// pull downloads one source and parses it, retrying transport failures.
func pull(ctx context.Context, get download, src Source, wait func(context.Context, time.Duration) error) ([]Entry, error) {
var last error
for attempt := 1; attempt <= attempts; attempt++ {
body, err := get(ctx, src.Origin)
if err != nil {
last = fmt.Errorf("attempt %d of %d: %w", attempt, attempts, err)
// The context ending is the deployment shutting down or giving up, not the
// publisher failing, so there is nothing to retry into.
if ctx.Err() != nil {
return nil, last
}
if attempt < attempts {
if werr := wait(ctx, time.Duration(attempt)*backoff); werr != nil {
return nil, last
}
continue
}
return nil, last
}
return src.parse(body)
}
return nil, last
}
// pause waits, or returns early when the context ends.
func pause(ctx context.Context, d time.Duration) error {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-t.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// digest is the content address of a parsed source: the version.
//
// It is computed over the SORTED, canonical rendering of the entries rather
// than over the downloaded bytes, and that is the whole point. A publisher who
// reorders their file, or adds a timestamp comment, has not changed the set —
// digesting the bytes would mint a new version and re-ingest every row for a
// change that is not one. Digesting the meaning makes the version an identity:
// the same set is the same version, whoever fetched it and whenever.
func digest(entries []Entry) string {
h := sha256.New()
for _, e := range order(entries) {
fmt.Fprintf(h, "%s\x00", e.Key)
for _, k := range keys(e.Value) {
fmt.Fprintf(h, "%s\x01%s\x02", k, e.Value[k])
}
fmt.Fprintf(h, "%g\x03%d\x04%d\x1e", e.Score, e.Orgs, e.N)
}
return hex.EncodeToString(h.Sum(nil))
}