Skip to content

fix(registry): write the packument cache with serde_json, not sonic-rs - #668

Merged
colinhacks merged 1 commit into
mainfrom
packument-cache-serializer
Aug 3, 2026
Merged

fix(registry): write the packument cache with serde_json, not sonic-rs#668
colinhacks merged 1 commit into
mainfrom
packument-cache-serializer

Conversation

@colinhacks

Copy link
Copy Markdown
Contributor

Both packument-cache writers serialize through serde_json::to_vec instead of sonic_rs::to_vec; read path unchanged.

A serde_json::Value may only be serialized by serde_json. Under arbitrary_precision, sonic-rs writes Number's private token literally, so cached numbers land as {"$serde_json::private::Number":"…"} and the next typed read fails invalid type: map, expected u64. Revalidation rewrites the poison verbatim, so it never heals.

nub compile pulls rolldown_common, which requires that feature; Cargo unifies per build and the cache is shared. No released binary can produce it. Also silently corrupts trustedPublisher/approver.

fmt, clippy, 309 tests pass.

A `serde_json::Value` may only be handed to serde_json's own serializer.
With `serde_json/arbitrary_precision` anywhere in the feature graph,
`Number::serialize` emits `serialize_struct("$serde_json::private::Number", 1)`
and relies on the serializer to intercept that token. serde_json does;
sonic-rs writes it out literally, turning every number in the document into
`{"$serde_json::private::Number":"27353961"}` on disk.

Cargo unifies features per build, so one dependency turns this on for the
whole graph — `nub compile` pulls `rolldown_common`, which requires it. The
packument cache is shared by every build on the machine, so such a build
poisons it for other binaries, and the damage is self-perpetuating: a
poisoned entry reads back as a plain map and ETag revalidation rewrites it
verbatim, so it never heals. The visible symptom is
`invalid type: map, expected u64` on the next typed read.

Route both cache writers through one `to_cache_bytes` seam on
`serde_json::to_vec`. The read path stays on sonic-rs — deserializing into a
`Value` has no equivalent hazard, and that is where the throughput matters.

Beyond `unpackedSize` and `fileCount`, `Packument` reaches
`serde_json::Value` through `dist.attestations.provenance`,
`_npmUser.trustedPublisher` and `approver`. The latter two are trust-policy
evidence, so a corrupted one degrades a security decision without failing a
parse.

The regression test goes through `RawValue` rather than the number token:
serde_json both emits and intercepts the number token under
`#[cfg(feature = "arbitrary_precision")]`, so in a default-feature build
neither half is compiled and no assertion can tell the serializers apart.
`raw_value` is on by default and is the testable instance of the same
invariant. It carries a failing control asserting sonic-rs leaks the token.
Copilot AI review requested due to automatic review settings August 3, 2026 19:19
@vercel

vercel Bot commented Aug 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nub Ready Ready Preview Aug 3, 2026 7:21pm

Request Review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review any files in this pull request.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ The fix is correct — one stale claim in the new docstring, plus two scope questions.

Reviewed changes — the single commit d530e86, both files, plus the surrounding read paths in client/packument.rs and the tolerant deserializers in aube-registry/src/lib.rs.

  • Cache writers moved to serde_jsonwrite_cached_packument and write_cached_full_packument now serialize through a new private to_cache_bytes<T: Serialize> helper; the sonic_rs::from_slice read path is untouched.
  • Doc comment recording the invariant — a serde_json::Value may only be handed to serde_json's own serializer, because under arbitrary_precision Number's Serialize emits a private token the receiving serializer must intercept.
  • One unit test with a failing controlRawValue stands in for the number token (raw_value is on by default, arbitrary_precision is not), and the control asserts sonic_rs::to_vec leaks $serde_json::private:: so the test can't quietly stop guarding the serializer choice.
  • serde_json/raw_value named in [dev-dependencies] — redundant with the workspace dep, which already sets it, so the test doesn't depend on that unification holding.

I verified the premise against the pinned versions rather than taking it on trust. sonic-rs 0.5.8's Serializer::serialize_struct intercepts only its own two tokens ($sonic_rs::private::JsonNumber, $sonic_rs::LazyValue) and falls through to serialize_map for everything else, so serde_json's tokens do leak; serde_json 1.0.150's Number::serialize does emit serialize_struct(TOKEN, 1) under arbitrary_precision; and the read side really is safe, since Value's visit_u64/visit_i64/visit_f64 are not cfg-gated and sonic-rs never routes a numeric literal through visit_map. The test's control assertion holds. arbitrary_precision is enabled nowhere on this branch, so the bug is latent here and live only in a build that unifies it in — which matches the PR body.

ℹ️ An already-poisoned cache entry still never heals, and the cache namespace was not bumped

The fix stops new poison but does not remediate existing entries, and there is no trigger that would. Once the numbers are plain maps on disk the fixed serializer writes them back verbatim, and because no read errors there is nothing to fall back to a refetch — only a genuine upstream 200 replaces the entry. Blast radius is limited to machines that ran a build unifying arbitrary_precision (today, the compile-spike branch), so this may well be out of scope, but the PR body asserts "it never heals" and then leaves it that way.

Technical details
# Poisoned packument-cache entries are not invalidated by this fix

## Affected sites
- `vendor/aube/crates/aube-registry/src/client/packument.rs:260-267` — the full path's `304` branch writes `&c.packument` straight back out; `c` is the poisoned `Value` just read, whose numbers are already `Object` nodes, so the serializer swap changes nothing.
- `vendor/aube/crates/aube-registry/src/client/packument.rs:479-512` and `846-867` — the typed-revalidate and abbreviated `304` branches have the same shape.
- `vendor/aube/crates/aube-registry/src/client/cache.rs:205-244``read_cached_full_packument_typed_lookup` SUCCEEDS on a poisoned file (no strict numeric field remains on `Packument`), so the `let Ok(typed) = … else { return default }` refetch escape hatch never fires.
- `vendor/aube/crates/aube-store/src/lib.rs:61-62``PACKUMENT_CACHE_SUBDIR = "packuments-v1"` / `PACKUMENT_FULL_CACHE_SUBDIR = "packuments-full-v1"`, unchanged by this PR.

## Required outcome
- Either a deliberate decision that poisoned entries are the affected developer's problem (and the docstring says so), or an invalidation mechanism so an existing entry is not served indefinitely.

## Suggested approach (optional)
- A subdir version bump is the cheap blunt option: the caches are regenerable and `nub` refetches on a miss.
- Narrower: have the read path treat a `$serde_json::private::` key as a cache miss, which also self-documents the failure.

## Open questions for the human
- Is remediating already-poisoned caches in scope for a fix PR, or is "clear `~/.cache/nub/packuments*`" acceptable given only dev machines can be affected?

ℹ️ The same invariant is violated where nub pm use writes pnpm-workspace.yaml

I swept every non-serde_json serializer entry point in both crates/** and vendor/aube/crates/**; after this PR exactly one violation remains. crates/nub-cli/src/pm_engine/use_nub.rs:1120 hands a serde_json::Value to serde_yaml::to_string and writes the result to disk as pnpm-workspace.yaml. It is latent for the same reason the cache bug is latent, and the values come verbatim from package.json with no shape validation, so a number anywhere in overrides/auditConfig/catalogs would land as a token mapping in a real config file.

Technical details
# Remaining `serde_json::Value` → foreign serializer site

## Affected sites
- `crates/nub-cli/src/pm_engine/use_nub.rs:1120``serde_yaml::to_string(&serde_json::Value::Object(yaml.clone()))`, written to `pnpm-workspace.yaml` at line 1122. `yaml` is assembled at 1102-1117 from cloned `package.json` values (`workspaces`, `overrides`, `patchedDependencies`, `allowBuilds`, `auditConfig`, `catalog`, `catalogs`).

## Required outcome
- Same invariant as the packument cache: a value that reaches `serde_json::Value` is serialized by serde_json. Converting to `serde_yaml::Value` (or routing through a JSON round-trip) before handing it to the YAML serializer would satisfy it.

## Open questions for the human
- In scope for this PR, or a follow-up? Everything else checked out clean: the other `yaml_serde::to_*` / `serde_yaml::to_*` / `toml::to_*` sites serialize their own `Value` types, `aube-lockfile/src/pnpm/write.rs:953` has no `serde_json::Value` field reaching it, and the bun lockfile writer hand-rolls through `serde_json` plus `Number::to_string`.

ℹ️ Nitpicks

  • The comment this PR replaces documented a deliberate tradeoff ("a small throughput win on the cold-install metadata phase"). Worth a line in the PR body on whether the regression was measured for the full-packument write, where the Value routinely runs to megabytes — the correctness argument stands either way, but the next person to look at this will ask.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment on lines +155 to +156
/// `{"$serde_json::private::Number":"27353961"}` on disk, and the next read
/// fails the typed parse with `invalid type: map, expected u64`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This failure mode no longer holds on main: dist.unpackedSize is read through unpacked_size_tolerant (aube-registry/src/lib.rs:492-497), whose own docstring cites invalid type: map, expected u64 as the reason that tolerance was added — and no strict numeric field is left on Packument. The real consequence is a silent one, which makes the fix more important, not less, but the comment as written will send the next reader looking for an error that can't happen.

Technical details
# The documented consequence of a poisoned entry is stale

## Affected sites
- `vendor/aube/crates/aube-registry/src/client/cache.rs:155-156` — claims the next typed read fails with `invalid type: map, expected u64`.
- `vendor/aube/crates/aube-registry/src/lib.rs:492-497` + `513-584``unpacked_size_tolerant` absorbs any non-integer shape via `visit_map_to!(None)` and drops the field to `None`. Its docstring documents that exact error as the reason it exists.
- `vendor/aube/crates/aube-registry/src/client/cache.rs:209-224` — the typed reader's `Typed` struct therefore parses a poisoned file successfully, so its refetch escape hatch never fires.

## Required outcome
- The comment describes what actually happens: the poisoned entry parses, `unpackedSize` silently becomes `None`, the three `Option<serde_json::Value>` trust fields carry the token map, and nothing errors — so nothing triggers a refetch.

## Suggested approach (optional)
- Worth adding, since it is the non-obvious half: under `arbitrary_precision` the poison round-trips losslessly, because `KeyClassifier` (serde_json `src/value/de.rs`) maps the token back to `KeyClass::Number` and `ValueVisitor::visit_map` reconstructs `Value::Number`. The build that writes the corruption cannot observe it; only a build without the feature — the released binary — sees a map.
Suggested change
/// `{"$serde_json::private::Number":"27353961"}` on disk, and the next read
/// fails the typed parse with `invalid type: map, expected u64`.
/// `{"$serde_json::private::Number":"27353961"}` on disk. Nothing fails loudly
/// on the way back in: `unpacked_size_tolerant` absorbs the map shape and drops
/// the field to `None`, so the entry parses and the corruption stays silent.

@colinhacks
colinhacks merged commit e3a394f into main Aug 3, 2026
55 checks passed
@colinhacks
colinhacks deleted the packument-cache-serializer branch August 3, 2026 21:14
@colinhacks

Copy link
Copy Markdown
Contributor Author

Shipped in v0.7.0: https://github.com/nubjs/nub/releases/tag/v0.7.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants