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.
- Spec: S11 - npm local (publish-flow Step 3 streaming-tokenizer requirement)
- Work item: gitlab-org/ops/artifact-registry#22
- Plan step:
Step 13 — streaming JSON tokenizer + multi-hash + gzip+tar inspector
Related to gitlab-org/ops/artifact-registry#131 (npm hosted Step 13).
⚙️ Key decisions
encoding/json/v2+GOEXPERIMENT=jsonv2(module-wide). The spec mandatesjsontext.Decoderby name: stdlibencoding/json.Decoderwould 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 existingencoding/jsonv1 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 forpublish_read_timeout. Abufiobuffer (streampub_buffer_size) decouples producer progress from per-entry tar-parser speed. Limitsstruct, notNpmConfig. Two of the five bounds (max_envelope_depth,max_envelope_object_keys) are platform-shared decoder config (work item #90), notNpmConfigfields. The library takes all five via an explicitLimitsstruct 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 underinternal/format/. Per ADR 023 theformat-isolationdeny rule prefix-matchesinternal/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.
FuzzProcessis the first fuzz target underinternal/format/npm/**; perdocs/dev/go-testing.mdthis MR adds thefuzz:npmCI job (mirroringfuzz: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_tags → envelope_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.FuzzProcessno-panic guard).golangci-lint(repo-pinnedv2.12) →0 issues.GOEXPERIMENT=jsonv2 go build ./...→ clean module-wide.
ℹ️ Notes
Result.VersionPackageJSONcaptures the per-version object as normalized JSON (re-serialized tokens, compact and key-order-preserving) bounded by the span cap —jsontextcannot 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 viaError.AbortOffset. - Includes a review-hardening pass (the
fix(npm):commits): adversarial tar-entry validation (rejecting backslash separators), an inline depth bound on the post-_attachmentsenvelope tail, error strings that never echo client-supplied input, and fail-closedLimitsvalidation — 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.