Surface structured parse diagnostics from PolicySet.parsePolicies - #367
jamesmulcahy wants to merge 5 commits into
Conversation
Parse failures are reduced to format!("Internal JNI Error: {e}") in
jni_failed, discarding the miette diagnostic cedar produced: the source
span, the tokens the parser expected, and the help text. ParseErrors'
Display also prints only its first error, so subsequent errors are lost.
Add PolicyParseException, a subclass of InternalException carrying
List<DetailedError> - the same representation the validation path already
returns. parsePoliciesJni downcasts ParseErrors and converts each
ParseError via the existing From<&E: miette::Diagnostic> impl. If building
the richer exception fails for any reason the generic path is used, so a
parse error can never become a different kind of failure.
Existing catch (InternalException) blocks are unaffected. Two message
details change deliberately: the "Internal JNI Error: " prefix is dropped,
since it describes the binding rather than the policy and reads as a
library fault rather than a typo the caller can fix; and getErrors()
carries one entry per parse error rather than a single entry for the whole
document, which is what its plural contract always implied.
Signed-off-by: James Mulcahy <jmulcahy@netflix.com>
8d0587c to
5276059
Compare
|
Full disclosure -- I've not written any rust before myself, and Claude helped with this change. Background/motivation is well summarized by Claude above. The TL;DR is that the |
|
Hi @lianah @mark-creamer-amazon @muditchaudhary -- James from Netflix here, we met a few weeks ago! Our Cedar usage is going well, but we've run into some UX friction with policy validation that should be improved by this PR. Would appreciate your time in reviewing! Thank you! |
|
Hey James, I'll take a look today. Thanks for the PR! |
|
I agree with the direction of this PR, but I do worry about breaking existing consumers here.
I think I'm hesitant towards the |
[...]
Understood, I think that's a reasonable take. I'll update the PR to conform with your guidance. Thanks for the review! |
Review feedback on cedar-policy#367: dropping the "Internal JNI Error: " prefix from getMessage() is a breaking change. Consumers branch on that string and at least one matches it with an anchored regex, so it is effectively part of the API even though the prefix describes the binding rather than the policy. PolicyParseException now takes the message the generic path would have produced and passes it through, so getMessage() is byte-for-byte unchanged: "Internal error: Internal JNI Error: " followed by ParseErrors' Display, which prints the first error alone. internal_error_message is the single definition of that string, shared with the throw_internal fallback, so the two paths cannot drift. The added detail is reached through the accessors instead. getErrors() still carries one entry per parse error - the plurality its List contract always implied - each the bare Cedar message, and getDetailedErrors() carries the miette diagnostics. InternalException gains a protected constructor setting message and error list independently; the existing ones derive the message from the list, which would have widened it as the list was populated. messagesDropTheInternalJniErrorPrefix becomes messageIsUnchangedForBackCompat and now guards against the regression it previously asserted, and the multiple-error test pins the message to the first error alone. Also imports assertDoesNotThrow, and adds the CHANGELOG entry the PR was missing. Signed-off-by: James Mulcahy <jmulcahy@netflix.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@mark-creamer-amazon — Claude Code here, posting as James's agent; he's asked me to follow up on your review and has reviewed this reply before it went out. Two commits pushed, branch now at
|
The spans Cedar reports are UTF-8 byte offsets, but nothing said so
beyond "in bytes" on the two fields, and the obvious way to use them --
source.substring(start, end) -- is wrong the moment the policy text
contains a non-ASCII character. It does not fail quietly: on a document
with an accented identifier or an emoji in a comment it throws
StringIndexOutOfBoundsException, because the byte offset runs past the
end of the shorter UTF-16 string.
SourceLabel now carries a class-level explanation with the byte-slicing
snippet callers should use instead, and the field comments name the
offsets as UTF-8 and give their inclusivity. It also records three other
properties that are not apparent from the types, all confirmed against
the parser:
- offsets are absolute within the whole parsed text rather than
relative to the enclosing policy, so they stay usable when several
policies are parsed together, and they carry no policy identity of
their own;
- a span may be empty, which is what an unterminated string literal
produces, so a renderer must not assume a character to underline;
- a span covers the unexpected token, which for a missing operand is
the token that followed it, possibly on a later line.
PolicyParseException's own Javadoc described how the class differed from
the generic error path that preceded it, and said getErrors() "does
change" -- relative to a revision no reader of the released class will
have seen. Rewritten to say what the type is: a list contrasting the
three accessors by fidelity, which is what a caller needs in order to
choose between them. The rationale for freezing getMessage() stays in
the private Rust that builds it, where it warns whoever might
reasonably re-break it, rather than in public API documentation.
Also adds the whitespace checkstyleMain wants inside the empty
TypeReference body in PolicyParseException, which was failing the build.
Signed-off-by: James Mulcahy <jmulcahy@netflix.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ffdf01b to
c1eb82d
Compare
SpotBugs flagged the blanket `catch (Exception)` in readDetailedErrors (REC_CATCH_EXCEPTION). Catch JsonProcessingException and RuntimeException instead: same defensive behaviour, no catch of exceptions that cannot arise. Narrowing the catch exposed CT_CONSTRUCTOR_THROW, since the constructor can now throw and leave a partially initialised object. Make the class final, SpotBugs' remedy for that rule; it was never meant to be subclassed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: James Mulcahy <jmulcahy@netflix.com>
1e6a918 to
8632241
Compare
|
@mark-creamer-amazon I pushed a fix for the CI failure; if you can approve again I'm optimistic we'll get a clean run, but I'll keep an eye on it! |
| * Added Offset function support [#331](https://github.com/cedar-policy/cedar-java/pull/331) | ||
| * Added PolicySet to JSON conversion API [#329](https://github.com/cedar-policy/cedar-java/pull/329) | ||
| * Added Cedar Schema support for Entity Validation [#332](https://github.com/cedar-policy/cedar-java/pull/332) | ||
| * Added `PolicyParseException`, thrown by `PolicySet.parsePolicies` when policy text fails to parse. It is a subclass of `InternalException`, so existing `catch` blocks and `getMessage()` are unaffected, and adds `getDetailedErrors()` returning Cedar's structured diagnostics - source span, expected tokens, and help text - for each error. `getErrors()` now carries one entry per parse error rather than a single entry for the whole document [#367](https://github.com/cedar-policy/cedar-java/pull/367) |
There was a problem hiding this comment.
Note to myself: CHANGELOG.md in main is stale. The features under "Unreleased" are now released in CedarJava 4.8. I will update it and also place this change under the correct section.
There was a problem hiding this comment.
Regarding removing the prefix in getErrors, it seems fine to me. @mark-creamer-amazon what do you think? IIRC Cedar does not treat mutating error messages as a breaking change.
| fn throw_internal(env: &mut JNIEnv<'_>, errs: &ParseErrors) { | ||
| // We have to unwrap here as we're doing exception handling | ||
| // If we don't have the heap space to create an exception, the only valid move is ending the process | ||
| env.throw_new( |
There was a problem hiding this comment.
throw_internal can be reached with a Java exception already pending: every ? in build_parse_exception leaves one parked when it fails, although this is rare (E.g., new_object -> NoClassDefFoundError if the .jar predates the .so, new_string/new_object_array -> OutOfMemoryError).
In these cases throw_new then returns Err(Error::JavaException) because the crate correctly refuses to call JNI in that state, and .unwrap() panics which I believe would abort the JVM.
One fix would be to reuse jni_failed, which already guards on exception_check() and formats the identical message or add env.exception_check().unwrap_or_default() check here.
Rest of the PR looks good to me. Thank you for working on this!
There was a problem hiding this comment.
Good catch — you're right, and thank you for tracing it so precisely. Fixed in 129ef7d.
Confirming the chain for the record: each ? in build_parse_exception bottoms out in a jni-rs call whose jni_non_null_call!/jni_non_void_call! macro ends in check_exception!, which returns Error::JavaException while leaving the exception pending — it detects, it doesn't clear. throw_internal then resolves its class argument through Desc::lookup → find_class, which hits that same check and returns Err before ThrowNew is ever reached. The .unwrap() panics, and since jni_fn expands parsePoliciesJni to an extern "C" function, unwinding across that boundary aborts the process. So a recoverable NoClassDefFoundError from a .jar/.so version skew — the realistic trigger — took the JVM down.
I went with the exception_check() guard inside throw_internal rather than reusing jni_failed, because jni_failed's signature isn't quite the right fit here: it takes &dyn Error and returns a jvalue, neither of which this call site wants (it's already holding a &ParseErrors, and it's on a path that returns ()), so routing through it needed more adaptation at the call sites than the guard itself costs. Putting the guard in throw_internal rather than at the two call sites also covers the env.throw(exception).is_err() branch — benign today, since throw on an already-constructed JThrowable does no checked lookup, but it would be a latent instance of the same bug. Entirely happy to switch to jni_failed if you'd prefer the single implementation; it's a small change either way.
One note on the resulting behaviour, which I think is a slight improvement beyond the crash fix: returning early means the pending OutOfMemoryError/NoClassDefFoundError propagates to the caller when the native method returns, rather than being replaced by an InternalException whose message names a parse error. The original error is much more diagnostic for that class of failure. I've written that reasoning into the comment on the guard, along with why the unwrap below it is sound once the guard is in place — the existing "only valid move is ending the process" comment was inherited from jni_failed, where the guard is precisely what justifies it.
Testing
cargo test --features partial-eval: 71 passed, 0 failed.cargo fmt --check: clean. The two build warnings (unused variable: warnings,method get is never used) are pre-existing onmain../gradlew check: clean, SpotBugs and Checkstyle included.- Full
CedarJavasuite: 68,030 tests, 58 failures — all inSharedIntegrationTests, all the sameunable to find an applicable action given the policy scope constraintsassertion. I re-ran with this commit reverted and got an identical 58, and the branch touches no corpus resources or validation code, so they're pre-existing in my environment rather than anything from this PR. (My earlier "68,723 tests, 0 failures" was from an environment where that corpus resolved differently; CI is the authority here.)
Same local-build caveat as before: host cargo build --release --features partial-eval with the .so staged manually, as zig isn't available to me for the zigbuild cross-compile path.
I'll leave the CHANGELOG placement to you as you noted, and the getErrors() prefix question with you and @mark-creamer-amazon — happy to restore the prefix if you'd rather keep those strings frozen.
There was a problem hiding this comment.
Yikes, Claude's just way too verbose here -- my apologies. I'll vet it more closely next time. I asked it to mention two things, and it's written a thesis 🤦
Every `?` in `build_parse_exception` is a jni-rs call that can fail because the JVM threw — `NoClassDefFoundError` if the loaded `.jar` predates the `.so`, `OutOfMemoryError` from `new_string` or `new_object_array`. jni-rs detects the exception but leaves it pending, so the `Err(_) => throw_internal(...)` fallback ran with an exception in flight. There `throw_new` resolves its class through `find_class`, which refuses to call JNI in that state and returns `Error::JavaException`; unwrapping that panicked, and unwinding out of the `extern "C"` boundary `jni_fn` generates would abort the JVM. Guard on `exception_check` as `jni_failed` does. Returning is also the better behaviour: the pending error says more about the failure than an `InternalException` naming a parse error would, and it reaches the caller when the native method returns. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: James Mulcahy <jmulcahy@netflix.com>
129ef7d to
7bdffd7
Compare
Problem
PolicySet.parsePoliciesthrowsInternalExceptionwhose message isformat!("Internal JNI Error: {e}")(CedarJavaFFI/src/interface.rs, injni_failed). For a parse failure that discards everythingmietterecorded — the source span, the tokens the parser expected, the help text — and becauseParseErrors'Displayprints only its first error, every subsequent error is lost too.A policy author writing this:
sees only:
No location, and no hint that the mistake is a missing
action ==. The Rust CLI, on the same input:Measured against cedar-java 4.10.0:
Change
Everything needed already exists in the crate:
PolicySet::from_strreturnsParseErrors, which isIntoIterator<Item = ParseError>ParseErrorimplementsmiette::Diagnosticcedar_policy::ffi::DetailedErroralready hasimpl<E: miette::Diagnostic + ?Sized> From<&E>— the impl the validation path already usesSo
parsePoliciesJnidowncastsParseErrorsand throws a newPolicyParseException extends InternalExceptioncarryingList<DetailedError>, the same representationAuthorizationEngine.validatealready returns:Existing
catch (InternalException e)blocks are unaffected. If building the richer exception fails for any reason, the generic path is used, so a parse error can never turn into a different kind of failure.Two message changes, both up for discussion
getMessage()Internal error: Internal JNI Error: unexpected token ...Internal error: unexpected token ...getErrors()[Internal JNI Error: unexpected token ...][unexpected token ...]"Internal JNI Error: "prefix is dropped — it describes the binding rather than the policy, and reading "Internal error" for an ordinary typo suggests a fault in the library rather than something the caller can fix.getErrors()carries one entry per parse error rather than a single entry for the whole document, which is what its plural contract always implied.Both are independent of the diagnostics and easy to drop if you'd rather keep the strings frozen — happy to revise.
Breaking?
Not to any type or signature:
PolicyParseExceptionis a subclass and no existing method changes shape. Callers string-matching on the message text of a parse failure would see the two differences above.Testing
PolicyParseDiagnosticsTests— span covers the offending token, all errors reported not just the first, help text survives where Cedar supplies it, still catchable asInternalException, message/getErrors()strings pinned, valid policy sets unaffected.CedarJavasuite: 68,723 tests, 0 failures.cargo testinCedarJavaFFI: 69 passed.Local FFI builds used
cargo build --release --features partial-evalfor the host target rather than thecargo zigbuildcross-compile path, sincezigwas not available in my environment; CI exercises the normal path.Scope
Deliberately limited to
parsePolicies.Policy.parseStaticPolicy,Policy.parsePolicyTemplate,Schema.parseandPolicyFormatterlose detail the same way and would be natural follow-ups — kept out here to keep the change focused, per CONTRIBUTING.Related: #68 asks for the id of a malformed policy, which is adjacent but stops short of diagnostics.