Skip to content

feat(error): populate decode/encode error offsets from cursor positions#1275

Open
Greg Lamberson (glamberson) wants to merge 2 commits into
Devolutions:masterfrom
lamco-admin:feat/ironrdp-error-offset-sweep
Open

feat(error): populate decode/encode error offsets from cursor positions#1275
Greg Lamberson (glamberson) wants to merge 2 commits into
Devolutions:masterfrom
lamco-admin:feat/ironrdp-error-offset-sweep

Conversation

@glamberson
Copy link
Copy Markdown
Contributor

Depends on #1266. This PR stacks on the foundational reshape; the GitHub diff will collapse once #1266 merges. Both branches share base commit e935058c.

Summary

Audit-driven workspace sweep populating the offset field on every decode and encode error variant introduced by #1266. Where #1266 documented 0 as the "offset unknown" sentinel and left existing call sites passing it, this PR walks the workspace and replaces it with cursor.pos() wherever a ReadCursor or WriteCursor is in lexical scope.

Kept separate from #1266 so the foundational error-shape change stays a reviewable single-purpose PR.

Methodology

Per-site classification, not a heuristic substitution. For each call site that touches the new offset parameter:

  1. Find the immediate enclosing fn (brace-depth walk, not a regex over the file).
  2. Inspect that fn's signature for a &mut ReadCursor<...> or &mut WriteCursor<...> parameter.
  3. If exactly one cursor is in scope, substitute its actual parameter name (the BER routines use stream, for example, not src).
  4. If no cursor is in scope, leave the site at at: 0,. The function genuinely has no buffer to position-from.
  5. If the site is inside a macro_rules! body, handle the macro individually (adapt its signature to accept an offset, update each caller with the offset reachable at that call site).

Audit script lives in the PR description history if you want to reproduce: walks all at: 0, macro forms plus direct calls to invalid_field_err(..., 0), unexpected_message_type_err(..., 0), unsupported_value_err(..., 0), and invalid_field_err_with_source(..., 0, e).

Coverage

408 call sites reviewed in total:

Category Count Disposition
Macro form at: 0, in a cursor-bearing fn 333 Upgraded to in: <cursor> using the parameter's actual name
Macro form at: 0, with no cursor in the enclosing fn 57 Left at at: 0 (try_from impls on enum discriminants, new/with_* constructors, accessors on already-decoded structs, dimension validators, find_size(&[u8]))
Inside a macro_rules! body 9 Local macros adapted; see "Macro adapters" below
Direct function-call sites the macro-form audit missed 9 8 upgraded to cursor.pos(), 1 (find_size) stays 0

Macro adapters

Three local helper macros hardcoded 0 as the offset and were invoked from multiple call sites with differing cursor availability. Each macro was adapted to take an explicit offset argument; each caller now passes either 0 (no cursor in scope) or cursor.pos() (cursor in scope):

  • validate_dimensions! in ironrdp-displaycontrol/src/pdu/mod.rs. Two callers: constructor passes 0, decode passes src.pos().
  • check_masks_alignment! in ironrdp-pdu/src/basic_output/pointer/mod.rs. Four callers, all inside encode or decode methods.
  • per_field_err! in ironrdp-pdu/src/mcs.rs. This one captures cursor position into the returned closure (move |error| invalid_field_err_with_source(..., offset, error)) so PER read/write failures carry the byte offset of the field that failed. All 26 callers updated to pass in: src, or in: dst, depending on whether they're in a per::read_* or per::write_* chain.

Foundation-crate macro tightening

The bare forms of three macros were removed so the discipline is enforced at compile time rather than reviewed by hand:

  • ensure_size!: lost its bare buffer form; the remaining form always extracts $buf.pos(). To make this work, encode_string was refactored from dst: &mut [u8] to dst: &mut WriteCursor<'_>. The two callers using the slice form (cliprdr/format_data/file_list.rs and rdp/capability_sets/input.rs) were updated to pass the cursor directly and use write_padding! for fixed-width fields. This is the only user-visible API change in this PR.
  • cast_length! and cast_int!: lost their bare forms. Callers must pick in: <cursor> to extract the position or at: <offset> to pass one explicitly. Doc comments updated.

Test snapshots

expect_test fixtures that print full Error debug output were regenerated via UPDATE_EXPECT=1 and now record the real byte offsets where decode failures originate. Examples:

Test New offset
pcb::null_size 4 (after the u32 cbSize read)
pcb::truncated 16
pcb::pcb_v2_string_too_big 18
x224::nego_request_unexpected_rdp_msg_type 35
x224::nego_confirm_unexpected_rdp_msg_type 12
x224::cookie_without_cr_lf_error_decode 20

Diff

Single commit deb0ac85 on top of #1266's e935058c. 101 files changed, +788 / -796 (the negative delta is real: removing the bare-form macro arms shrinks macros.rs, and many call sites became shorter after the cursor extraction).

CI

cargo xtask check fmt | lints | locks | typos | tests green. cargo test --workspace --exclude ironrdp-web --exclude ironrdp-bench --tests green.

Refs #1266, #1120.

Comment on lines 116 to 121
fn channel_by_id(&mut self, id: u32) -> DecodeResult<&mut DynamicChannel> {
let id = cast_length!("DRDYNVC", "", id)?;
let id = cast_length!(at: 0, "DRDYNVC", "", id)?;
self.dynamic_channels
.get_mut(id)
.ok_or_else(|| invalid_field_err!("DRDYNVC", "", "invalid channel id"))
.ok_or_else(|| invalid_field_err!(at: 0, "DRDYNVC", "", "invalid channel id"))
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

note: Here, we are using 0 for the offset because there is no relevant byte offset. In fact, we’re not even decoding anything, suggesting this function should not really return a DecodeResult. It’s fine to keep it that way with a dummy value for now, but we need to keep this in mind when performing this migration: we’ll have to change the error type of some functions, and we should not accomodate them too much in DecodeErrorKind

@glamberson Greg Lamberson (glamberson) force-pushed the feat/ironrdp-error-offset-sweep branch 2 times, most recently from df0cec0 to c18bd52 Compare May 15, 2026 11:45
Reshapes the position-aware error approach per Devolutions#1266 review feedback. Moves
position metadata out of the foundational `ironrdp-error` crate and onto the
decode and encode `*ErrorKind` enum variants where it actually applies.

Previously this PR added an optional `ErrorPosition { offset, field }` slot
to `Error<Kind>`'s `ErrorMeta`, paying a metadata slot on every error in the
workspace to serve only the decode and encode surfaces. The new shape places
a non-optional `offset: usize` on each variant that can know one:

- `DecodeErrorKind::{NotEnoughBytes, InvalidField, UnexpectedMessageType,
  UnsupportedVersion, UnsupportedValue}` gain `offset: usize`
- `EncodeErrorKind::*` mirrors the same shape for encode-side errors
- `*::Other` is unchanged; producers of `Other` typically lack stream-cursor
  access and the variant exists precisely for those cases

The `in_field` dotted-path use case is dropped from this PR per the same
review: `Error::set_context` already covers it. No parallel structured
field-tracking channel is introduced.

Trait and macro changes:

- `NotEnoughBytesErr`, `InvalidFieldErr`, `UnexpectedMessageTypeErr`,
  `UnsupportedVersionErr`, `UnsupportedValueErr` all take an additional
  `offset: usize` parameter
- The five corresponding `*_err!` macros gain `at:` and `in:` prefixes for
  explicit offset and cursor-extracted offset respectively
- `OtherErr` and `other_err!` are unchanged
- `cast_length!` and `cast_int!` macros pass `0` to the new offset slot
  (the macros do not have stream-cursor access)

Call-site migration:

- All 84 affected files updated to pass an explicit offset
- Most existing call sites pass `0` (the documented "offset unknown" sentinel)
  since the cursor is not always in lexical scope at the call site
- Cursor-aware sites can opt in via the new `in: cursor` macro syntax
- A workspace sweep upgrading `at: 0` to `in: cursor` where applicable is a
  follow-up

Display output includes the offset when known:
`invalid \`field\` at offset 47: reason`

Refs Devolutions#1257, Devolutions#1120. Supersedes the cross-cutting `ErrorPosition` approach
that was on this branch before today's review.
Audit-driven sweep through every call site that passed `0` as the offset
to a decode or encode error producer. Foundation in Devolutions#1266 documented `0`
as the "offset unknown" sentinel; this commit replaces it with a real
cursor position wherever one is in scope.

Coverage: 408 call sites total reviewed via brace-depth enclosing-fn analysis.

- 333 macro-form sites in `invalid_field_err!`, `not_enough_bytes_err!`,
  `unexpected_message_type_err!`, `unsupported_version_err!`,
  `unsupported_value_err!`, `cast_length!`, `cast_int!` upgraded from
  `at: 0` to `in: <cursor>`. The cursor's name comes from the immediate
  enclosing function signature (most are `src`, several `dst`, a few
  `cursor`, and the BER routines use `stream`).
- 9 direct-function-call sites (`invalid_field_err(...)`,
  `unexpected_message_type_err(...)`, `unsupported_value_err(...)`,
  `invalid_field_err_with_source(...)`) updated to pass `src.pos()` or
  `dst.pos()` from the enclosing decode/encode method.
- The `per_field_err!` helper macro in `ironrdp-pdu/src/mcs.rs` now
  takes `in: $cursor` and captures the cursor's position into the
  returned closure, so PER read/write failures carry the byte offset
  of the field that failed. All 26 callers updated.
- The `validate_dimensions!` and `check_masks_alignment!` local macros
  in displaycontrol/pointer now take an explicit `$offset:expr`. Their
  call sites pass `0` from constructors and `<cursor>.pos()` from
  decode/encode paths.
- 57 sites remain at `at: 0`. These are in `try_from` impls on enum
  discriminants, `new`/`with_*` constructors, accessors on already
  decoded structs, dimension/size validators, and one `find_size`
  helper that takes `&[u8]` and reports byte-0 errors. None of them
  have a cursor in scope, so no offset is knowable.

Foundation-crate macros were tightened to enforce the discipline at
compile time:

- `ensure_size!`'s inner offset is now `$buf.pos()` instead of `0`,
  enabled by refactoring `encode_string` from `dst: &mut [u8]` to
  `dst: &mut WriteCursor<'_>` so the cursor invariant holds for every
  caller. The two slice-form callers (`cliprdr/format_data/file_list.rs`
  and `rdp/capability_sets/input.rs`) were updated to pass the cursor
  directly and use `write_padding!` for fixed-width fields.
- `cast_length!` and `cast_int!` no longer accept a bare form. Callers
  must pick `in: <cursor>` to extract the position or `at: <offset>`
  to pass one explicitly.
- Doc comments for `cast_length!` and `cast_int!` updated to show the
  new in:/at: forms.

Test snapshots that print full `Error` debug output were regenerated
through `UPDATE_EXPECT=1`; the new fixtures now record the real byte
offsets where decode failures originate. Examples: pcb null_size at
offset 4 (after the u32 cbSize read), pcb_v2_string_too_big at 18,
x224 nego_request_unexpected_rdp_msg_type at 35.

The `encode_string` API change is the only user-visible API change in
this commit beyond Devolutions#1266's new error variant fields.

Refs Devolutions#1266, Devolutions#1120.
@glamberson Greg Lamberson (glamberson) force-pushed the feat/ironrdp-error-offset-sweep branch from c18bd52 to 8943637 Compare May 15, 2026 17:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants