feat(npm): streampub envelope bounds + attachment shape (part 4/8)

📦 What this MR does

Part 4/8 of the stack splitting the streampub streaming publish-envelope library (S11 Step 13). See Part 1/8 (!432) for the stack overview and the byte-identity contract against the reference, !420 (closed).

  • Targets 10io/npm-local-step-13-part3of8 (Part 3/8). GitLab retargets to main as the parent merges.
  • Depends on Part 3/8 (set as a merge-request dependency).

This part threads the five envelope memory-budget bounds back through the tokenizer and adds the attachment structural walk and the cross-field shape validation. The streaming attachment pipeline (multi-hash + gzip/tar inspector) is the only remaining major piece.

Bounds — all enforced incrementally during tokenization, so the offending structure is never fully materialized:

  • token.goreadToken now enforces the depth cap (MaxEnvelopeDepth) and the per-object key cap (enforceObjectKeyCap / MaxEnvelopeObjectKeys) on every token.
  • versions.goreadVersionValue enforces the per-version byte-span cap (MaxEnvelopePackageJSONSize), aborting on the first byte past it (S11 AC 43).
  • walker.goreadDistTags enforces the dist-tags count cap (MaxEnvelopeDistTags). The walker gains lim + the saw* flags; newWalker takes Limits.

Attachment shape + validation:

  • attachment.go (new) — readAttachments / readAttachmentObject / attachmentFileName. readAttachmentObject skips the data value structurally here; the streaming hand-off replaces that one line in a later part.
  • walker.govalidateShape (name present + valid, exactly one version, one attachment whose key matches {plain_name}-{version}.tgz), called after the walk; dispatchTopLevel routes _attachments.
  • error.goerrEnvelopeBound + the four bound codes, referenced for the first time here.

🧪 Tests

  • bounds_test.go (new): the five bounds — EnvelopePackageJSONTooLarge (AC 43, with the abort-offset assertion), EnvelopeTooDeep (AC 44), EnvelopeTooManyKeys (AC 45), EnvelopeDistTagsTooMany (AC 46), TwoVersionsAbortsEarly (AC 47) — plus FailsClosedOnInvalidLimits.
  • TestProcess_EnvelopeShapeInvalid (AC 38, unit half): missing name / versions / attachments and attachment-key mismatch.
  • The trailing-field depth-bomb test (TestProcess_TrailingFieldBoundBypass) needs the streaming tail scanner (consumeToEnvelopeEnd) and lands with it in a later part.

This closes the five "deferred bound" items flagged on Parts 2–3: the bounds land here with the real configurable caps rather than as interim ceilings.

  • golangci-lint (repo-pinned v2.12) → 0 issues
  • GOEXPERIMENT=jsonv2 go test -race -count=1 ./internal/format/npm/streampub/... → pass (10 tests)
  • Diff is 489 added / 462 net lines — within the 500-LOC reviewability guideline.

🚩 Deviations from the reference (!420 (closed))

Carries forward the Part 1/8 error.go API improvements (type ProcessError, unexported code / wrapped), the Part 2/8 six-cap validate, and the Part 3/8 test-label fix. token.go and versions.go reach their reference form here (modulo the *ProcessError rename in abortErr/mapDecodeErr).

New deviations in this part (from review):

  • Attachment structural walkreadAttachmentObject consumes the whole attachment object and readAttachments consumes the _attachments closing brace, rejects a second entry (mirroring readVersions' single-entry guard), and sets AttachmentKey/sawAttachments only after the object parses. The reference instead hands data off to the streaming pipeline, which consumes the envelope remainder itself; that is a later part. So this part's attachment.go and the keyData constant are transient and are replaced when the streaming hand-off lands — the final tree matches the reference.
  • Stale comment fixTestProcess_FailsClosedOnInvalidLimits's "this test FAILS today" note (a test-author-phase artifact) is corrected; Process already validates Limits up front, so the test passes.
  • Added test coverage (94168d2): an "empty attachments object" case in TestProcess_EnvelopeShapeInvalid asserts publish_envelope_invalid for "_attachments":{}, exercising the readAttachments empty-guard. That guard is byte-identical in the reference but untested there (the input appears only as a FuzzProcess seed, which asserts no-panic, not the spec code). Test-only; no production change.
Edited by David Fernandez

Merge request reports

Loading
Loading