Skip to content

fix(sdk): report a malformed manifest as an invalid file - #1044

Open
pflynn-virtru wants to merge 11 commits into
mainfrom
fix/validate-manifest-shape
Open

pflynn-virtru wants to merge 11 commits into
mainfrom
fix/validate-manifest-shape

Conversation

@pflynn-virtru

@pflynn-virtru pflynn-virtru commented Sep 18, 2026 •

Copy link
Copy Markdown
Member

Refs opentdf/platform#3513. Follow-up to #1042, stacked on it — retarget to main once that lands.

Problem

ZipReader.getManifest resolves the manifest entry by name and returns JSON.parse of whatever it finds. Nothing establishes the entry holds a manifest, so the first dereference downstream reports the problem — from outside the TdfError hierarchy, and missed by callers catching it.

Against an SDK-written archive with an intact 0.manifest.json plus a manifest.json that isn't a manifest:

Extra manifest.json decrypt / getPolicyId
{"name":"my-web-app"} TypeError: Cannot read properties of undefined
hello SyntaxError: Unexpected token 'h'
zero-length SyntaxError: Unexpected end of JSON input

instanceof InvalidFileError === false in every case. All three decrypt on a non-aligned-only reader, so this is strictly the cost of widening the lookup. Reachable before #1042 by planting 0.manifest.json; #1042 makes it reachable under a second name.

Change

getManifest wraps the parse and gates on a new isManifest.

The guard is deliberately weak. #1042 widened the reader; a guard demanding every declared field would reject what that change set out to accept. getPolicyId reads encryptionInformation.policy and nothing else, so requiring keyAccess or integrityInformation would fail manifests it serves fine. It rejects only what every caller would crash on — a non-object, or one with no encryptionInformation.

An earlier revision did demand those fields; the carrying only what its caller reads test caught it.

Passing says nothing about authenticity — the root signature runs later.

Testing

22 tests added to #1042's manifest-entry-name.spec.ts:

  • a spec-named entry that is not a TDF manifest — seven fixtures (web app manifest, missing encryptionInformation, array, null, string, non-JSON, zero-length), each asserted to leave the non-aligned manifest intact, then to fail decrypt and getPolicyId as InvalidFileError.
  • a spec-named manifest carrying only what its caller reads — a manifest holding only encryptionInformation.policy still yields its policy id, so the guard can't become a narrowing.

assertInvalidFile checks error.constructor === InvalidFileError, not instanceof — DecryptError, IntegrityError and UnsafeUrlError all extend it.

Mutation Result
isManifest returns true (behavior before this PR) 9 failing
guard widened to demand keyAccess + integrityInformation 1 failing
JSON.parse error rethrown raw 4 failing
$ cd lib && npm run test:mocha     # 449 passing, 0 failing
$ cd lib && npm run test:wtr       # 253 passed, 15 files
$ cd lib && npm run build          # clean
$ cd lib && npm run lint           # clean
$ cd lib && npm run license-check  # clean

Not run: cli/web-app suites (untouched), Playwright and xtest (need a live platform).

Out of scope

Three sibling gaps in reading foreign archives, all pre-existing: 0.payload is hardcoded (manifest.payload.url has zero read sites), the central directory is parsed from a 1000-byte tail window, and DEFLATE is unsupported.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved compatibility with TDF archives that use either the standard or legacy manifest filename when reading, decrypting, and retrieving policy IDs.
    • Invalid or malformed manifests now produce clearer file errors. Archives without a recognized manifest continue to report it as missing.
    • New archives continue to use the existing legacy manifest filename.

pflynn-virtru and others added 4 commits September 16, 2026 13:29
The OpenTDF spec names the manifest entry `manifest.json` at the archive
root. This SDK looks it up as `0.manifest.json` by exact central-directory
match with no fallback, so a TDF produced by any implementation written
against the published spec is rejected outright with
`Unable to retrieve CD manifest`.

Resolve the entry through `manifestEntryName()`, which prefers the spec
name and falls back to the off-spec `0.manifest.json` this SDK writes.
Read side only: `writeStream` still emits `0.manifest.json`, so the file
format this SDK produces is unchanged and nothing downstream breaks.
Renaming what the writer emits is a breaking change, tracked separately.

Resolution runs against the central directory, not against a failed read.
That keeps other failures honest: an oversized manifest under the spec
name stays a size error rather than silently yielding the off-spec entry.

Refs opentdf/platform#3513.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Paul Flynn <pflynn-virtru@users.noreply.github.com>
Review of the first cut found the test named for this change's central
design decision did not pin it, and that none of the coverage survived
the follow-up writer flip.

The oversized-manifest test composed `manifestEntryName` and
`getManifest` itself instead of going through a call site, so it only
re-asserted ZipReader's size guard — already covered in zip.spec.ts.
Rewriting `loadTDFStream` as `try { spec } catch { off-spec }`, the exact
design the implementation comment rejects, left all ten tests green.
Replace it with an archive that carries a valid off-spec manifest plus an
oversized spec-named central-directory record shadowing it, and assert
`decrypt` and `getPolicyId` both fail on size. That reader now fails two
tests.

Reading `0.manifest.json` was covered only incidentally, because the
writer still emits it; flipping the writer left the off-spec name
untested. Generate both fixtures from one table so each name is exercised
explicitly under either writer.

`renameManifestEntry` matched only the off-spec name, so after that flip
it would have become an identity rebuild and the guard test asserting the
fixture was really renamed would have passed vacuously. It now throws
unless exactly one manifest entry matched.

Also: compare the policy id to the original archive's rather than
asserting it is a string; slice stored bytes with `compressedSize`; pin
the missing-manifest error; drop the file comment's claim to cover
spec-conforming producers generally, which the fixtures cannot support.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Paul Flynn <pflynn-virtru@users.noreply.github.com>
Code review found the two roles collapsed into one constant. Reading
`0.manifest.json` and writing it are different decisions, but
`offspecManifestFileName` served both, and its docstring invited exactly
the edit — repoint it at the spec name — that would have left
`manifestEntryName` returning the spec name on both branches and made
every archive this SDK has ever written unreadable.

Give the writer its own binding, `manifestEntryNameToWrite`. Flipping it
is now the whole of the writer change and cannot disturb the read side.

The guard against that mistake was the writer tripwire, and it was blind:
it asserted against `offspecManifestFileName` rather than the literal, as
did every other assertion in the suite, so a suite built out of those
constants could not notice them changing. Collapsing the constant used to
leave the tripwire green while the writer emitted the wrong name; the
spec now spells both names out and that mutation fails five tests,
including the tripwire and the whole off-spec read group.

Also: `Client.getPolicyId` was a statement-for-statement copy of
`loadTDFStream`, which it can just call — the duplication is why this
branch had to make the same change in two files; drop `entry()`'s unused
`overrides` parameter; correct `renameManifestEntry`'s docstring, which
promised a rename its guard does not check for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Paul Flynn <pflynn-virtru@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Sep 18, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: f5ee4777-0ce0-4a8a-9d8f-4ec509c351e4

📥 Commits

Reviewing files that changed from the base of the PR and between 55a0521 and aa966c5.

📒 Files selected for processing (5)
  • lib/tdf3/src/client/index.ts
  • lib/tdf3/src/models/manifest.ts
  • lib/tdf3/src/tdf.ts
  • lib/tdf3/src/utils/zip-reader.ts
  • lib/tests/mocha/manifest-entry-name.spec.ts

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

TDF reading now supports both manifest.json and 0.manifest.json. Manifest JSON is validated before use, and Client.getPolicyId retrieves the manifest through loadTDFStream.

Changes

TDF manifest reading

Layer / File(s) Summary
Manifest entry resolution
lib/tdf3/src/tdf.ts, lib/tests/mocha/manifest-entry-name.spec.ts
The reader selects manifest.json when it is present and otherwise selects 0.manifest.json. The writer continues to use 0.manifest.json. Tests cover name selection, missing names, entry-size precedence, and writer output.
Manifest validation
lib/tdf3/src/models/manifest.ts, lib/tdf3/src/utils/zip-reader.ts, lib/tests/mocha/manifest-entry-name.spec.ts
The new isManifest guard checks the value and its encryptionInformation property. ZipReader.getManifest reports JSON parse failures and invalid manifest shapes as InvalidFileError. Tests cover invalid and incomplete manifest data.
Policy ID loading
lib/tdf3/src/client/index.ts, lib/tests/mocha/manifest-entry-name.spec.ts
Client.getPolicyId now reads the manifest through loadTDFStream. Tests cover both manifest names, callback use, and policy ID extraction from a manifest with limited fields.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant Archive
  participant loadTDFStream
  participant manifestEntryName
  participant Client
  Archive->>loadTDFStream: supplies TDF archive
  loadTDFStream->>manifestEntryName: passes central directory
  manifestEntryName-->>loadTDFStream: returns selected manifest entry name
  loadTDFStream-->>Client: returns loaded manifest
Loading

Suggested reviewers: dmihalcik-virtru

Merge Risk: ⚪ Minimal · up to aa966

The change supports both manifest names while preserving legacy writes and reports malformed manifests as invalid files. No merge-blocking issue is established; merge after normal checks pass.

Security Architecture Review

Security architecture risk: 🔵 Low · up to aa966

Invalid archive entries are rejected more consistently, and no new authentication bypass was demonstrated. Policy identifiers remain unverified metadata, however, and whether callers rely on them for authorization is not established.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — A party controlling an input archive can influence the selected manifest, parsing outcome, and returned policy identifier. The inspected change does not establish broader tenant, service, or data-store authority; exposure depends on how applications consume those results.

Security Findings and Attack Paths

  • observed — getPolicyId returns policy.uuid without invoking authenticated decryption or integrity verification. The supplied before-change description shows that unauthenticated metadata extraction predates this fix. No evidence establishes downstream use of this value as an authorization decision.

Trust Boundaries and Controls

  • observed — The inspected decryption path supplies configured authentication and KAS-origin enforcement to key unwrapping, uses authenticated signed KAS requests, and rejects a root-integrity mismatch before payload streaming. The weak manifest guard does not replace these controls.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 16 functions across 5 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: reporting malformed manifests as invalid files in the SDK.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch fix/validate-manifest-shape
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

A rabbit checks the archive’s name,
Two manifest paths enter the game.
The reader guards each parsed page,
Policy IDs arrive on stage.
Hop, hop, the tests confirm the flow!

Comment @coderabbitai help to get the list of available commands.

Refs opentdf/platform#3513. Follow-up to #1042, stacked on it.

`getManifest` resolves the manifest entry by name and returns
`JSON.parse` of whatever it finds, so a `manifest.json` holding something
else surfaces as a raw `TypeError` or `SyntaxError` — outside the
`TdfError` hierarchy, and missed by callers catching it. Wrap the parse
and gate on `isManifest`.

The guard is deliberately weak: it rejects only what every caller would
crash on. `getPolicyId` needs just `encryptionInformation.policy`, so
demanding `keyAccess` or `integrityInformation` would narrow a reader
#1042 just widened. An earlier revision did demand them; the
`carrying only what its caller reads` test caught it.

22 tests added to the #1042 suite. Mutation-tested:

  isManifest always true (behavior before this commit)   9 failing
  guard widened to demand keyAccess + integrityInfo      1 failing
  JSON.parse error rethrown raw                          4 failing

  $ cd lib && npm run test:mocha    # 447 passing, 0 failing
  $ cd lib && npm run test:wtr      # 253 passed, 15 files
  $ cd lib && npm run lint          # clean
  $ cd lib && npm run license-check # clean

Not run: cli/web-app suites (untouched), Playwright and xtest (need a
live platform).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Paul Flynn <pflynn-virtru@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown

Signed-off-by: Paul Flynn <pflynn-virtru@users.noreply.github.com>
Signed-off-by: Paul Flynn <pflynn-virtru@users.noreply.github.com>
Signed-off-by: Paul Flynn <pflynn-virtru@users.noreply.github.com>
Signed-off-by: Paul Flynn <pflynn-virtru@users.noreply.github.com>
# Conflicts:
#	lib/tdf3/src/utils/zip-reader.ts
@github-actions

Copy link
Copy Markdown

@pflynn-virtru
pflynn-virtru marked this pull request as ready for review September 23, 2026 15:01
@pflynn-virtru
pflynn-virtru requested a review from a team as a code owner September 23, 2026 15:01
Reword comments and test descriptions that call the legacy
0.manifest.json entry "off-spec". Wording only; no behavior change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Paul Flynn <pflynn-virtru@users.noreply.github.com>
@sonarqubecloud

Copy link
Copy Markdown

Base automatically changed from fix/read-spec-manifest-entry to main October 1, 2026 14:53

This branch has not been deployed

No deployments
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.

1 participant