feat(npm): streampub streaming publish-envelope library (S11 Step 13)

📦 What this MR does

Implements Step 13 of the npm local plan: the handler-independent streaming publish-envelope library internal/format/npm/streampub.

streampub.Process walks an npm publish envelope in a single tokenizing pass (encoding/json/v2's jsontext.Decoder), streaming the base64 _attachments[{file}].data directly to a multi-hash sink (SHA-256 / SHA-1 / SHA-512) and an inline gzip+tar inspector — capturing package/package.json, validating tar entries, and enforcing the five envelope memory-budget bounds incrementally so no oversized structure is ever materialized.

Related to gitlab-org/ops/artifact-registry#131 (npm hosted Step 13).

⚙️ Key decisions

  • encoding/json/v2 + GOEXPERIMENT=jsonv2 (module-wide). The spec mandates jsontext.Decoder by name: stdlib encoding/json.Decoder would buffer the entire base64 attachment before user code sees it, defeating the no-memory-buffer contract. The flag is wired in .mise.toml ([env]) and .gitlab-ci.yml (variables:). It also re-backs the existing encoding/json v1 importers; the full unit suite was verified to pass identically with the flag off and on (zero regressions).
  • Drain-on-error + bounded-buffer contract. The inspector goroutine drains the pipe on every exit path and reports its error via CloseWithError, so either side erroring fails within bounded time — never waiting for publish_read_timeout. A bufio buffer (streampub_buffer_size) decouples producer progress from per-entry tar-parser speed.
  • Limits struct, not NpmConfig. Two of the five bounds (max_envelope_depth, max_envelope_object_keys) are platform-shared decoder config (work item #90), not NpmConfig fields. The library takes all five via an explicit Limits struct so the Step 14/15 handler can source the platform bounds however #90 eventually provides them.
  • First format subpackage + depguard. streampub/ is the codebase's first subpackage under internal/format/. Per ADR 023 the format-isolation deny rule prefix-matches internal/format/, which would block the npm handler from importing its own subpackage — so the plan's Naming Conventions and Step 8 scope were corrected to require a targeted depguard allow-rule (lands with Step 8's rules).
  • First npm fuzz target. FuzzProcess is the first fuzz target under internal/format/npm/**; per docs/dev/go-testing.md this MR adds the fuzz:npm CI job (mirroring fuzz:oci).

🧪 Spec coverage

Unit-level surface owned by Step 13. Integration halves of split ACs (38, 39, 47) and integration-only ACs are owned by later steps (14, 15, 16) per the plan's AC → Step map.

Acceptance criteria

# Criterion Test
AC 37 Body not valid JSON → bad_request TestProcess_InvalidJSON
AC 38 Envelope shape invalid → publish_envelope_invalid (unit half) TestProcess_EnvelopeShapeInvalid
AC 39 Envelope name fails npm regex → package_name_invalid (streaming-unit half) TestProcess_PackageNameInvalid
AC 43 Tokenizer aborts at first byte past max_envelope_package_json_size; no per-version struct materialized TestProcess_EnvelopePackageJSONTooLarge
AC 44 Nesting past depth cap → envelope_too_deep TestProcess_EnvelopeTooDeep
AC 45 Object keys past key cap → envelope_too_many_keys TestProcess_EnvelopeTooManyKeys
AC 46 dist-tags over max_envelope_dist_tagsenvelope_dist_tags_too_many TestProcess_EnvelopeDistTagsTooMany
AC 47 Two-entry versions map → publish_envelope_invalid; aborts on 2nd key (unit half) TestProcess_TwoVersionsAbortsEarly
AC 4 Multi-hash SHA-1 (dist.shasum) in one pass TestProcess_HappyPath
AC 51 Multi-hash SHA-512 (dist.integrity) in one pass TestProcess_HappyPath
AC 48 Context cancellation drives inspector exit, no leak TestProcess_ContextCancellation
AC 49 No resident inspector goroutine on success-path return TestProcess_NoGoroutineLeakOnSuccess

Error cases

Condition Code Test
Body not valid JSON bad_request TestProcess_InvalidJSON
Envelope shape invalid publish_envelope_invalid TestProcess_EnvelopeShapeInvalid, TestProcess_TwoVersionsAbortsEarly
Package name fails npm regex package_name_invalid TestProcess_PackageNameInvalid
Per-version raw byte span over cap envelope_package_json_too_large TestProcess_EnvelopePackageJSONTooLarge
Nesting depth over cap envelope_too_deep TestProcess_EnvelopeTooDeep
Object over per-object key cap envelope_too_many_keys TestProcess_EnvelopeTooManyKeys
dist-tags map over cap envelope_dist_tags_too_many TestProcess_EnvelopeDistTagsTooMany
Decoded tarball over max_tarball_size tarball_size_invalid TestProcess_TarballSizeInvalid
Malformed gzip/tar, missing/oversized package.json, tar path-traversal manifest_coherence_failed TestProcess_CoherenceFailures, TestProcess_Base64Corruption
Captured package.json over max_package_json_size package_json_too_large TestProcess_CoherenceFailures

Security considerations

Concern Test
Streaming base64 (no full buffer) TestProcess_HappyPath, TestProcess_DecoderBoundaryClean
Envelope metadata memory budget (5 bounds, abort during tokenization) TestProcess_EnvelopePackageJSONTooLarge, _EnvelopeTooDeep, _EnvelopeTooManyKeys, _EnvelopeDistTagsTooMany, _TwoVersionsAbortsEarly
Coherence-sink goroutine lifecycle (drain-on-error, bounded exit, no leak) TestProcess_DrainOnError_BoundedTime, _ContextCancellation, _NoGoroutineLeakOnSuccess
Tar path traversal (.., backslash separators, escaping package/) TestProcess_CoherenceFailures, TestProcess_TarBackslashTraversalRejected
Envelope-tail depth bound (no depth-bomb in trailing fields after _attachments) TestProcess_TrailingFieldBoundBypass
Error string never echoes client-supplied input (tar entry names, attachment keys) TestProcess_ErrorDoesNotLeakEntryName
Fail-closed on misconfigured Limits (caps must be positive; distinct from client-input rejection) TestProcess_FailsClosedOnInvalidLimits
Untrusted-input parsing never panics FuzzProcess
Decoder-boundary clean hand-off TestProcess_DecoderBoundaryClean
Bounded-buffer decoupling wired (output-transparent) TestProcess_BackpressureBufferWired
Per-version package.json captured for downstream filter/persist TestProcess_CapturesVersionPackageJSON

Verification

  • GOEXPERIMENT=jsonv2 go test -race -count=1 ./internal/format/npm/streampub/... → pass (all functions, incl. FuzzProcess no-panic guard).
  • golangci-lint (repo-pinned v2.12) → 0 issues.
  • GOEXPERIMENT=jsonv2 go build ./... → clean module-wide.

ℹ️ Notes

  • Result.VersionPackageJSON captures the per-version object as normalized JSON (re-serialized tokens, compact and key-order-preserving) bounded by the span cap — jsontext cannot hand back a value's raw byte span without buffering the whole value, which would regress the AC 43 incremental bound. Semantically lossless for the downstream allow-list filter / persistence (Steps 14-16).
  • AC 43 abort-offset reading is provisional pending spec-author confirmation (the spec names InputOffset(), an absolute offset, while the cap is a per-version byte span); surfaced via Error.AbortOffset.
  • Includes a review-hardening pass (the fix(npm): commits): adversarial tar-entry validation (rejecting backslash separators), an inline depth bound on the post-_attachments envelope tail, error strings that never echo client-supplied input, and fail-closed Limits validation — each covered by a regression test in the Security considerations table above. The test file is also split into topical files and the binary fixtures are marked generated to keep the diff reviewable.

📏 MR size justification

This MR is ~2,950 reviewable LOC (excluding the 8 binary .tgz fixtures), over the 500-LOC guideline in development-model.md § MR Size Guidelines. That guideline allows exceeding the ceiling with a justification in the description rather than a split; the rationale:

  • It is a single, already-decomposed plan step. Step 13 of the merged npm local plan is the streampub library. The plan splits S11 into 25 steps precisely to keep MRs small — this is one of them, and the plan deliberately separated the library from its publish-handler consumer (Steps 14/15).
  • Most of the volume is tests + fixtures (~1,330 LOC), not production code. streampub parses untrusted publish envelopes, so the plan mandates a crafted-envelope suite, a checked-in fixture corpus, and fuzzing. The test files are 1,162 LOC and the one-shot fixture generator is 171 LOC; the 8 binary fixtures add zero reviewable lines.
  • The production code (~1,520 LOC) is one interlocking mechanism. The S11 single-pass design hands the base64 attachment stream to the gzip/tar inspector mid-tokenization, so the JSON walk, the five incremental memory bounds, the multi-hash, the drain-on-error pipeline, and the error taxonomy cannot be split into separately-mergeable MRs without leaving a non-functional Process.
  • Reviewability mitigations applied: the test file is split into topical files (bounds_test.go / coherence_test.go / lifecycle_test.go); the binary fixtures are marked generated so GitLab collapses them; and the history is clean conventional commits (test → feat → refactor → fix → docs) reviewable commit-by-commit.
Edited by David Fernandez

Merge request reports

Loading
Loading