48d3a9d6da
Adds `Guid::parse_str(&str) -> Result<Guid, RpcError>` to `crates/mxaccess-rpc/src/guid.rs` as the inverse of the existing `Display` impl. Accepts the canonical dashed-hex form, optionally braced (.NET `B` format), case-insensitive, and tolerant of bare 32-char hex without dashes. Single-pass char-by-char nibble accumulator avoids per-byte string allocation; applies the same byte-swap of groups 1-3 that the `Display` impl reads. Eight new tests cover round-trip against the existing `Display` fixture (`crates/mxaccess-rpc/src/guid.rs:111-119`, `b49f92f7-c748-4169-8eca-a0670b012746`), braces, uppercase, no-dashes, zero-GUID, too-short, too-long, and non-hex rejection. The five live-NMX examples (`connect-write-read`, `subscribe`, `recovery`, `multi-tag`, `secured-write`) lose their per-file 15-line `parse_guid` helpers in favour of the canonical implementation. `asb-subscribe` and `subscribe-buffered` are unaffected — they don't parse GUIDs. Test count delta: 524 → 532 (+8) Open followups touched: F17 resolved. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
287 lines
9.0 KiB
Rust
287 lines
9.0 KiB
Rust
//! 16-byte GUID with .NET-compatible display.
|
|
//!
|
|
//! Hoisted from `objref::Guid` in M2 wave 2 — see `design/followups.md` F7.
|
|
//! Both `objref` (for `iid`/`ipid`) and `pdu` (for `SyntaxId` IIDs) and the
|
|
//! M2 wave 2 `orpc::OrpcThis::cid` / `object_exporter::*` / `rem_unknown::*`
|
|
//! types share this single representation rather than each rolling their own.
|
|
//!
|
|
//! Stored as 16 wire bytes. The first three groups on the wire are
|
|
//! little-endian (`Data1` u32 LE, `Data2` u16 LE, `Data3` u16 LE) followed by
|
|
//! 8 big-endian `Data4` bytes — the byte layout produced by .NET
|
|
//! `new Guid(ReadOnlySpan<byte>)` and consumed by `Guid.TryWriteBytes` (used
|
|
//! across the .NET reference, e.g. `ComObjRef.cs:31,36`,
|
|
//! `OrpcStructures.cs:48,127`, `RemUnknownMessages.cs:20,30`).
|
|
|
|
#![allow(clippy::indexing_slicing)]
|
|
|
|
/// 16-byte GUID. See module docs for byte layout.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
|
pub struct Guid(pub [u8; 16]);
|
|
|
|
impl Guid {
|
|
pub const ZERO: Guid = Guid([0u8; 16]);
|
|
|
|
pub const fn new(bytes: [u8; 16]) -> Self {
|
|
Self(bytes)
|
|
}
|
|
|
|
pub const fn as_bytes(&self) -> &[u8; 16] {
|
|
&self.0
|
|
}
|
|
|
|
/// Parse a `Guid` from a 16-byte little-endian-leading wire slice. Mirrors
|
|
/// the .NET `new Guid(span)` byte order.
|
|
///
|
|
/// # Errors
|
|
/// Returns [`crate::error::RpcError::ShortRead`] if `bytes.len() < 16`.
|
|
pub fn parse(bytes: &[u8]) -> Result<Self, crate::error::RpcError> {
|
|
if bytes.len() < 16 {
|
|
return Err(crate::error::RpcError::ShortRead {
|
|
expected: 16,
|
|
actual: bytes.len(),
|
|
});
|
|
}
|
|
let mut out = [0u8; 16];
|
|
out.copy_from_slice(&bytes[..16]);
|
|
Ok(Self(out))
|
|
}
|
|
|
|
/// Parse a `12345678-1234-1234-1234-123456789012` style GUID string
|
|
/// into wire-byte form. Inverse of the [`std::fmt::Display`] impl.
|
|
///
|
|
/// Accepts the canonical dashed-hex form, optionally wrapped in
|
|
/// `{...}` braces (the .NET `B` format). Case-insensitive. The
|
|
/// first three hex groups are stored little-endian on the wire (per
|
|
/// the module docstring) so the parser byte-swaps them after the
|
|
/// raw hex pass.
|
|
///
|
|
/// There is no .NET reference to mirror here — the Display impl is
|
|
/// the spec, this is its inverse.
|
|
///
|
|
/// # Errors
|
|
/// Returns [`crate::error::RpcError::Decode`] if the input is not
|
|
/// 32 hex chars (with 4 optional dashes and optional outer braces),
|
|
/// or contains a non-hex character.
|
|
pub fn parse_str(s: &str) -> Result<Self, crate::error::RpcError> {
|
|
let trimmed = s.trim_start_matches('{').trim_end_matches('}');
|
|
// Strip dashes; everything else must be a hex digit.
|
|
let mut bytes = [0u8; 16];
|
|
let mut nibble_count = 0usize;
|
|
let mut acc: u8 = 0;
|
|
for c in trimmed.chars() {
|
|
if c == '-' {
|
|
continue;
|
|
}
|
|
let digit = match c.to_digit(16) {
|
|
Some(d) => d as u8,
|
|
None => {
|
|
return Err(crate::error::RpcError::Decode {
|
|
offset: nibble_count / 2,
|
|
reason: "non-hex character in guid",
|
|
buffer_len: trimmed.len(),
|
|
});
|
|
}
|
|
};
|
|
if nibble_count >= 32 {
|
|
return Err(crate::error::RpcError::Decode {
|
|
offset: 16,
|
|
reason: "guid hex too long",
|
|
buffer_len: trimmed.len(),
|
|
});
|
|
}
|
|
if nibble_count % 2 == 0 {
|
|
acc = digit << 4;
|
|
} else {
|
|
bytes[nibble_count / 2] = acc | digit;
|
|
}
|
|
nibble_count += 1;
|
|
}
|
|
if nibble_count != 32 {
|
|
return Err(crate::error::RpcError::Decode {
|
|
offset: nibble_count / 2,
|
|
reason: "guid hex too short",
|
|
buffer_len: trimmed.len(),
|
|
});
|
|
}
|
|
// Byte-swap the first three groups so the resulting bytes match
|
|
// the wire layout the Display impl reads.
|
|
bytes[0..4].reverse();
|
|
bytes[4..6].reverse();
|
|
bytes[6..8].reverse();
|
|
Ok(Self(bytes))
|
|
}
|
|
|
|
/// Write the 16 wire bytes into `dst[..16]`. Mirrors .NET
|
|
/// `Guid.TryWriteBytes(span)`.
|
|
///
|
|
/// # Errors
|
|
/// Returns [`crate::error::RpcError::ShortRead`] if `dst.len() < 16`.
|
|
pub fn write_to(&self, dst: &mut [u8]) -> Result<(), crate::error::RpcError> {
|
|
if dst.len() < 16 {
|
|
return Err(crate::error::RpcError::ShortRead {
|
|
expected: 16,
|
|
actual: dst.len(),
|
|
});
|
|
}
|
|
dst[..16].copy_from_slice(&self.0);
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for Guid {
|
|
/// Mirrors .NET `Guid.ToString("D")`: dashed hex, lowercase, e.g.
|
|
/// `b49f92f7-c748-4169-8eca-a0670b012746`. The first three groups are
|
|
/// little-endian on the wire so are byte-swapped on display.
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
let b = &self.0;
|
|
write!(
|
|
f,
|
|
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
|
|
b[3],
|
|
b[2],
|
|
b[1],
|
|
b[0],
|
|
b[5],
|
|
b[4],
|
|
b[7],
|
|
b[6],
|
|
b[8],
|
|
b[9],
|
|
b[10],
|
|
b[11],
|
|
b[12],
|
|
b[13],
|
|
b[14],
|
|
b[15],
|
|
)
|
|
}
|
|
}
|
|
|
|
impl From<[u8; 16]> for Guid {
|
|
fn from(bytes: [u8; 16]) -> Self {
|
|
Self(bytes)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(
|
|
clippy::unwrap_used,
|
|
clippy::expect_used,
|
|
clippy::indexing_slicing,
|
|
clippy::panic
|
|
)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn display_matches_dotnet_d_format() {
|
|
// First 3 groups are byte-swapped on display (LE wire → BE display).
|
|
let g = Guid::new([
|
|
0xF7, 0x92, 0x9F, 0xB4, 0x48, 0xC7, 0x69, 0x41, 0x8E, 0xCA, 0xA0, 0x67, 0x0B, 0x01,
|
|
0x27, 0x46,
|
|
]);
|
|
assert_eq!(g.to_string(), "b49f92f7-c748-4169-8eca-a0670b012746");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_round_trip() {
|
|
let bytes = [0u8, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
|
|
let g = Guid::parse(&bytes).unwrap();
|
|
let mut out = [0u8; 16];
|
|
g.write_to(&mut out).unwrap();
|
|
assert_eq!(out, bytes);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_short_buffer_errors() {
|
|
assert!(matches!(
|
|
Guid::parse(&[0u8; 15]),
|
|
Err(crate::error::RpcError::ShortRead { .. })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn zero_guid() {
|
|
assert_eq!(
|
|
Guid::ZERO.to_string(),
|
|
"00000000-0000-0000-0000-000000000000"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_str_round_trips_display() {
|
|
// The dashed-hex form from the display fixture above.
|
|
let g = Guid::parse_str("b49f92f7-c748-4169-8eca-a0670b012746").unwrap();
|
|
assert_eq!(
|
|
g.0,
|
|
[
|
|
0xF7, 0x92, 0x9F, 0xB4, 0x48, 0xC7, 0x69, 0x41, 0x8E, 0xCA, 0xA0, 0x67, 0x0B, 0x01,
|
|
0x27, 0x46,
|
|
]
|
|
);
|
|
// Round-trip back via Display.
|
|
assert_eq!(g.to_string(), "b49f92f7-c748-4169-8eca-a0670b012746");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_str_accepts_braces() {
|
|
// .NET "B" format wraps the dashed-hex form in `{}`.
|
|
let g = Guid::parse_str("{b49f92f7-c748-4169-8eca-a0670b012746}").unwrap();
|
|
assert_eq!(g.to_string(), "b49f92f7-c748-4169-8eca-a0670b012746");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_str_accepts_uppercase() {
|
|
let g = Guid::parse_str("B49F92F7-C748-4169-8ECA-A0670B012746").unwrap();
|
|
assert_eq!(g.to_string(), "b49f92f7-c748-4169-8eca-a0670b012746");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_str_accepts_no_dashes() {
|
|
let g = Guid::parse_str("b49f92f7c74841698ecaa0670b012746").unwrap();
|
|
assert_eq!(g.to_string(), "b49f92f7-c748-4169-8eca-a0670b012746");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_str_round_trips_zero() {
|
|
let g = Guid::parse_str("00000000-0000-0000-0000-000000000000").unwrap();
|
|
assert_eq!(g, Guid::ZERO);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_str_rejects_too_short() {
|
|
let err = Guid::parse_str("b49f92f7-c748-4169-8eca-a0670b0127").unwrap_err();
|
|
assert!(matches!(
|
|
err,
|
|
crate::error::RpcError::Decode {
|
|
reason: "guid hex too short",
|
|
..
|
|
}
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_str_rejects_too_long() {
|
|
let err = Guid::parse_str("b49f92f7-c748-4169-8eca-a0670b01274600").unwrap_err();
|
|
assert!(matches!(
|
|
err,
|
|
crate::error::RpcError::Decode {
|
|
reason: "guid hex too long",
|
|
..
|
|
}
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_str_rejects_non_hex() {
|
|
let err = Guid::parse_str("b49f92f7-c748-4169-8eca-a0670b01274z").unwrap_err();
|
|
assert!(matches!(
|
|
err,
|
|
crate::error::RpcError::Decode {
|
|
reason: "non-hex character in guid",
|
|
..
|
|
}
|
|
));
|
|
}
|
|
}
|